mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 04:00:10 +00:00
Integrate JailJS for CSP-restricted execution in browser extension
Major changes: - Migrate browser-extension to use web-ui package (85% code reduction) - Add JailJS content script with ES6+ transform support - Expose DOM constructors (Event, KeyboardEvent, etc.) to JailJS - Support top-level await by wrapping code in async IIFE - Add returnFile() support in JailJS execution - Refactor KeyStore into pluggable storage-adapter pattern - Make ChatPanel configurable with sandboxUrlProvider and additionalTools - Update jailjs to 0.1.1 Files deleted (33 duplicate files): - All browser-extension components, dialogs, state, tools, utils - Now using web-ui versions via @mariozechner/pi-web-ui Files added: - packages/browser-extension/src/content.ts (JailJS content script) - packages/web-ui/src/state/storage-adapter.ts - packages/web-ui/src/state/key-store.ts Browser extension now has only 5 source files (down from 38).
This commit is contained in:
parent
f2eecb78d2
commit
aaea0f4600
61 changed files with 633 additions and 9270 deletions
69
package-lock.json
generated
69
package-lock.json
generated
|
|
@ -10,6 +10,9 @@
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@mariozechner/jailjs": "^0.1.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.1.4",
|
"@biomejs/biome": "^2.1.4",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
|
|
@ -31,6 +34,61 @@
|
||||||
"anthropic-ai-sdk": "bin/cli"
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/parser": {
|
||||||
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.28.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"parser": "bin/babel-parser.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/standalone": {
|
||||||
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-Qc1BNCfuJZBKs2SC5lqRmSYOw7Ka0X7urZQ7oVsGIax4eGDUIHX+CDg752N4jDxC2rbBh3li098ReGOtjT0x4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/types": {
|
||||||
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
|
"@babel/helper-validator-identifier": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.2.5",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.5.tgz",
|
||||||
|
|
@ -735,6 +793,16 @@
|
||||||
"@lit-labs/ssr-dom-shim": "^1.4.0"
|
"@lit-labs/ssr-dom-shim": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mariozechner/jailjs": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mariozechner/jailjs/-/jailjs-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-vC4+ZDNJRkQVCnBPKKmawWS1BIEhV34O7qphm3nWZYvQb5ikSfgpZY2SzKkJrAo6LKmQe8Jo4fvggu7fPGMcZg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.26.10",
|
||||||
|
"@babel/standalone": "^7.28.4",
|
||||||
|
"@babel/types": "^7.26.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mariozechner/mini-lit": {
|
"node_modules/@mariozechner/mini-lit": {
|
||||||
"version": "0.1.7",
|
"version": "0.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.1.7.tgz",
|
||||||
|
|
@ -5291,6 +5359,7 @@
|
||||||
"name": "@mariozechner/pi-reader-extension",
|
"name": "@mariozechner/pi-reader-extension",
|
||||||
"version": "0.5.43",
|
"version": "0.5.43",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mariozechner/jailjs": "^0.1.0",
|
||||||
"@mariozechner/mini-lit": "^0.1.7",
|
"@mariozechner/mini-lit": "^0.1.7",
|
||||||
"@mariozechner/pi-ai": "^0.5.43",
|
"@mariozechner/pi-ai": "^0.5.43",
|
||||||
"@mariozechner/pi-web-ui": "^0.5.43",
|
"@mariozechner/pi-web-ui": "^0.5.43",
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,8 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"version": "0.0.1"
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@mariozechner/jailjs": "^0.1.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
packages/browser-extension/dist-chrome.zip
Normal file
BIN
packages/browser-extension/dist-chrome.zip
Normal file
Binary file not shown.
|
|
@ -29,6 +29,14 @@
|
||||||
"http://localhost/*",
|
"http://localhost/*",
|
||||||
"http://127.0.0.1/*"
|
"http://127.0.0.1/*"
|
||||||
],
|
],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle",
|
||||||
|
"all_frames": false
|
||||||
|
}
|
||||||
|
],
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
"pages": ["sandbox.html"]
|
"pages": ["sandbox.html"]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@
|
||||||
"open_at_install": false
|
"open_at_install": false
|
||||||
},
|
},
|
||||||
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://cdn.skypack.dev; object-src 'self'",
|
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://cdn.skypack.dev; object-src 'self'",
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle",
|
||||||
|
"all_frames": false
|
||||||
|
}
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"activeTab",
|
"activeTab",
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@
|
||||||
"check": "npm run typecheck"
|
"check": "npm run typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mariozechner/jailjs": "^0.1.0",
|
||||||
"@mariozechner/mini-lit": "^0.1.7",
|
"@mariozechner/mini-lit": "^0.1.7",
|
||||||
"@mariozechner/pi-ai": "^0.5.43",
|
"@mariozechner/pi-ai": "^0.5.43",
|
||||||
|
"@mariozechner/pi-web-ui": "^0.5.43",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"js-interpreter": "^6.0.1",
|
"js-interpreter": "^6.0.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ const outDir = join(packageRoot, `dist-${targetBrowser}`);
|
||||||
|
|
||||||
const entryPoints = {
|
const entryPoints = {
|
||||||
sidepanel: join(packageRoot, "src/sidepanel.ts"),
|
sidepanel: join(packageRoot, "src/sidepanel.ts"),
|
||||||
background: join(packageRoot, "src/background.ts")
|
background: join(packageRoot, "src/background.ts"),
|
||||||
|
content: join(packageRoot, "src/content.ts")
|
||||||
};
|
};
|
||||||
|
|
||||||
rmSync(outDir, { recursive: true, force: true });
|
rmSync(outDir, { recursive: true, force: true });
|
||||||
|
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
import { Badge, html } from "@mariozechner/mini-lit";
|
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
import "./components/AgentInterface.js";
|
|
||||||
import { AgentSession, type AgentSessionState, type ThinkingLevel } from "./state/agent-session.js";
|
|
||||||
import { ArtifactsPanel } from "./tools/artifacts/index.js";
|
|
||||||
import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
|
|
||||||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
|
||||||
import { getAuthToken } from "./utils/auth-token.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() private session!: AgentSession;
|
|
||||||
@state() private artifactsPanel!: ArtifactsPanel;
|
|
||||||
@state() private hasArtifacts = false;
|
|
||||||
@state() private artifactCount = 0;
|
|
||||||
@state() private showArtifactsPanel = false;
|
|
||||||
@state() private windowWidth = window.innerWidth;
|
|
||||||
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
|
|
||||||
|
|
||||||
private resizeHandler = () => {
|
|
||||||
this.windowWidth = window.innerWidth;
|
|
||||||
this.requestUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
// Listen to window resize
|
|
||||||
window.addEventListener("resize", this.resizeHandler);
|
|
||||||
|
|
||||||
// Ensure panel fills height and allows flex layout
|
|
||||||
this.style.display = "flex";
|
|
||||||
this.style.flexDirection = "column";
|
|
||||||
this.style.height = "100%";
|
|
||||||
this.style.minHeight = "0";
|
|
||||||
|
|
||||||
// Create JavaScript REPL tool with attachments provider
|
|
||||||
const javascriptReplTool = createJavaScriptReplTool();
|
|
||||||
|
|
||||||
// Set up artifacts panel
|
|
||||||
this.artifactsPanel = new ArtifactsPanel();
|
|
||||||
registerToolRenderer("artifacts", this.artifactsPanel);
|
|
||||||
|
|
||||||
// Attachments provider for both REPL and artifacts
|
|
||||||
const getAttachments = () => {
|
|
||||||
// Get all attachments from conversation messages
|
|
||||||
const attachments: any[] = [];
|
|
||||||
for (const message of this.session.state.messages) {
|
|
||||||
if (message.role === "user") {
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [message.content];
|
|
||||||
for (const block of content) {
|
|
||||||
if (typeof block !== "string" && block.type === "image") {
|
|
||||||
attachments.push({
|
|
||||||
id: `image-${attachments.length}`,
|
|
||||||
fileName: "image.png",
|
|
||||||
mimeType: block.mimeType || "image/png",
|
|
||||||
size: 0,
|
|
||||||
content: block.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return attachments;
|
|
||||||
};
|
|
||||||
|
|
||||||
javascriptReplTool.attachmentsProvider = getAttachments;
|
|
||||||
this.artifactsPanel.attachmentsProvider = getAttachments;
|
|
||||||
|
|
||||||
this.artifactsPanel.onArtifactsChange = () => {
|
|
||||||
const count = this.artifactsPanel.artifacts?.size ?? 0;
|
|
||||||
const created = count > this.artifactCount;
|
|
||||||
this.hasArtifacts = count > 0;
|
|
||||||
this.artifactCount = count;
|
|
||||||
|
|
||||||
// Auto-open when new artifacts are created
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
systemPrompt: this.systemPrompt,
|
|
||||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
|
||||||
tools: [browserJavaScriptTool, javascriptReplTool, this.artifactsPanel.tool],
|
|
||||||
thinkingLevel: "off" as ThinkingLevel,
|
|
||||||
messages: [],
|
|
||||||
} satisfies Partial<AgentSessionState>;
|
|
||||||
// initialState = { ...initialState, ...(simpleHtml as any) };
|
|
||||||
// initialState = { ...initialState, ...(longSession as any) };
|
|
||||||
|
|
||||||
// Create agent session first so attachments provider works
|
|
||||||
this.session = new AgentSession({
|
|
||||||
initialState,
|
|
||||||
authTokenProvider: async () => getAuthToken(),
|
|
||||||
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reconstruct artifacts panel from initial messages (session must exist first)
|
|
||||||
await this.artifactsPanel.reconstructFromMessages(initialState.messages);
|
|
||||||
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
window.removeEventListener("resize", this.resizeHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose method to toggle artifacts panel
|
|
||||||
public toggleArtifactsPanel() {
|
|
||||||
this.showArtifactsPanel = !this.showArtifactsPanel;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if artifacts panel is currently visible
|
|
||||||
public get artifactsPanelVisible(): boolean {
|
|
||||||
return this.showArtifactsPanel;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.session) {
|
|
||||||
return html`<div class="flex items-center justify-center h-full">
|
|
||||||
<div class="text-muted-foreground">Loading...</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%;"}">
|
|
||||||
<agent-interface
|
|
||||||
.session=${this.session}
|
|
||||||
.enableAttachments=${true}
|
|
||||||
.enableModelSelector=${true}
|
|
||||||
.enableThinking=${true}
|
|
||||||
.showThemeToggle=${false}
|
|
||||||
.showDebugToggle=${false}
|
|
||||||
></agent-interface>
|
|
||||||
</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>
|
|
||||||
${
|
|
||||||
this.artifactCount > 1
|
|
||||||
? html`<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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
|
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
|
||||||
@source "../../../node_modules/@mariozechner/mini-lit/dist";
|
@source "../../../node_modules/@mariozechner/mini-lit/dist";
|
||||||
|
|
||||||
|
/* Tell Tailwind to scan web-ui components */
|
||||||
|
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
|
||||||
|
@source "../../web-ui/src";
|
||||||
|
|
||||||
/* Import Tailwind */
|
/* Import Tailwind */
|
||||||
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
|
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
import { html } from "@mariozechner/mini-lit";
|
|
||||||
import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai";
|
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { customElement, property, query } from "lit/decorators.js";
|
|
||||||
import { ApiKeysDialog } from "../dialogs/ApiKeysDialog.js";
|
|
||||||
import { ModelSelector } from "../dialogs/ModelSelector.js";
|
|
||||||
import type { MessageEditor } from "./MessageEditor.js";
|
|
||||||
import "./MessageEditor.js";
|
|
||||||
import "./MessageList.js";
|
|
||||||
import "./Messages.js"; // Import for side effects to register the custom elements
|
|
||||||
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
|
|
||||||
import { keyStore } from "../state/KeyStore.js";
|
|
||||||
import "./StreamingMessageContainer.js";
|
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
import { formatUsage } from "../utils/format.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
|
|
||||||
|
|
||||||
@customElement("agent-interface")
|
|
||||||
export class AgentInterface extends LitElement {
|
|
||||||
// Optional external session: when provided, this component becomes a view over the session
|
|
||||||
@property({ attribute: false }) session?: AgentSession;
|
|
||||||
@property() enableAttachments = true;
|
|
||||||
@property() enableModelSelector = true;
|
|
||||||
@property() enableThinking = true;
|
|
||||||
@property() showThemeToggle = false;
|
|
||||||
@property() showDebugToggle = false;
|
|
||||||
|
|
||||||
// References
|
|
||||||
@query("message-editor") private _messageEditor!: MessageEditor;
|
|
||||||
@query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer;
|
|
||||||
|
|
||||||
private _autoScroll = true;
|
|
||||||
private _lastScrollTop = 0;
|
|
||||||
private _lastClientHeight = 0;
|
|
||||||
private _scrollContainer?: HTMLElement;
|
|
||||||
private _resizeObserver?: ResizeObserver;
|
|
||||||
private _unsubscribeSession?: () => void;
|
|
||||||
|
|
||||||
public setInput(text: string, attachments?: Attachment[]) {
|
|
||||||
const update = () => {
|
|
||||||
if (!this._messageEditor) requestAnimationFrame(update);
|
|
||||||
else {
|
|
||||||
this._messageEditor.value = text;
|
|
||||||
this._messageEditor.attachments = attachments || [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
this.style.display = "flex";
|
|
||||||
this.style.flexDirection = "column";
|
|
||||||
this.style.height = "100%";
|
|
||||||
this.style.minHeight = "0";
|
|
||||||
|
|
||||||
// Wait for first render to get scroll container
|
|
||||||
await this.updateComplete;
|
|
||||||
this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement;
|
|
||||||
|
|
||||||
if (this._scrollContainer) {
|
|
||||||
// Set up ResizeObserver to detect content changes
|
|
||||||
this._resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (this._autoScroll && this._scrollContainer) {
|
|
||||||
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Observe the content container inside the scroll container
|
|
||||||
const contentContainer = this._scrollContainer.querySelector(".max-w-3xl");
|
|
||||||
if (contentContainer) {
|
|
||||||
this._resizeObserver.observe(contentContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up scroll listener with better detection
|
|
||||||
this._scrollContainer.addEventListener("scroll", this._handleScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to external session if provided
|
|
||||||
this.setupSessionSubscription();
|
|
||||||
|
|
||||||
// Attach debug listener if session provided
|
|
||||||
if (this.session) {
|
|
||||||
this.session = this.session; // explicitly set to trigger subscription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
|
|
||||||
// Clean up observers and listeners
|
|
||||||
if (this._resizeObserver) {
|
|
||||||
this._resizeObserver.disconnect();
|
|
||||||
this._resizeObserver = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._scrollContainer) {
|
|
||||||
this._scrollContainer.removeEventListener("scroll", this._handleScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._unsubscribeSession) {
|
|
||||||
this._unsubscribeSession();
|
|
||||||
this._unsubscribeSession = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupSessionSubscription() {
|
|
||||||
if (this._unsubscribeSession) {
|
|
||||||
this._unsubscribeSession();
|
|
||||||
this._unsubscribeSession = undefined;
|
|
||||||
}
|
|
||||||
if (!this.session) return;
|
|
||||||
this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
|
|
||||||
if (ev.type === "state-update") {
|
|
||||||
if (this._streamingContainer) {
|
|
||||||
this._streamingContainer.isStreaming = ev.state.isStreaming;
|
|
||||||
this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming);
|
|
||||||
}
|
|
||||||
this.requestUpdate();
|
|
||||||
} else if (ev.type === "error-no-model") {
|
|
||||||
// TODO show some UI feedback
|
|
||||||
} else if (ev.type === "error-no-api-key") {
|
|
||||||
// Open API keys dialog to configure the missing key
|
|
||||||
ApiKeysDialog.open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleScroll = (_ev: any) => {
|
|
||||||
if (!this._scrollContainer) return;
|
|
||||||
|
|
||||||
const currentScrollTop = this._scrollContainer.scrollTop;
|
|
||||||
const scrollHeight = this._scrollContainer.scrollHeight;
|
|
||||||
const clientHeight = this._scrollContainer.clientHeight;
|
|
||||||
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
|
|
||||||
|
|
||||||
// Ignore relayout due to message editor getting pushed up by stats
|
|
||||||
if (clientHeight < this._lastClientHeight) {
|
|
||||||
this._lastClientHeight = clientHeight;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only disable auto-scroll if user scrolled UP or is far from bottom
|
|
||||||
if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) {
|
|
||||||
this._autoScroll = false;
|
|
||||||
} else if (distanceFromBottom < 10) {
|
|
||||||
// Re-enable if very close to bottom
|
|
||||||
this._autoScroll = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lastScrollTop = currentScrollTop;
|
|
||||||
this._lastClientHeight = clientHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
public async sendMessage(input: string, attachments?: Attachment[]) {
|
|
||||||
if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return;
|
|
||||||
const session = this.session;
|
|
||||||
if (!session) throw new Error("No session set on AgentInterface");
|
|
||||||
if (!session.state.model) throw new Error("No model set on AgentInterface");
|
|
||||||
|
|
||||||
// Check if API key exists for the provider (only needed in direct mode)
|
|
||||||
const provider = session.state.model.provider;
|
|
||||||
let apiKey = await keyStore.getKey(provider);
|
|
||||||
|
|
||||||
// If no API key, open the API keys dialog
|
|
||||||
if (!apiKey) {
|
|
||||||
await ApiKeysDialog.open();
|
|
||||||
// Check again after dialog closes
|
|
||||||
apiKey = await keyStore.getKey(provider);
|
|
||||||
// If still no API key, abort the send
|
|
||||||
if (!apiKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only clear editor after we know we can send
|
|
||||||
this._messageEditor.value = "";
|
|
||||||
this._messageEditor.attachments = [];
|
|
||||||
this._autoScroll = true; // Enable auto-scroll when sending a message
|
|
||||||
|
|
||||||
await this.session?.prompt(input, attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderMessages() {
|
|
||||||
if (!this.session)
|
|
||||||
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session available")}</div>`;
|
|
||||||
const state = this.session.state;
|
|
||||||
// Build a map of tool results to allow inline rendering in assistant messages
|
|
||||||
const toolResultsById = new Map<string, ToolResultMessage<any>>();
|
|
||||||
for (const message of state.messages) {
|
|
||||||
if (message.role === "toolResult") {
|
|
||||||
toolResultsById.set(message.toolCallId, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<!-- Stable messages list - won't re-render during streaming -->
|
|
||||||
<message-list
|
|
||||||
.messages=${this.session.state.messages}
|
|
||||||
.tools=${state.tools}
|
|
||||||
.pendingToolCalls=${this.session ? this.session.state.pendingToolCalls : new Set<string>()}
|
|
||||||
.isStreaming=${state.isStreaming}
|
|
||||||
></message-list>
|
|
||||||
|
|
||||||
<!-- Streaming message container - manages its own updates -->
|
|
||||||
<streaming-message-container
|
|
||||||
class="${state.isStreaming ? "" : "hidden"}"
|
|
||||||
.tools=${state.tools}
|
|
||||||
.isStreaming=${state.isStreaming}
|
|
||||||
.pendingToolCalls=${state.pendingToolCalls}
|
|
||||||
.toolResultsById=${toolResultsById}
|
|
||||||
></streaming-message-container>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderStats() {
|
|
||||||
if (!this.session) return html`<div class="text-xs h-5"></div>`;
|
|
||||||
|
|
||||||
const state = this.session.state;
|
|
||||||
const totals = state.messages
|
|
||||||
.filter((m) => m.role === "assistant")
|
|
||||||
.reduce(
|
|
||||||
(acc, msg: any) => {
|
|
||||||
const usage = msg.usage;
|
|
||||||
if (usage) {
|
|
||||||
acc.input += usage.input;
|
|
||||||
acc.output += usage.output;
|
|
||||||
acc.cacheRead += usage.cacheRead;
|
|
||||||
acc.cacheWrite += usage.cacheWrite;
|
|
||||||
acc.cost.total += usage.cost.total;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
} satisfies Usage,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
|
|
||||||
const totalsText = hasTotals ? formatUsage(totals) : "";
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="text-xs text-muted-foreground flex justify-between items-center h-5">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
${this.showThemeToggle ? html`<theme-toggle></theme-toggle>` : html``}
|
|
||||||
</div>
|
|
||||||
<div class="flex ml-auto items-center gap-3">${totalsText ? html`<span>${totalsText}</span>` : ""}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
if (!this.session)
|
|
||||||
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session set")}</div>`;
|
|
||||||
|
|
||||||
const session = this.session;
|
|
||||||
const state = this.session.state;
|
|
||||||
return html`
|
|
||||||
<div class="flex flex-col h-full bg-background text-foreground">
|
|
||||||
<!-- Messages Area -->
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
<div class="max-w-3xl mx-auto p-4 pb-0">${this.renderMessages()}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Input Area -->
|
|
||||||
<div class="shrink-0">
|
|
||||||
<div class="max-w-3xl mx-auto px-2">
|
|
||||||
<message-editor
|
|
||||||
.isStreaming=${state.isStreaming}
|
|
||||||
.currentModel=${state.model}
|
|
||||||
.thinkingLevel=${state.thinkingLevel}
|
|
||||||
.showAttachmentButton=${this.enableAttachments}
|
|
||||||
.showModelSelector=${this.enableModelSelector}
|
|
||||||
.showThinking=${this.enableThinking}
|
|
||||||
.onSend=${(input: string, attachments: Attachment[]) => {
|
|
||||||
this.sendMessage(input, attachments);
|
|
||||||
}}
|
|
||||||
.onAbort=${() => session.abort()}
|
|
||||||
.onModelSelect=${() => {
|
|
||||||
ModelSelector.open(state.model, (model) => session.setModel(model));
|
|
||||||
}}
|
|
||||||
.onThinkingChange=${
|
|
||||||
this.enableThinking
|
|
||||||
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
|
|
||||||
session.setThinkingLevel(level);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
></message-editor>
|
|
||||||
${this.renderStats()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register custom element with guard
|
|
||||||
if (!customElements.get("agent-interface")) {
|
|
||||||
customElements.define("agent-interface", AgentInterface);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { html, icon } from "@mariozechner/mini-lit";
|
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { customElement, property } from "lit/decorators.js";
|
|
||||||
import { FileSpreadsheet, FileText, X } from "lucide";
|
|
||||||
import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
|
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
@customElement("attachment-tile")
|
|
||||||
export class AttachmentTile extends LitElement {
|
|
||||||
@property({ type: Object }) attachment!: Attachment;
|
|
||||||
@property({ type: Boolean }) showDelete = false;
|
|
||||||
@property() onDelete?: () => void;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
this.classList.add("max-h-16");
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleClick = () => {
|
|
||||||
AttachmentOverlay.open(this.attachment);
|
|
||||||
};
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
const hasPreview = !!this.attachment.preview;
|
|
||||||
const isImage = this.attachment.type === "image";
|
|
||||||
const isPdf = this.attachment.mimeType === "application/pdf";
|
|
||||||
const isDocx =
|
|
||||||
this.attachment.mimeType?.includes("wordprocessingml") ||
|
|
||||||
this.attachment.fileName.toLowerCase().endsWith(".docx");
|
|
||||||
const isPptx =
|
|
||||||
this.attachment.mimeType?.includes("presentationml") ||
|
|
||||||
this.attachment.fileName.toLowerCase().endsWith(".pptx");
|
|
||||||
const isExcel =
|
|
||||||
this.attachment.mimeType?.includes("spreadsheetml") ||
|
|
||||||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
|
|
||||||
this.attachment.fileName.toLowerCase().endsWith(".xls");
|
|
||||||
|
|
||||||
// Choose the appropriate icon
|
|
||||||
const getDocumentIcon = () => {
|
|
||||||
if (isExcel) return icon(FileSpreadsheet, "md");
|
|
||||||
return icon(FileText, "md");
|
|
||||||
};
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="relative group inline-block">
|
|
||||||
${
|
|
||||||
hasPreview
|
|
||||||
? html`
|
|
||||||
<div class="relative">
|
|
||||||
<img
|
|
||||||
src="data:${isImage ? this.attachment.mimeType : "image/png"};base64,${this.attachment.preview}"
|
|
||||||
class="w-16 h-16 object-cover rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
alt="${this.attachment.fileName}"
|
|
||||||
title="${this.attachment.fileName}"
|
|
||||||
@click=${this.handleClick}
|
|
||||||
/>
|
|
||||||
${
|
|
||||||
isPdf
|
|
||||||
? html`
|
|
||||||
<!-- PDF badge overlay -->
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 bg-background/90 px-1 py-0.5 rounded-b-lg">
|
|
||||||
<div class="text-[10px] text-muted-foreground text-center font-medium">${i18n("PDF")}</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: html`
|
|
||||||
<!-- Fallback: document icon + filename -->
|
|
||||||
<div
|
|
||||||
class="w-16 h-16 rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity bg-muted text-muted-foreground flex flex-col items-center justify-center p-2"
|
|
||||||
@click=${this.handleClick}
|
|
||||||
title="${this.attachment.fileName}"
|
|
||||||
>
|
|
||||||
${getDocumentIcon()}
|
|
||||||
<div class="text-[10px] text-center truncate w-full">
|
|
||||||
${
|
|
||||||
this.attachment.fileName.length > 10
|
|
||||||
? this.attachment.fileName.substring(0, 8) + "..."
|
|
||||||
: this.attachment.fileName
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
${
|
|
||||||
this.showDelete
|
|
||||||
? html`
|
|
||||||
<button
|
|
||||||
@click=${(e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.onDelete?.();
|
|
||||||
}}
|
|
||||||
class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
|
|
||||||
title="${i18n("Remove")}"
|
|
||||||
>
|
|
||||||
${icon(X, "xs")}
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { html, icon } from "@mariozechner/mini-lit";
|
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { property, state } from "lit/decorators.js";
|
|
||||||
import { Check, Copy } from "lucide";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
export class ConsoleBlock extends LitElement {
|
|
||||||
@property() content: string = "";
|
|
||||||
@state() private copied = false;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async copy() {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(this.content || "");
|
|
||||||
this.copied = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
this.copied = false;
|
|
||||||
}, 1500);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Copy failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override updated() {
|
|
||||||
// Auto-scroll to bottom on content changes
|
|
||||||
const container = this.querySelector(".console-scroll") as HTMLElement | null;
|
|
||||||
if (container) {
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
return html`
|
|
||||||
<div class="border border-border rounded-lg overflow-hidden">
|
|
||||||
<div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
|
|
||||||
<span class="text-xs text-muted-foreground font-mono">${i18n("console")}</span>
|
|
||||||
<button
|
|
||||||
@click=${() => this.copy()}
|
|
||||||
class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
|
|
||||||
title="${i18n("Copy output")}"
|
|
||||||
>
|
|
||||||
${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
|
|
||||||
${this.copied ? html`<span>${i18n("Copied!")}</span>` : ""}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="console-scroll overflow-auto max-h-64">
|
|
||||||
<pre class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs text-foreground font-mono whitespace-pre-wrap">
|
|
||||||
${this.content || ""}</pre
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register custom element
|
|
||||||
if (!customElements.get("console-block")) {
|
|
||||||
customElements.define("console-block", ConsoleBlock);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
|
|
||||||
import { type Ref, ref } from "lit/directives/ref.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
|
|
||||||
export type InputSize = "sm" | "md" | "lg";
|
|
||||||
|
|
||||||
export interface InputProps extends BaseComponentProps {
|
|
||||||
type?: InputType;
|
|
||||||
size?: InputSize;
|
|
||||||
value?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
required?: boolean;
|
|
||||||
name?: string;
|
|
||||||
autocomplete?: string;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
inputRef?: Ref<HTMLInputElement>;
|
|
||||||
onInput?: (e: Event) => void;
|
|
||||||
onChange?: (e: Event) => void;
|
|
||||||
onKeyDown?: (e: KeyboardEvent) => void;
|
|
||||||
onKeyUp?: (e: KeyboardEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Input = fc<InputProps>(
|
|
||||||
({
|
|
||||||
type = "text",
|
|
||||||
size = "md",
|
|
||||||
value = "",
|
|
||||||
placeholder = "",
|
|
||||||
label = "",
|
|
||||||
error = "",
|
|
||||||
disabled = false,
|
|
||||||
required = false,
|
|
||||||
name = "",
|
|
||||||
autocomplete = "",
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
inputRef,
|
|
||||||
onInput,
|
|
||||||
onChange,
|
|
||||||
onKeyDown,
|
|
||||||
onKeyUp,
|
|
||||||
className = "",
|
|
||||||
}) => {
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: "h-8 px-3 py-1 text-sm",
|
|
||||||
md: "h-9 px-3 py-1 text-sm md:text-sm",
|
|
||||||
lg: "h-10 px-4 py-1 text-base",
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseClasses =
|
|
||||||
"flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
|
|
||||||
const interactionClasses =
|
|
||||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
|
|
||||||
const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
|
|
||||||
const darkClasses = "dark:bg-input/30";
|
|
||||||
const stateClasses = error
|
|
||||||
? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
|
|
||||||
: "border-input";
|
|
||||||
const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
|
|
||||||
|
|
||||||
const handleInput = (e: Event) => {
|
|
||||||
onInput?.(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e: Event) => {
|
|
||||||
onChange?.(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="flex flex-col gap-1.5 ${className}">
|
|
||||||
${
|
|
||||||
label
|
|
||||||
? html`
|
|
||||||
<label class="text-sm font-medium text-foreground">
|
|
||||||
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
|
|
||||||
</label>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="${type}"
|
|
||||||
class="${baseClasses} ${
|
|
||||||
sizeClasses[size]
|
|
||||||
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
|
|
||||||
.value=${value}
|
|
||||||
placeholder="${placeholder}"
|
|
||||||
?disabled=${disabled}
|
|
||||||
?required=${required}
|
|
||||||
?aria-invalid=${!!error}
|
|
||||||
name="${name}"
|
|
||||||
autocomplete="${autocomplete}"
|
|
||||||
min="${min ?? ""}"
|
|
||||||
max="${max ?? ""}"
|
|
||||||
step="${step ?? ""}"
|
|
||||||
@input=${handleInput}
|
|
||||||
@change=${handleChange}
|
|
||||||
@keydown=${onKeyDown}
|
|
||||||
@keyup=${onKeyUp}
|
|
||||||
${inputRef ? ref(inputRef) : ""}
|
|
||||||
/>
|
|
||||||
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
import { Button, html, icon } from "@mariozechner/mini-lit";
|
|
||||||
import type { Model } from "@mariozechner/pi-ai";
|
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
import { createRef, ref } from "lit/directives/ref.js";
|
|
||||||
import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
|
|
||||||
import "./AttachmentTile.js";
|
|
||||||
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
@customElement("message-editor")
|
|
||||||
export class MessageEditor extends LitElement {
|
|
||||||
private _value = "";
|
|
||||||
private textareaRef = createRef<HTMLTextAreaElement>();
|
|
||||||
|
|
||||||
@property()
|
|
||||||
get value() {
|
|
||||||
return this._value;
|
|
||||||
}
|
|
||||||
|
|
||||||
set value(val: string) {
|
|
||||||
const oldValue = this._value;
|
|
||||||
this._value = val;
|
|
||||||
this.requestUpdate("value", oldValue);
|
|
||||||
this.updateComplete.then(() => {
|
|
||||||
const textarea = this.textareaRef.value;
|
|
||||||
if (textarea) {
|
|
||||||
textarea.style.height = "auto";
|
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@property() isStreaming = false;
|
|
||||||
@property() currentModel?: Model<any>;
|
|
||||||
@property() showAttachmentButton = true;
|
|
||||||
@property() showModelSelector = true;
|
|
||||||
@property() showThinking = false; // Disabled for now
|
|
||||||
@property() onInput?: (value: string) => void;
|
|
||||||
@property() onSend?: (input: string, attachments: Attachment[]) => void;
|
|
||||||
@property() onAbort?: () => void;
|
|
||||||
@property() onModelSelect?: () => void;
|
|
||||||
@property() onFilesChange?: (files: Attachment[]) => void;
|
|
||||||
@property() attachments: Attachment[] = [];
|
|
||||||
@property() maxFiles = 10;
|
|
||||||
@property() maxFileSize = 20 * 1024 * 1024; // 20MB
|
|
||||||
@property() acceptedTypes =
|
|
||||||
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
|
|
||||||
|
|
||||||
@state() processingFiles = false;
|
|
||||||
private fileInputRef = createRef<HTMLInputElement>();
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTextareaInput = (e: Event) => {
|
|
||||||
const textarea = e.target as HTMLTextAreaElement;
|
|
||||||
this.value = textarea.value;
|
|
||||||
textarea.style.height = "auto";
|
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
||||||
this.onInput?.(this.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
|
|
||||||
this.handleSend();
|
|
||||||
}
|
|
||||||
} else if (e.key === "Escape" && this.isStreaming) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.onAbort?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleSend = () => {
|
|
||||||
this.onSend?.(this.value, this.attachments);
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleAttachmentClick = () => {
|
|
||||||
this.fileInputRef.value?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
private async handleFilesSelected(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
const files = Array.from(input.files || []);
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
if (files.length + this.attachments.length > this.maxFiles) {
|
|
||||||
alert(`Maximum ${this.maxFiles} files allowed`);
|
|
||||||
input.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processingFiles = true;
|
|
||||||
const newAttachments: Attachment[] = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
if (file.size > this.maxFileSize) {
|
|
||||||
alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachment = await loadAttachment(file);
|
|
||||||
newAttachments.push(attachment);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing ${file.name}:`, error);
|
|
||||||
alert(`Failed to process ${file.name}: ${String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.attachments = [...this.attachments, ...newAttachments];
|
|
||||||
this.onFilesChange?.(this.attachments);
|
|
||||||
this.processingFiles = false;
|
|
||||||
input.value = ""; // Reset input
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeFile(fileId: string) {
|
|
||||||
this.attachments = this.attachments.filter((f) => f.id !== fileId);
|
|
||||||
this.onFilesChange?.(this.attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
private adjustTextareaHeight() {
|
|
||||||
const textarea = this.textareaRef.value;
|
|
||||||
if (textarea) {
|
|
||||||
// Reset height to auto to get accurate scrollHeight
|
|
||||||
textarea.style.height = "auto";
|
|
||||||
// Only adjust if there's content, otherwise keep minimal height
|
|
||||||
if (this.value.trim()) {
|
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override firstUpdated() {
|
|
||||||
const textarea = this.textareaRef.value;
|
|
||||||
if (textarea) {
|
|
||||||
// Don't adjust height on first render - let it be minimal
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override updated() {
|
|
||||||
// Only adjust height when component updates if there's content
|
|
||||||
if (this.value) {
|
|
||||||
this.adjustTextareaHeight();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
return html`
|
|
||||||
<div class="bg-card rounded-xl border border-border shadow-sm">
|
|
||||||
<!-- Attachments -->
|
|
||||||
${
|
|
||||||
this.attachments.length > 0
|
|
||||||
? html`
|
|
||||||
<div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
|
|
||||||
${this.attachments.map(
|
|
||||||
(attachment) => html`
|
|
||||||
<attachment-tile
|
|
||||||
.attachment=${attachment}
|
|
||||||
.showDelete=${true}
|
|
||||||
.onDelete=${() => this.removeFile(attachment.id)}
|
|
||||||
></attachment-tile>
|
|
||||||
`,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
|
|
||||||
placeholder=${i18n("Type a message...")}
|
|
||||||
rows="1"
|
|
||||||
style="max-height: 200px;"
|
|
||||||
.value=${this.value}
|
|
||||||
@input=${this.handleTextareaInput}
|
|
||||||
@keydown=${this.handleKeyDown}
|
|
||||||
${ref(this.textareaRef)}
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
<!-- Hidden file input -->
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
${ref(this.fileInputRef)}
|
|
||||||
@change=${this.handleFilesSelected}
|
|
||||||
accept=${this.acceptedTypes}
|
|
||||||
multiple
|
|
||||||
style="display: none;"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Button Row -->
|
|
||||||
<div class="px-2 pb-2 flex items-center justify-between">
|
|
||||||
<!-- Left side - attachment and quick action buttons -->
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
${
|
|
||||||
this.showAttachmentButton
|
|
||||||
? this.processingFiles
|
|
||||||
? html`
|
|
||||||
<div class="h-8 w-8 flex items-center justify-center">
|
|
||||||
${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: html`
|
|
||||||
${Button({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "icon",
|
|
||||||
className: "h-8 w-8",
|
|
||||||
onClick: this.handleAttachmentClick,
|
|
||||||
children: icon(Paperclip, "sm"),
|
|
||||||
})}
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Model selector and send on the right -->
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
${
|
|
||||||
this.showModelSelector && this.currentModel
|
|
||||||
? html`
|
|
||||||
${Button({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "sm",
|
|
||||||
onClick: () => {
|
|
||||||
// Focus textarea before opening model selector so focus returns there
|
|
||||||
this.textareaRef.value?.focus();
|
|
||||||
// Wait for next frame to ensure focus takes effect before dialog captures it
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.onModelSelect?.();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
children: html`
|
|
||||||
${icon(Sparkles, "sm")}
|
|
||||||
<span class="ml-1">${this.currentModel.id}</span>
|
|
||||||
`,
|
|
||||||
className: "h-8 text-xs truncate",
|
|
||||||
})}
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${
|
|
||||||
this.isStreaming
|
|
||||||
? html`
|
|
||||||
${Button({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "icon",
|
|
||||||
onClick: this.onAbort,
|
|
||||||
children: icon(Square, "sm"),
|
|
||||||
className: "h-8 w-8",
|
|
||||||
})}
|
|
||||||
`
|
|
||||||
: html`
|
|
||||||
${Button({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "icon",
|
|
||||||
onClick: this.handleSend,
|
|
||||||
disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
|
|
||||||
children: html`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
|
|
||||||
className: "h-8 w-8",
|
|
||||||
})}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { html } from "@mariozechner/mini-lit";
|
|
||||||
import type {
|
|
||||||
AgentTool,
|
|
||||||
AssistantMessage as AssistantMessageType,
|
|
||||||
Message,
|
|
||||||
ToolResultMessage as ToolResultMessageType,
|
|
||||||
} from "@mariozechner/pi-ai";
|
|
||||||
import { LitElement, type TemplateResult } from "lit";
|
|
||||||
import { property } from "lit/decorators.js";
|
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
|
||||||
|
|
||||||
export class MessageList extends LitElement {
|
|
||||||
@property({ type: Array }) messages: Message[] = [];
|
|
||||||
@property({ type: Array }) tools: AgentTool[] = [];
|
|
||||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
|
||||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildRenderItems() {
|
|
||||||
// Map tool results by call id for quick lookup
|
|
||||||
const resultByCallId = new Map<string, ToolResultMessageType>();
|
|
||||||
for (const message of this.messages) {
|
|
||||||
if (message.role === "toolResult") {
|
|
||||||
resultByCallId.set(message.toolCallId, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: Array<{ key: string; template: TemplateResult }> = [];
|
|
||||||
let index = 0;
|
|
||||||
for (const msg of this.messages) {
|
|
||||||
if (msg.role === "user") {
|
|
||||||
items.push({
|
|
||||||
key: `msg:${index}`,
|
|
||||||
template: html`<user-message .message=${msg}></user-message>`,
|
|
||||||
});
|
|
||||||
index++;
|
|
||||||
} else if (msg.role === "assistant") {
|
|
||||||
const amsg = msg as AssistantMessageType;
|
|
||||||
items.push({
|
|
||||||
key: `msg:${index}`,
|
|
||||||
template: html`<assistant-message
|
|
||||||
.message=${amsg}
|
|
||||||
.tools=${this.tools}
|
|
||||||
.isStreaming=${this.isStreaming}
|
|
||||||
.pendingToolCalls=${this.pendingToolCalls}
|
|
||||||
.toolResultsById=${resultByCallId}
|
|
||||||
.hideToolCalls=${false}
|
|
||||||
></assistant-message>`,
|
|
||||||
});
|
|
||||||
index++;
|
|
||||||
} else {
|
|
||||||
// Skip standalone toolResult messages; they are rendered via paired tool-message above
|
|
||||||
// For completeness, other roles are not expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
const items = this.buildRenderItems();
|
|
||||||
return html`<div class="flex flex-col gap-3">
|
|
||||||
${repeat(
|
|
||||||
items,
|
|
||||||
(it) => it.key,
|
|
||||||
(it) => it.template,
|
|
||||||
)}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register custom element
|
|
||||||
if (!customElements.get("message-list")) {
|
|
||||||
customElements.define("message-list", MessageList);
|
|
||||||
}
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
import { Button, html, icon } from "@mariozechner/mini-lit";
|
|
||||||
import type {
|
|
||||||
AgentTool,
|
|
||||||
AssistantMessage as AssistantMessageType,
|
|
||||||
ToolCall,
|
|
||||||
ToolResultMessage as ToolResultMessageType,
|
|
||||||
UserMessage as UserMessageType,
|
|
||||||
} from "@mariozechner/pi-ai";
|
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
|
|
||||||
import { LitElement, type TemplateResult } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
import { Bug, Loader, Wrench } from "lucide";
|
|
||||||
import { renderToolParams, renderToolResult } from "../tools/index.js";
|
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
import { formatUsage } from "../utils/format.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
|
|
||||||
export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
|
|
||||||
|
|
||||||
@customElement("user-message")
|
|
||||||
export class UserMessage extends LitElement {
|
|
||||||
@property({ type: Object }) message!: UserMessageWithAttachments;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
const content =
|
|
||||||
typeof this.message.content === "string"
|
|
||||||
? this.message.content
|
|
||||||
: this.message.content.find((c) => c.type === "text")?.text || "";
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="py-2 px-4 border-l-4 border-accent-foreground/60 text-primary-foreground">
|
|
||||||
<markdown-block .content=${content}></markdown-block>
|
|
||||||
${
|
|
||||||
this.message.attachments && this.message.attachments.length > 0
|
|
||||||
? html`
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
${this.message.attachments.map(
|
|
||||||
(attachment) => html` <attachment-tile .attachment=${attachment}></attachment-tile> `,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("assistant-message")
|
|
||||||
export class AssistantMessage extends LitElement {
|
|
||||||
@property({ type: Object }) message!: AssistantMessageType;
|
|
||||||
@property({ type: Array }) tools?: AgentTool<any>[];
|
|
||||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
|
||||||
@property({ type: Boolean }) hideToolCalls = false;
|
|
||||||
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
|
|
||||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
// Render content in the order it appears
|
|
||||||
const orderedParts: TemplateResult[] = [];
|
|
||||||
|
|
||||||
for (const chunk of this.message.content) {
|
|
||||||
if (chunk.type === "text" && chunk.text.trim() !== "") {
|
|
||||||
orderedParts.push(html`<markdown-block .content=${chunk.text}></markdown-block>`);
|
|
||||||
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
|
|
||||||
orderedParts.push(html` <markdown-block .content=${chunk.thinking} .isThinking=${true}></markdown-block> `);
|
|
||||||
} else if (chunk.type === "toolCall") {
|
|
||||||
if (!this.hideToolCalls) {
|
|
||||||
const tool = this.tools?.find((t) => t.name === chunk.name);
|
|
||||||
const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
|
|
||||||
const result = this.toolResultsById?.get(chunk.id);
|
|
||||||
const aborted = !pending && !result && !this.isStreaming;
|
|
||||||
orderedParts.push(
|
|
||||||
html`<tool-message
|
|
||||||
.tool=${tool}
|
|
||||||
.toolCall=${chunk}
|
|
||||||
.result=${result}
|
|
||||||
.pending=${pending}
|
|
||||||
.aborted=${aborted}
|
|
||||||
.isStreaming=${this.isStreaming}
|
|
||||||
></tool-message>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div>
|
|
||||||
${orderedParts.length ? html` <div class="px-4 flex flex-col gap-3">${orderedParts}</div> ` : ""}
|
|
||||||
${
|
|
||||||
this.message.usage
|
|
||||||
? html` <div class="px-4 mt-2 text-xs text-muted-foreground">${formatUsage(this.message.usage)}</div> `
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${
|
|
||||||
this.message.stopReason === "error" && this.message.errorMessage
|
|
||||||
? html`
|
|
||||||
<div class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm overflow-hidden">
|
|
||||||
<strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${
|
|
||||||
this.message.stopReason === "aborted"
|
|
||||||
? html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("tool-message-debug")
|
|
||||||
export class ToolMessageDebugView extends LitElement {
|
|
||||||
@property({ type: Object }) callArgs: any;
|
|
||||||
@property({ type: String }) result?: AgentToolResult<any>;
|
|
||||||
@property({ type: Boolean }) hasResult: boolean = false;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this; // light DOM for shared styles
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
private pretty(value: unknown): { content: string; isJson: boolean } {
|
|
||||||
try {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const maybeJson = JSON.parse(value);
|
|
||||||
return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
|
|
||||||
}
|
|
||||||
return { content: JSON.stringify(value, null, 2), isJson: true };
|
|
||||||
} catch {
|
|
||||||
return { content: typeof value === "string" ? value : String(value), isJson: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
const output = this.pretty(this.result?.output);
|
|
||||||
const details = this.pretty(this.result?.details);
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Call")}</div>
|
|
||||||
<code-block .code=${this.pretty(this.callArgs).content} language="json"></code-block>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Result")}</div>
|
|
||||||
${
|
|
||||||
this.hasResult
|
|
||||||
? html`<code-block .code=${output.content} language="${output.isJson ? "json" : "text"}"></code-block>
|
|
||||||
<code-block .code=${details.content} language="${details.isJson ? "json" : "text"}"></code-block>`
|
|
||||||
: html`<div class="text-xs text-muted-foreground">${i18n("(no result)")}</div>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("tool-message")
|
|
||||||
export class ToolMessage extends LitElement {
|
|
||||||
@property({ type: Object }) toolCall!: ToolCall;
|
|
||||||
@property({ type: Object }) tool?: AgentTool<any>;
|
|
||||||
@property({ type: Object }) result?: ToolResultMessageType;
|
|
||||||
@property({ type: Boolean }) pending: boolean = false;
|
|
||||||
@property({ type: Boolean }) aborted: boolean = false;
|
|
||||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
|
||||||
@state() private _showDebug = false;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
private toggleDebug = () => {
|
|
||||||
this._showDebug = !this._showDebug;
|
|
||||||
};
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
const toolLabel = this.tool?.label || this.toolCall.name;
|
|
||||||
const toolName = this.tool?.name || this.toolCall.name;
|
|
||||||
const isError = this.result?.isError === true;
|
|
||||||
const hasResult = !!this.result;
|
|
||||||
|
|
||||||
let statusIcon: TemplateResult;
|
|
||||||
if (this.pending || (this.isStreaming && !hasResult)) {
|
|
||||||
statusIcon = html`<span class="inline-block text-muted-foreground animate-spin">${icon(Loader, "sm")}</span>`;
|
|
||||||
} else if (this.aborted && !hasResult) {
|
|
||||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
|
|
||||||
} else if (hasResult && isError) {
|
|
||||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
|
|
||||||
} else if (hasResult) {
|
|
||||||
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
|
|
||||||
} else {
|
|
||||||
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize error text
|
|
||||||
let errorMessage = this.result?.output || "";
|
|
||||||
if (isError) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(errorMessage);
|
|
||||||
if ((parsed as any).error) errorMessage = (parsed as any).error;
|
|
||||||
else if ((parsed as any).message) errorMessage = (parsed as any).message;
|
|
||||||
} catch {}
|
|
||||||
errorMessage = errorMessage.replace(/^(Tool )?Error:\s*/i, "");
|
|
||||||
errorMessage = errorMessage.replace(/^Error:\s*/i, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const paramsTpl = renderToolParams(
|
|
||||||
toolName,
|
|
||||||
this.toolCall.arguments,
|
|
||||||
this.isStreaming || (this.pending && !hasResult),
|
|
||||||
);
|
|
||||||
const resultTpl =
|
|
||||||
hasResult && !isError ? renderToolResult(toolName, this.toolCall.arguments, this.result!) : undefined;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground">
|
|
||||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${statusIcon}
|
|
||||||
<span class="font-medium">${toolLabel}</span>
|
|
||||||
</div>
|
|
||||||
${Button({
|
|
||||||
variant: this._showDebug ? "default" : "ghost",
|
|
||||||
size: "sm",
|
|
||||||
onClick: this.toggleDebug,
|
|
||||||
children: icon(Bug, "sm"),
|
|
||||||
className: "text-muted-foreground",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${
|
|
||||||
this._showDebug
|
|
||||||
? html`<tool-message-debug
|
|
||||||
.callArgs=${this.toolCall.arguments}
|
|
||||||
.result=${this.result}
|
|
||||||
.hasResult=${!!this.result}
|
|
||||||
></tool-message-debug>`
|
|
||||||
: html`
|
|
||||||
<div class="mt-2 text-sm text-muted-foreground">${paramsTpl}</div>
|
|
||||||
${
|
|
||||||
this.pending && !hasResult
|
|
||||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Waiting for tool result…")}</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${
|
|
||||||
this.aborted && !hasResult
|
|
||||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Call was aborted; no result.")}</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${
|
|
||||||
hasResult && isError
|
|
||||||
? html`<div class="mt-2 p-2 border border-destructive rounded bg-destructive/10 text-sm text-destructive">
|
|
||||||
${errorMessage}
|
|
||||||
</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${resultTpl ? html`<div class="mt-2">${resultTpl}</div>` : ""}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("aborted-message")
|
|
||||||
export class AbortedMessage extends LitElement {
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override render(): unknown {
|
|
||||||
return html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,470 +0,0 @@
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { customElement } from "lit/decorators.js";
|
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
|
|
||||||
declare const browser: any;
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("sandbox-iframe")
|
|
||||||
export class SandboxIframe extends LitElement {
|
|
||||||
private iframe?: HTMLIFrameElement;
|
|
||||||
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
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 attachments Attachments available
|
|
||||||
*/
|
|
||||||
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
|
|
||||||
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
|
|
||||||
|
|
||||||
// Wait for sandbox-ready and send content
|
|
||||||
const readyHandler = (e: MessageEvent) => {
|
|
||||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
|
||||||
window.removeEventListener("message", readyHandler);
|
|
||||||
this.iframe?.contentWindow?.postMessage(
|
|
||||||
{
|
|
||||||
type: "sandbox-load",
|
|
||||||
sandboxId,
|
|
||||||
code: completeHtml,
|
|
||||||
attachments,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("message", readyHandler);
|
|
||||||
|
|
||||||
// Always recreate iframe to ensure fresh sandbox and sandbox-ready message
|
|
||||||
this.iframe?.remove();
|
|
||||||
this.iframe = document.createElement("iframe");
|
|
||||||
this.iframe.sandbox.add("allow-scripts");
|
|
||||||
this.iframe.sandbox.add("allow-modals");
|
|
||||||
this.iframe.style.width = "100%";
|
|
||||||
this.iframe.style.height = "100%";
|
|
||||||
this.iframe.style.border = "none";
|
|
||||||
|
|
||||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
|
||||||
this.iframe.src = isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
|
||||||
|
|
||||||
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 attachments Attachments available to the code
|
|
||||||
* @param signal Abort signal
|
|
||||||
* @returns Promise resolving to execution result
|
|
||||||
*/
|
|
||||||
public async execute(
|
|
||||||
sandboxId: string,
|
|
||||||
code: string,
|
|
||||||
attachments: Attachment[],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<SandboxResult> {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
throw new Error("Execution aborted");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the complete HTML document with runtime + user code
|
|
||||||
const completeHtml = this.prepareHtmlDocument(sandboxId, code, attachments);
|
|
||||||
|
|
||||||
// Wait for sandbox to be ready and execute
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const logs: Array<{ type: string; text: string }> = [];
|
|
||||||
const files: SandboxFile[] = [];
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
const messageHandler = (e: MessageEvent) => {
|
|
||||||
// Ignore messages not for this sandbox
|
|
||||||
if (e.data.sandboxId !== sandboxId) return;
|
|
||||||
|
|
||||||
if (e.data.type === "console") {
|
|
||||||
logs.push({
|
|
||||||
type: e.data.method === "error" ? "error" : "log",
|
|
||||||
text: e.data.text,
|
|
||||||
});
|
|
||||||
} else if (e.data.type === "file-returned") {
|
|
||||||
files.push({
|
|
||||||
fileName: e.data.fileName,
|
|
||||||
content: e.data.content,
|
|
||||||
mimeType: e.data.mimeType,
|
|
||||||
});
|
|
||||||
} else if (e.data.type === "execution-complete") {
|
|
||||||
completed = true;
|
|
||||||
cleanup();
|
|
||||||
resolve({
|
|
||||||
success: true,
|
|
||||||
console: logs,
|
|
||||||
files: files,
|
|
||||||
});
|
|
||||||
} else if (e.data.type === "execution-error") {
|
|
||||||
completed = true;
|
|
||||||
cleanup();
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
console: logs,
|
|
||||||
error: e.data.error,
|
|
||||||
files,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const abortHandler = () => {
|
|
||||||
if (!completed) {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error("Execution aborted"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
window.removeEventListener("message", messageHandler);
|
|
||||||
signal?.removeEventListener("abort", abortHandler);
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up listeners
|
|
||||||
window.addEventListener("message", messageHandler);
|
|
||||||
signal?.addEventListener("abort", abortHandler);
|
|
||||||
|
|
||||||
// Set up sandbox-ready listener BEFORE creating iframe to avoid race condition
|
|
||||||
const readyHandler = (e: MessageEvent) => {
|
|
||||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
|
||||||
window.removeEventListener("message", readyHandler);
|
|
||||||
// Send the complete HTML
|
|
||||||
this.iframe?.contentWindow?.postMessage(
|
|
||||||
{
|
|
||||||
type: "sandbox-load",
|
|
||||||
sandboxId,
|
|
||||||
code: completeHtml,
|
|
||||||
attachments,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("message", readyHandler);
|
|
||||||
|
|
||||||
// Timeout after 30 seconds
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (!completed) {
|
|
||||||
cleanup();
|
|
||||||
window.removeEventListener("message", readyHandler);
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: { message: "Execution timeout (30s)", stack: "" },
|
|
||||||
console: logs,
|
|
||||||
files,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
// NOW create and append iframe AFTER all listeners are set up
|
|
||||||
this.iframe?.remove();
|
|
||||||
this.iframe = document.createElement("iframe");
|
|
||||||
this.iframe.sandbox.add("allow-scripts");
|
|
||||||
this.iframe.sandbox.add("allow-modals");
|
|
||||||
this.iframe.style.width = "100%";
|
|
||||||
this.iframe.style.height = "100%";
|
|
||||||
this.iframe.style.border = "none";
|
|
||||||
|
|
||||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
|
||||||
this.iframe.src = isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
|
||||||
|
|
||||||
this.appendChild(this.iframe);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepare complete HTML document with runtime + user code
|
|
||||||
*/
|
|
||||||
private prepareHtmlDocument(sandboxId: string, userCode: string, attachments: Attachment[]): string {
|
|
||||||
// Runtime script that will be injected
|
|
||||||
const runtime = this.getRuntimeScript(sandboxId, attachments);
|
|
||||||
|
|
||||||
// Check if user provided full HTML
|
|
||||||
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
|
|
||||||
|
|
||||||
if (hasHtmlTag) {
|
|
||||||
// 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
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
${runtime}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script type="module">
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
${userCode}
|
|
||||||
window.complete();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error?.stack || error?.message || String(error));
|
|
||||||
window.complete({
|
|
||||||
message: error?.message || String(error),
|
|
||||||
stack: error?.stack || new Error().stack
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the runtime script that captures console, provides helpers, etc.
|
|
||||||
*/
|
|
||||||
private getRuntimeScript(sandboxId: string, attachments: Attachment[]): string {
|
|
||||||
// Convert attachments to serializable format
|
|
||||||
const attachmentsData = attachments.map((a) => ({
|
|
||||||
id: a.id,
|
|
||||||
fileName: a.fileName,
|
|
||||||
mimeType: a.mimeType,
|
|
||||||
size: a.size,
|
|
||||||
content: a.content,
|
|
||||||
extractedText: a.extractedText,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Runtime function that will run in the sandbox (NO parameters - values injected before function)
|
|
||||||
const runtimeFunc = () => {
|
|
||||||
// Helper functions
|
|
||||||
(window as any).listFiles = () =>
|
|
||||||
(attachments || []).map((a: any) => ({
|
|
||||||
id: a.id,
|
|
||||||
fileName: a.fileName,
|
|
||||||
mimeType: a.mimeType,
|
|
||||||
size: a.size,
|
|
||||||
}));
|
|
||||||
|
|
||||||
(window as any).readTextFile = (attachmentId: string) => {
|
|
||||||
const a = (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).readBinaryFile = (attachmentId: string) => {
|
|
||||||
const a = (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;
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).returnFile = 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(
|
|
||||||
"returnFile: 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(
|
|
||||||
"returnFile: 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";
|
|
||||||
}
|
|
||||||
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: "file-returned",
|
|
||||||
sandboxId,
|
|
||||||
fileName,
|
|
||||||
content: finalContent,
|
|
||||||
mimeType: finalMimeType,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Console capture
|
|
||||||
const originalConsole = {
|
|
||||||
log: console.log,
|
|
||||||
error: console.error,
|
|
||||||
warn: console.warn,
|
|
||||||
info: console.info,
|
|
||||||
};
|
|
||||||
|
|
||||||
["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(" ");
|
|
||||||
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: "console",
|
|
||||||
sandboxId,
|
|
||||||
method,
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
|
|
||||||
(originalConsole as any)[method].apply(console, args);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track errors for HTML artifacts
|
|
||||||
let lastError: { message: string; stack: string } | null = null;
|
|
||||||
|
|
||||||
// Error handlers
|
|
||||||
window.addEventListener("error", (e) => {
|
|
||||||
const text =
|
|
||||||
(e.error?.stack || e.message || String(e)) + " at line " + (e.lineno || "?") + ":" + (e.colno || "?");
|
|
||||||
|
|
||||||
// Store the error
|
|
||||||
lastError = {
|
|
||||||
message: e.error?.message || e.message || String(e),
|
|
||||||
stack: e.error?.stack || text,
|
|
||||||
};
|
|
||||||
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: "console",
|
|
||||||
sandboxId,
|
|
||||||
method: "error",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("unhandledrejection", (e) => {
|
|
||||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
|
||||||
|
|
||||||
// Store the error
|
|
||||||
lastError = {
|
|
||||||
message: e.reason?.message || String(e.reason) || "Unhandled promise rejection",
|
|
||||||
stack: e.reason?.stack || text,
|
|
||||||
};
|
|
||||||
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: "console",
|
|
||||||
sandboxId,
|
|
||||||
method: "error",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expose complete() method for user code to call
|
|
||||||
let completionSent = false;
|
|
||||||
(window as any).complete = (error?: { message: string; stack: string }) => {
|
|
||||||
if (completionSent) return;
|
|
||||||
completionSent = true;
|
|
||||||
|
|
||||||
// Use provided error or last caught error
|
|
||||||
const finalError = error || lastError;
|
|
||||||
|
|
||||||
if (finalError) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: "execution-error",
|
|
||||||
sandboxId,
|
|
||||||
error: finalError,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: "execution-complete",
|
|
||||||
sandboxId,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fallback timeout for HTML artifacts that don't call complete()
|
|
||||||
if (document.readyState === "complete" || document.readyState === "interactive") {
|
|
||||||
setTimeout(() => (window as any).complete(), 2000);
|
|
||||||
} else {
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
setTimeout(() => (window as any).complete(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepend the const declarations, then the function
|
|
||||||
return (
|
|
||||||
`<script>\n` +
|
|
||||||
`window.sandboxId = ${JSON.stringify(sandboxId)};\n` +
|
|
||||||
`window.attachments = ${JSON.stringify(attachmentsData)};\n` +
|
|
||||||
`(${runtimeFunc.toString()})();\n` +
|
|
||||||
`</script>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { html } from "@mariozechner/mini-lit";
|
|
||||||
import type { AgentTool, Message, ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { property, state } from "lit/decorators.js";
|
|
||||||
|
|
||||||
export class StreamingMessageContainer extends LitElement {
|
|
||||||
@property({ type: Array }) tools: AgentTool[] = [];
|
|
||||||
@property({ type: Boolean }) isStreaming = false;
|
|
||||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
|
||||||
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
|
|
||||||
|
|
||||||
@state() private _message: Message | null = null;
|
|
||||||
private _pendingMessage: Message | null = null;
|
|
||||||
private _updateScheduled = false;
|
|
||||||
private _immediateUpdate = false;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to update the message with batching for performance
|
|
||||||
public setMessage(message: Message | null, immediate = false) {
|
|
||||||
// Store the latest message
|
|
||||||
this._pendingMessage = message;
|
|
||||||
|
|
||||||
// If this is an immediate update (like clearing), apply it right away
|
|
||||||
if (immediate || message === null) {
|
|
||||||
this._immediateUpdate = true;
|
|
||||||
this._message = message;
|
|
||||||
this.requestUpdate();
|
|
||||||
// Cancel any pending updates since we're clearing
|
|
||||||
this._pendingMessage = null;
|
|
||||||
this._updateScheduled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise batch updates for performance during streaming
|
|
||||||
if (!this._updateScheduled) {
|
|
||||||
this._updateScheduled = true;
|
|
||||||
|
|
||||||
requestAnimationFrame(async () => {
|
|
||||||
// Only apply the update if we haven't been cleared
|
|
||||||
if (!this._immediateUpdate && this._pendingMessage !== null) {
|
|
||||||
// Deep clone the message to ensure Lit detects changes in nested properties
|
|
||||||
// (like toolCall.arguments being mutated during streaming)
|
|
||||||
this._message = JSON.parse(JSON.stringify(this._pendingMessage));
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
// Reset for next batch
|
|
||||||
this._pendingMessage = null;
|
|
||||||
this._updateScheduled = false;
|
|
||||||
this._immediateUpdate = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
// Show loading indicator if loading but no message yet
|
|
||||||
if (!this._message) {
|
|
||||||
if (this.isStreaming)
|
|
||||||
return html`<div class="flex flex-col gap-3 mb-3">
|
|
||||||
<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>
|
|
||||||
</div>`;
|
|
||||||
return html``; // Empty until a message is set
|
|
||||||
}
|
|
||||||
const msg = this._message;
|
|
||||||
|
|
||||||
if (msg.role === "toolResult") {
|
|
||||||
// Skip standalone tool result in streaming; the stable list will render paired tool-message
|
|
||||||
return html``;
|
|
||||||
} else if (msg.role === "user") {
|
|
||||||
// Skip standalone tool result in streaming; the stable list will render it immediiately
|
|
||||||
return html``;
|
|
||||||
} else if (msg.role === "assistant") {
|
|
||||||
// Assistant message - render inline tool messages during streaming
|
|
||||||
return html`
|
|
||||||
<div class="flex flex-col gap-3 mb-3">
|
|
||||||
<assistant-message
|
|
||||||
.message=${msg}
|
|
||||||
.tools=${this.tools}
|
|
||||||
.isStreaming=${this.isStreaming}
|
|
||||||
.pendingToolCalls=${this.pendingToolCalls}
|
|
||||||
.toolResultsById=${this.toolResultsById}
|
|
||||||
.hideToolCalls=${false}
|
|
||||||
></assistant-message>
|
|
||||||
${this.isStreaming ? html`<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register custom element
|
|
||||||
if (!customElements.get("streaming-message-container")) {
|
|
||||||
customElements.define("streaming-message-container", StreamingMessageContainer);
|
|
||||||
}
|
|
||||||
213
packages/browser-extension/src/content.ts
Normal file
213
packages/browser-extension/src/content.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Content script - runs in isolated world with JailJS interpreter for CSP-restricted pages
|
||||||
|
import { Interpreter } from "@mariozechner/jailjs";
|
||||||
|
import { transformToES5 } from "@mariozechner/jailjs/transform";
|
||||||
|
|
||||||
|
console.log("[pi-ai] Content script loaded - JailJS interpreter available");
|
||||||
|
|
||||||
|
// Listen for code execution requests
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message.type === "EXECUTE_CODE") {
|
||||||
|
const mode = message.mode || "jailjs";
|
||||||
|
console.log(`[pi-ai:${mode}] Executing code`);
|
||||||
|
|
||||||
|
// Execute in async context to support returnFile
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Capture console output
|
||||||
|
const consoleOutput: Array<{ type: string; args: unknown[] }> = [];
|
||||||
|
const files: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }> = [];
|
||||||
|
|
||||||
|
// Create interpreter with console capture and returnFile support
|
||||||
|
const interpreter = new Interpreter({
|
||||||
|
// Expose controlled DOM access
|
||||||
|
document: document,
|
||||||
|
window: window,
|
||||||
|
|
||||||
|
// Console that captures output
|
||||||
|
console: {
|
||||||
|
log: (...args: unknown[]) => {
|
||||||
|
consoleOutput.push({ type: "log", args });
|
||||||
|
console.log("[Sandbox]", ...args);
|
||||||
|
},
|
||||||
|
error: (...args: unknown[]) => {
|
||||||
|
consoleOutput.push({ type: "error", args });
|
||||||
|
console.error("[Sandbox]", ...args);
|
||||||
|
},
|
||||||
|
warn: (...args: unknown[]) => {
|
||||||
|
consoleOutput.push({ type: "warn", args });
|
||||||
|
console.warn("[Sandbox]", ...args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// returnFile function
|
||||||
|
returnFile: async (
|
||||||
|
fileName: string,
|
||||||
|
content: string | Uint8Array | Blob | Record<string, unknown>,
|
||||||
|
mimeType?: string,
|
||||||
|
) => {
|
||||||
|
let finalContent: string | Uint8Array;
|
||||||
|
let finalMimeType: string;
|
||||||
|
|
||||||
|
if (content instanceof Blob) {
|
||||||
|
// Convert Blob to Uint8Array
|
||||||
|
const arrayBuffer = await content.arrayBuffer();
|
||||||
|
finalContent = new Uint8Array(arrayBuffer);
|
||||||
|
finalMimeType = mimeType || content.type || "application/octet-stream";
|
||||||
|
|
||||||
|
// Enforce MIME type requirement for binary data
|
||||||
|
if (!mimeType && !content.type) {
|
||||||
|
throw new Error(
|
||||||
|
`returnFile: 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(
|
||||||
|
`returnFile: 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 {
|
||||||
|
// Assume it's an object to be JSON stringified
|
||||||
|
finalContent = JSON.stringify(content, null, 2);
|
||||||
|
finalMimeType = mimeType || "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
fileName,
|
||||||
|
content: finalContent,
|
||||||
|
mimeType: finalMimeType,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timers
|
||||||
|
setTimeout: setTimeout.bind(window),
|
||||||
|
setInterval: setInterval.bind(window),
|
||||||
|
clearTimeout: clearTimeout.bind(window),
|
||||||
|
clearInterval: clearInterval.bind(window),
|
||||||
|
|
||||||
|
// DOM Event Constructors
|
||||||
|
Event: Event,
|
||||||
|
CustomEvent: CustomEvent,
|
||||||
|
MouseEvent: MouseEvent,
|
||||||
|
KeyboardEvent: KeyboardEvent,
|
||||||
|
InputEvent: InputEvent,
|
||||||
|
FocusEvent: FocusEvent,
|
||||||
|
UIEvent: UIEvent,
|
||||||
|
WheelEvent: WheelEvent,
|
||||||
|
TouchEvent: typeof TouchEvent !== "undefined" ? TouchEvent : undefined,
|
||||||
|
PointerEvent: typeof PointerEvent !== "undefined" ? PointerEvent : undefined,
|
||||||
|
DragEvent: DragEvent,
|
||||||
|
ClipboardEvent: ClipboardEvent,
|
||||||
|
MessageEvent: MessageEvent,
|
||||||
|
StorageEvent: StorageEvent,
|
||||||
|
PopStateEvent: PopStateEvent,
|
||||||
|
HashChangeEvent: HashChangeEvent,
|
||||||
|
ProgressEvent: ProgressEvent,
|
||||||
|
AnimationEvent: AnimationEvent,
|
||||||
|
TransitionEvent: TransitionEvent,
|
||||||
|
|
||||||
|
// DOM Element Constructors
|
||||||
|
HTMLElement: HTMLElement,
|
||||||
|
HTMLDivElement: HTMLDivElement,
|
||||||
|
HTMLSpanElement: HTMLSpanElement,
|
||||||
|
HTMLInputElement: HTMLInputElement,
|
||||||
|
HTMLButtonElement: HTMLButtonElement,
|
||||||
|
HTMLFormElement: HTMLFormElement,
|
||||||
|
HTMLAnchorElement: HTMLAnchorElement,
|
||||||
|
HTMLImageElement: HTMLImageElement,
|
||||||
|
HTMLCanvasElement: HTMLCanvasElement,
|
||||||
|
HTMLVideoElement: HTMLVideoElement,
|
||||||
|
HTMLAudioElement: HTMLAudioElement,
|
||||||
|
HTMLTextAreaElement: HTMLTextAreaElement,
|
||||||
|
HTMLSelectElement: HTMLSelectElement,
|
||||||
|
HTMLOptionElement: HTMLOptionElement,
|
||||||
|
HTMLIFrameElement: HTMLIFrameElement,
|
||||||
|
HTMLTableElement: HTMLTableElement,
|
||||||
|
HTMLTableRowElement: HTMLTableRowElement,
|
||||||
|
HTMLTableCellElement: HTMLTableCellElement,
|
||||||
|
|
||||||
|
// Other DOM types
|
||||||
|
Node: Node,
|
||||||
|
Element: Element,
|
||||||
|
DocumentFragment: DocumentFragment,
|
||||||
|
Text: Text,
|
||||||
|
Comment: Comment,
|
||||||
|
NodeList: NodeList,
|
||||||
|
HTMLCollection: HTMLCollection,
|
||||||
|
DOMTokenList: DOMTokenList,
|
||||||
|
CSSStyleDeclaration: CSSStyleDeclaration,
|
||||||
|
XMLHttpRequest: XMLHttpRequest,
|
||||||
|
FormData: FormData,
|
||||||
|
Blob: Blob,
|
||||||
|
File: File,
|
||||||
|
FileReader: FileReader,
|
||||||
|
URL: URL,
|
||||||
|
URLSearchParams: URLSearchParams,
|
||||||
|
Headers: Headers,
|
||||||
|
Request: Request,
|
||||||
|
Response: Response,
|
||||||
|
AbortController: AbortController,
|
||||||
|
AbortSignal: AbortSignal,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
Math: Math,
|
||||||
|
JSON: JSON,
|
||||||
|
Date: Date,
|
||||||
|
Set: Set,
|
||||||
|
Map: Map,
|
||||||
|
WeakSet: WeakSet,
|
||||||
|
WeakMap: WeakMap,
|
||||||
|
ArrayBuffer: ArrayBuffer,
|
||||||
|
DataView: DataView,
|
||||||
|
Int8Array: Int8Array,
|
||||||
|
Uint8Array: Uint8Array,
|
||||||
|
Uint8ClampedArray: Uint8ClampedArray,
|
||||||
|
Int16Array: Int16Array,
|
||||||
|
Uint16Array: Uint16Array,
|
||||||
|
Int32Array: Int32Array,
|
||||||
|
Uint32Array: Uint32Array,
|
||||||
|
Float32Array: Float32Array,
|
||||||
|
Float64Array: Float64Array,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap code in async IIFE to support top-level await
|
||||||
|
// JailJS supports await inside async functions but not at top level
|
||||||
|
const wrappedCode = `(async function() {\n${message.code}\n})();`;
|
||||||
|
|
||||||
|
// Transform ES6+ to ES5 AST and execute
|
||||||
|
const ast = transformToES5(wrappedCode);
|
||||||
|
const result = interpreter.evaluate(ast);
|
||||||
|
|
||||||
|
// Wait for async operations to complete
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
await result;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[pi-ai:${mode}] Execution success`);
|
||||||
|
sendResponse({
|
||||||
|
success: true,
|
||||||
|
result: result,
|
||||||
|
console: consoleOutput,
|
||||||
|
files: files,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error(`[pi-ai:${mode}] Execution error:`, err);
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return true; // Keep channel open for async response
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
import { Alert, Badge, Button, DialogBase, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit";
|
|
||||||
import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai";
|
|
||||||
import type { PropertyValues } from "lit";
|
|
||||||
import { customElement, state } from "lit/decorators.js";
|
|
||||||
import { Input } from "../components/Input.js";
|
|
||||||
import { keyStore } from "../state/KeyStore.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
// Test models for each provider - known to be reliable and cheap
|
|
||||||
const TEST_MODELS: Record<string, string> = {
|
|
||||||
anthropic: "claude-3-5-haiku-20241022",
|
|
||||||
openai: "gpt-4o-mini",
|
|
||||||
google: "gemini-2.0-flash-exp",
|
|
||||||
groq: "llama-3.3-70b-versatile",
|
|
||||||
openrouter: "openai/gpt-4o-mini",
|
|
||||||
cerebras: "llama3.1-8b",
|
|
||||||
xai: "grok-2-1212",
|
|
||||||
zai: "glm-4-plus",
|
|
||||||
};
|
|
||||||
|
|
||||||
@customElement("api-keys-dialog")
|
|
||||||
export class ApiKeysDialog extends DialogBase {
|
|
||||||
@state() apiKeys: Record<string, boolean> = {}; // provider -> configured
|
|
||||||
@state() apiKeyInputs: Record<string, string> = {};
|
|
||||||
@state() testResults: Record<string, "success" | "error" | "testing"> = {};
|
|
||||||
@state() savingProvider = "";
|
|
||||||
@state() testingProvider = "";
|
|
||||||
@state() error = "";
|
|
||||||
|
|
||||||
protected override modalWidth = "min(600px, 90vw)";
|
|
||||||
protected override modalHeight = "min(600px, 80vh)";
|
|
||||||
|
|
||||||
static async open() {
|
|
||||||
const dialog = new ApiKeysDialog();
|
|
||||||
dialog.open();
|
|
||||||
await dialog.loadKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
|
||||||
super.firstUpdated(changedProperties);
|
|
||||||
await this.loadKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadKeys() {
|
|
||||||
this.apiKeys = await keyStore.getAllKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Get the test model for this provider
|
|
||||||
const modelId = TEST_MODELS[provider];
|
|
||||||
if (!modelId) {
|
|
||||||
this.error = `No test model configured for ${provider}`;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = getModel(provider as any, modelId);
|
|
||||||
if (!model) {
|
|
||||||
this.error = `Test model ${modelId} not found for ${provider}`;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple test prompt
|
|
||||||
const context: Context = {
|
|
||||||
messages: [{ role: "user", content: "Reply with exactly: test successful" }],
|
|
||||||
};
|
|
||||||
const response = await complete(model, context, {
|
|
||||||
apiKey,
|
|
||||||
maxTokens: 10, // Keep it minimal for testing
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Check if response contains expected text
|
|
||||||
const text = response.content
|
|
||||||
.filter((b) => b.type === "text")
|
|
||||||
.map((b) => b.text)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return text.toLowerCase().includes("test successful");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`API key test failed for ${provider}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveKey(provider: string) {
|
|
||||||
const key = this.apiKeyInputs[provider];
|
|
||||||
if (!key) return;
|
|
||||||
|
|
||||||
this.savingProvider = provider;
|
|
||||||
this.testResults[provider] = "testing";
|
|
||||||
this.error = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test the key first
|
|
||||||
const isValid = await this.testApiKey(provider, key);
|
|
||||||
|
|
||||||
if (isValid) {
|
|
||||||
await keyStore.setKey(provider, key);
|
|
||||||
this.apiKeyInputs[provider] = ""; // Clear input
|
|
||||||
await this.loadKeys();
|
|
||||||
this.testResults[provider] = "success";
|
|
||||||
} else {
|
|
||||||
this.testResults[provider] = "error";
|
|
||||||
this.error = `Invalid API key for ${provider}`;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
this.testResults[provider] = "error";
|
|
||||||
this.error = `Failed to save key for ${provider}: ${err.message}`;
|
|
||||||
} finally {
|
|
||||||
this.savingProvider = "";
|
|
||||||
|
|
||||||
// Clear test result after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
delete this.testResults[provider];
|
|
||||||
this.requestUpdate();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async testExistingKey(provider: string) {
|
|
||||||
this.testingProvider = provider;
|
|
||||||
this.testResults[provider] = "testing";
|
|
||||||
this.error = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKey = await keyStore.getKey(provider);
|
|
||||||
if (!apiKey) {
|
|
||||||
this.testResults[provider] = "error";
|
|
||||||
this.error = `No API key found for ${provider}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await this.testApiKey(provider, apiKey);
|
|
||||||
|
|
||||||
if (isValid) {
|
|
||||||
this.testResults[provider] = "success";
|
|
||||||
} else {
|
|
||||||
this.testResults[provider] = "error";
|
|
||||||
this.error = `API key for ${provider} is no longer valid`;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
this.testResults[provider] = "error";
|
|
||||||
this.error = `Test failed for ${provider}: ${err.message}`;
|
|
||||||
} finally {
|
|
||||||
this.testingProvider = "";
|
|
||||||
|
|
||||||
// Clear test result after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
delete this.testResults[provider];
|
|
||||||
this.requestUpdate();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async removeKey(provider: string) {
|
|
||||||
if (!confirm(`Remove API key for ${provider}?`)) return;
|
|
||||||
|
|
||||||
await keyStore.removeKey(provider);
|
|
||||||
this.apiKeyInputs[provider] = "";
|
|
||||||
await this.loadKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override renderContent(): TemplateResult {
|
|
||||||
const providers = getProviders();
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="p-6 pb-4 border-b border-border flex-shrink-0">
|
|
||||||
${DialogHeader({ title: i18n("API Keys Configuration") })}
|
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
|
||||||
${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
${
|
|
||||||
this.error
|
|
||||||
? html`
|
|
||||||
<div class="px-6 pt-4">${Alert(this.error, "destructive")}</div>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<!-- test-->
|
|
||||||
|
|
||||||
<!-- Scrollable content -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
|
||||||
<div class="space-y-6">
|
|
||||||
${providers.map(
|
|
||||||
(provider) => html`
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm font-medium text-muted-foreground capitalize">${provider}</span>
|
|
||||||
${
|
|
||||||
this.apiKeys[provider]
|
|
||||||
? Badge({ children: i18n("Configured"), variant: "default" })
|
|
||||||
: Badge({ children: i18n("Not configured"), variant: "secondary" })
|
|
||||||
}
|
|
||||||
${
|
|
||||||
this.testResults[provider] === "success"
|
|
||||||
? Badge({ children: i18n("✓ Valid"), variant: "default" })
|
|
||||||
: this.testResults[provider] === "error"
|
|
||||||
? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
|
|
||||||
: this.testResults[provider] === "testing"
|
|
||||||
? Badge({ children: i18n("Testing..."), variant: "secondary" })
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
${Input({
|
|
||||||
type: "password",
|
|
||||||
placeholder: this.apiKeys[provider] ? i18n("Update API key") : i18n("Enter API key"),
|
|
||||||
value: this.apiKeyInputs[provider] || "",
|
|
||||||
onInput: (e: Event) => {
|
|
||||||
this.apiKeyInputs[provider] = (e.target as HTMLInputElement).value;
|
|
||||||
this.requestUpdate();
|
|
||||||
},
|
|
||||||
className: "flex-1",
|
|
||||||
})}
|
|
||||||
|
|
||||||
${Button({
|
|
||||||
onClick: () => this.saveKey(provider),
|
|
||||||
variant: "default",
|
|
||||||
size: "sm",
|
|
||||||
disabled: !this.apiKeyInputs[provider] || this.savingProvider === provider,
|
|
||||||
loading: this.savingProvider === provider,
|
|
||||||
children:
|
|
||||||
this.savingProvider === provider
|
|
||||||
? i18n("Testing...")
|
|
||||||
: this.apiKeys[provider]
|
|
||||||
? i18n("Update")
|
|
||||||
: i18n("Save"),
|
|
||||||
})}
|
|
||||||
|
|
||||||
${
|
|
||||||
this.apiKeys[provider]
|
|
||||||
? html`
|
|
||||||
${Button({
|
|
||||||
onClick: () => this.testExistingKey(provider),
|
|
||||||
variant: "outline",
|
|
||||||
size: "sm",
|
|
||||||
loading: this.testingProvider === provider,
|
|
||||||
disabled: this.testingProvider !== "" && this.testingProvider !== provider,
|
|
||||||
children:
|
|
||||||
this.testingProvider === provider ? i18n("Testing...") : i18n("Test"),
|
|
||||||
})}
|
|
||||||
${Button({
|
|
||||||
onClick: () => this.removeKey(provider),
|
|
||||||
variant: "ghost",
|
|
||||||
size: "sm",
|
|
||||||
children: i18n("Remove"),
|
|
||||||
})}
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer with help text -->
|
|
||||||
<div class="p-6 pt-4 border-t border-border flex-shrink-0">
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
${i18n("API keys are required to use AI models. Get your keys from the provider's website.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,635 +0,0 @@
|
||||||
import { Button, html, icon } from "@mariozechner/mini-lit";
|
|
||||||
import "@mariozechner/mini-lit/dist/ModeToggle.js";
|
|
||||||
import { renderAsync } from "docx-preview";
|
|
||||||
import { LitElement } from "lit";
|
|
||||||
import { state } from "lit/decorators.js";
|
|
||||||
import { Download, X } from "lucide";
|
|
||||||
import * as pdfjsLib from "pdfjs-dist";
|
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
|
|
||||||
|
|
||||||
export class AttachmentOverlay extends LitElement {
|
|
||||||
@state() private attachment?: Attachment;
|
|
||||||
@state() private showExtractedText = false;
|
|
||||||
@state() private error: string | null = null;
|
|
||||||
|
|
||||||
// Track current loading task to cancel if needed
|
|
||||||
private currentLoadingTask: any = null;
|
|
||||||
private onCloseCallback?: () => void;
|
|
||||||
private boundHandleKeyDown?: (e: KeyboardEvent) => void;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
static open(attachment: Attachment, onClose?: () => void) {
|
|
||||||
const overlay = new AttachmentOverlay();
|
|
||||||
overlay.attachment = attachment;
|
|
||||||
overlay.onCloseCallback = onClose;
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
overlay.setupEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupEventListeners() {
|
|
||||||
this.boundHandleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", this.boundHandleKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
private close() {
|
|
||||||
this.cleanup();
|
|
||||||
if (this.boundHandleKeyDown) {
|
|
||||||
window.removeEventListener("keydown", this.boundHandleKeyDown);
|
|
||||||
}
|
|
||||||
this.onCloseCallback?.();
|
|
||||||
this.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFileType(): FileType {
|
|
||||||
if (!this.attachment) return "text";
|
|
||||||
|
|
||||||
if (this.attachment.type === "image") return "image";
|
|
||||||
if (this.attachment.mimeType === "application/pdf") return "pdf";
|
|
||||||
if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
|
|
||||||
if (
|
|
||||||
this.attachment.mimeType?.includes("presentationml") ||
|
|
||||||
this.attachment.fileName.toLowerCase().endsWith(".pptx")
|
|
||||||
)
|
|
||||||
return "pptx";
|
|
||||||
if (
|
|
||||||
this.attachment.mimeType?.includes("spreadsheetml") ||
|
|
||||||
this.attachment.mimeType?.includes("ms-excel") ||
|
|
||||||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
|
|
||||||
this.attachment.fileName.toLowerCase().endsWith(".xls")
|
|
||||||
)
|
|
||||||
return "excel";
|
|
||||||
|
|
||||||
return "text";
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFileTypeLabel(): string {
|
|
||||||
const type = this.getFileType();
|
|
||||||
switch (type) {
|
|
||||||
case "pdf":
|
|
||||||
return i18n("PDF");
|
|
||||||
case "docx":
|
|
||||||
return i18n("Document");
|
|
||||||
case "pptx":
|
|
||||||
return i18n("Presentation");
|
|
||||||
case "excel":
|
|
||||||
return i18n("Spreadsheet");
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleBackdropClick = () => {
|
|
||||||
this.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleDownload = () => {
|
|
||||||
if (!this.attachment) return;
|
|
||||||
|
|
||||||
// Create a blob from the base64 content
|
|
||||||
const byteCharacters = atob(this.attachment.content);
|
|
||||||
const byteNumbers = new Array(byteCharacters.length);
|
|
||||||
for (let i = 0; i < byteCharacters.length; i++) {
|
|
||||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
||||||
}
|
|
||||||
const byteArray = new Uint8Array(byteNumbers);
|
|
||||||
const blob = new Blob([byteArray], { type: this.attachment.mimeType });
|
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = this.attachment.fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
private cleanup() {
|
|
||||||
this.showExtractedText = false;
|
|
||||||
this.error = null;
|
|
||||||
// Cancel any loading PDF task when closing
|
|
||||||
if (this.currentLoadingTask) {
|
|
||||||
this.currentLoadingTask.destroy();
|
|
||||||
this.currentLoadingTask = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
if (!this.attachment) return html``;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<!-- Full screen overlay -->
|
|
||||||
<div class="fixed inset-0 bg-black/90 z-50 flex flex-col" @click=${this.handleBackdropClick}>
|
|
||||||
<!-- Compact header bar -->
|
|
||||||
<div class="bg-background/95 backdrop-blur border-b border-border" @click=${(e: Event) => e.stopPropagation()}>
|
|
||||||
<div class="px-4 py-2 flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
|
||||||
<span class="text-sm font-medium text-foreground truncate">${this.attachment.fileName}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${this.renderToggle()}
|
|
||||||
${Button({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "icon",
|
|
||||||
onClick: this.handleDownload,
|
|
||||||
children: icon(Download, "sm"),
|
|
||||||
className: "h-8 w-8",
|
|
||||||
})}
|
|
||||||
${Button({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "icon",
|
|
||||||
onClick: () => this.close(),
|
|
||||||
children: icon(X, "sm"),
|
|
||||||
className: "h-8 w-8",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content container -->
|
|
||||||
<div class="flex-1 flex items-center justify-center overflow-auto" @click=${(e: Event) => e.stopPropagation()}>
|
|
||||||
${this.renderContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderToggle() {
|
|
||||||
if (!this.attachment) return html``;
|
|
||||||
|
|
||||||
const fileType = this.getFileType();
|
|
||||||
const hasExtractedText = !!this.attachment.extractedText;
|
|
||||||
const showToggle = fileType !== "image" && fileType !== "text" && fileType !== "pptx" && hasExtractedText;
|
|
||||||
|
|
||||||
if (!showToggle) return html``;
|
|
||||||
|
|
||||||
const fileTypeLabel = this.getFileTypeLabel();
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<mode-toggle
|
|
||||||
.modes=${[fileTypeLabel, i18n("Text")]}
|
|
||||||
.selectedIndex=${this.showExtractedText ? 1 : 0}
|
|
||||||
@mode-change=${(e: CustomEvent<{ index: number; mode: string }>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.showExtractedText = e.detail.index === 1;
|
|
||||||
this.error = null;
|
|
||||||
}}
|
|
||||||
></mode-toggle>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderContent() {
|
|
||||||
if (!this.attachment) return html``;
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (this.error) {
|
|
||||||
return html`
|
|
||||||
<div class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl">
|
|
||||||
<div class="font-medium mb-1">${i18n("Error loading file")}</div>
|
|
||||||
<div class="text-sm opacity-90">${this.error}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content based on file type
|
|
||||||
return this.renderFileContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderFileContent() {
|
|
||||||
if (!this.attachment) return html``;
|
|
||||||
|
|
||||||
const fileType = this.getFileType();
|
|
||||||
|
|
||||||
// Show extracted text if toggled
|
|
||||||
if (this.showExtractedText && fileType !== "image") {
|
|
||||||
return html`
|
|
||||||
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
|
|
||||||
<pre class="whitespace-pre-wrap font-mono text-xs leading-relaxed">${
|
|
||||||
this.attachment.extractedText || i18n("No text content available")
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render based on file type
|
|
||||||
switch (fileType) {
|
|
||||||
case "image": {
|
|
||||||
const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
|
|
||||||
return html`
|
|
||||||
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg shadow-lg" alt="${this.attachment.fileName}" />
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "pdf":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
id="pdf-container"
|
|
||||||
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
|
|
||||||
></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
case "docx":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
id="docx-container"
|
|
||||||
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
|
|
||||||
></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
case "excel":
|
|
||||||
return html` <div id="excel-container" class="bg-card text-foreground overflow-auto w-full h-full"></div> `;
|
|
||||||
|
|
||||||
case "pptx":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
id="pptx-container"
|
|
||||||
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
|
|
||||||
></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return html`
|
|
||||||
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
|
|
||||||
<pre class="whitespace-pre-wrap font-mono text-sm">${
|
|
||||||
this.attachment.extractedText || i18n("No content available")
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override async updated(changedProperties: Map<string, any>) {
|
|
||||||
super.updated(changedProperties);
|
|
||||||
|
|
||||||
// Only process if we need to render the actual file (not extracted text)
|
|
||||||
if (
|
|
||||||
(changedProperties.has("attachment") || changedProperties.has("showExtractedText")) &&
|
|
||||||
this.attachment &&
|
|
||||||
!this.showExtractedText &&
|
|
||||||
!this.error
|
|
||||||
) {
|
|
||||||
const fileType = this.getFileType();
|
|
||||||
|
|
||||||
switch (fileType) {
|
|
||||||
case "pdf":
|
|
||||||
await this.renderPdf();
|
|
||||||
break;
|
|
||||||
case "docx":
|
|
||||||
await this.renderDocx();
|
|
||||||
break;
|
|
||||||
case "excel":
|
|
||||||
await this.renderExcel();
|
|
||||||
break;
|
|
||||||
case "pptx":
|
|
||||||
await this.renderExtractedText();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderPdf() {
|
|
||||||
const container = this.querySelector("#pdf-container");
|
|
||||||
if (!container || !this.attachment) return;
|
|
||||||
|
|
||||||
let pdf: any = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Convert base64 to ArrayBuffer
|
|
||||||
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
|
|
||||||
|
|
||||||
// Cancel any existing loading task
|
|
||||||
if (this.currentLoadingTask) {
|
|
||||||
this.currentLoadingTask.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the PDF
|
|
||||||
this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
|
||||||
pdf = await this.currentLoadingTask.promise;
|
|
||||||
this.currentLoadingTask = null;
|
|
||||||
|
|
||||||
// Clear container and add wrapper
|
|
||||||
container.innerHTML = "";
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "";
|
|
||||||
container.appendChild(wrapper);
|
|
||||||
|
|
||||||
// Render all pages
|
|
||||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
|
||||||
const page = await pdf.getPage(pageNum);
|
|
||||||
|
|
||||||
// Create a container for each page
|
|
||||||
const pageContainer = document.createElement("div");
|
|
||||||
pageContainer.className = "mb-4 last:mb-0";
|
|
||||||
|
|
||||||
// Create canvas for this page
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
|
|
||||||
// Set scale for reasonable resolution
|
|
||||||
const viewport = page.getViewport({ scale: 1.5 });
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
|
|
||||||
// Style the canvas
|
|
||||||
canvas.className = "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
|
|
||||||
|
|
||||||
// Fill white background for proper PDF rendering
|
|
||||||
if (context) {
|
|
||||||
context.fillStyle = "white";
|
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render page
|
|
||||||
await page.render({
|
|
||||||
canvasContext: context!,
|
|
||||||
viewport: viewport,
|
|
||||||
canvas: canvas,
|
|
||||||
}).promise;
|
|
||||||
|
|
||||||
pageContainer.appendChild(canvas);
|
|
||||||
|
|
||||||
// Add page separator for multi-page documents
|
|
||||||
if (pageNum < pdf.numPages) {
|
|
||||||
const separator = document.createElement("div");
|
|
||||||
separator.className = "h-px bg-border my-4";
|
|
||||||
pageContainer.appendChild(separator);
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapper.appendChild(pageContainer);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error rendering PDF:", error);
|
|
||||||
this.error = error?.message || i18n("Failed to load PDF");
|
|
||||||
} finally {
|
|
||||||
if (pdf) {
|
|
||||||
pdf.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderDocx() {
|
|
||||||
const container = this.querySelector("#docx-container");
|
|
||||||
if (!container || !this.attachment) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Convert base64 to ArrayBuffer
|
|
||||||
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
|
|
||||||
|
|
||||||
// Clear container first
|
|
||||||
container.innerHTML = "";
|
|
||||||
|
|
||||||
// Create a wrapper div for the document
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "docx-wrapper-custom";
|
|
||||||
container.appendChild(wrapper);
|
|
||||||
|
|
||||||
// Render the DOCX file into the wrapper
|
|
||||||
await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
|
|
||||||
className: "docx",
|
|
||||||
inWrapper: true,
|
|
||||||
ignoreWidth: true, // Let it be responsive
|
|
||||||
ignoreHeight: false,
|
|
||||||
ignoreFonts: false,
|
|
||||||
breakPages: true,
|
|
||||||
ignoreLastRenderedPageBreak: true,
|
|
||||||
experimental: false,
|
|
||||||
trimXmlDeclaration: true,
|
|
||||||
useBase64URL: false,
|
|
||||||
renderHeaders: true,
|
|
||||||
renderFooters: true,
|
|
||||||
renderFootnotes: true,
|
|
||||||
renderEndnotes: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply custom styles to match theme and fix sizing
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.textContent = `
|
|
||||||
#docx-container {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#docx-container .docx-wrapper-custom {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#docx-container .docx-wrapper {
|
|
||||||
max-width: 100% !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
background: transparent !important;
|
|
||||||
padding: 0em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#docx-container .docx-wrapper > section.docx {
|
|
||||||
box-shadow: none !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 2em !important;
|
|
||||||
background: white !important;
|
|
||||||
color: black !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
overflow-x: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix tables and wide content */
|
|
||||||
#docx-container table {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: auto !important;
|
|
||||||
overflow-x: auto !important;
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#docx-container img {
|
|
||||||
max-width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix paragraphs and text */
|
|
||||||
#docx-container p,
|
|
||||||
#docx-container span,
|
|
||||||
#docx-container div {
|
|
||||||
max-width: 100% !important;
|
|
||||||
word-wrap: break-word !important;
|
|
||||||
overflow-wrap: break-word !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide page breaks in web view */
|
|
||||||
#docx-container .docx-page-break {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
container.appendChild(style);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error rendering DOCX:", error);
|
|
||||||
this.error = error?.message || i18n("Failed to load document");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderExcel() {
|
|
||||||
const container = this.querySelector("#excel-container");
|
|
||||||
if (!container || !this.attachment) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Convert base64 to ArrayBuffer
|
|
||||||
const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
|
|
||||||
|
|
||||||
// Read the workbook
|
|
||||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
|
||||||
|
|
||||||
// Clear container
|
|
||||||
container.innerHTML = "";
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "overflow-auto h-full flex flex-col";
|
|
||||||
container.appendChild(wrapper);
|
|
||||||
|
|
||||||
// Create tabs for multiple sheets
|
|
||||||
if (workbook.SheetNames.length > 1) {
|
|
||||||
const tabContainer = document.createElement("div");
|
|
||||||
tabContainer.className = "flex gap-2 mb-4 border-b border-border sticky top-0 bg-card z-10";
|
|
||||||
|
|
||||||
const sheetContents: HTMLElement[] = [];
|
|
||||||
|
|
||||||
workbook.SheetNames.forEach((sheetName, index) => {
|
|
||||||
// Create tab button
|
|
||||||
const tab = document.createElement("button");
|
|
||||||
tab.textContent = sheetName;
|
|
||||||
tab.className =
|
|
||||||
index === 0
|
|
||||||
? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
|
|
||||||
: "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
|
|
||||||
|
|
||||||
// Create sheet content
|
|
||||||
const sheetDiv = document.createElement("div");
|
|
||||||
sheetDiv.style.display = index === 0 ? "flex" : "none";
|
|
||||||
sheetDiv.className = "flex-1 overflow-auto";
|
|
||||||
sheetDiv.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
|
|
||||||
sheetContents.push(sheetDiv);
|
|
||||||
|
|
||||||
// Tab click handler
|
|
||||||
tab.onclick = () => {
|
|
||||||
// Update tab styles
|
|
||||||
tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
|
|
||||||
if (btnIndex === index) {
|
|
||||||
btn.className = "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
|
|
||||||
} else {
|
|
||||||
btn.className =
|
|
||||||
"px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Show/hide sheets
|
|
||||||
sheetContents.forEach((content, contentIndex) => {
|
|
||||||
content.style.display = contentIndex === index ? "flex" : "none";
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
tabContainer.appendChild(tab);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.appendChild(tabContainer);
|
|
||||||
sheetContents.forEach((content) => {
|
|
||||||
wrapper.appendChild(content);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Single sheet
|
|
||||||
const sheetName = workbook.SheetNames[0];
|
|
||||||
wrapper.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error rendering Excel:", error);
|
|
||||||
this.error = error?.message || i18n("Failed to load spreadsheet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
|
|
||||||
const sheetDiv = document.createElement("div");
|
|
||||||
|
|
||||||
// Generate HTML table
|
|
||||||
const htmlTable = XLSX.utils.sheet_to_html(worksheet, { id: `sheet-${sheetName}` });
|
|
||||||
const tempDiv = document.createElement("div");
|
|
||||||
tempDiv.innerHTML = htmlTable;
|
|
||||||
|
|
||||||
// Find and style the table
|
|
||||||
const table = tempDiv.querySelector("table");
|
|
||||||
if (table) {
|
|
||||||
table.className = "w-full border-collapse text-foreground";
|
|
||||||
|
|
||||||
// Style all cells
|
|
||||||
table.querySelectorAll("td, th").forEach((cell) => {
|
|
||||||
const cellEl = cell as HTMLElement;
|
|
||||||
cellEl.className = "border border-border px-3 py-2 text-sm text-left";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Style header row
|
|
||||||
const headerCells = table.querySelectorAll("thead th, tr:first-child td");
|
|
||||||
if (headerCells.length > 0) {
|
|
||||||
headerCells.forEach((th) => {
|
|
||||||
const thEl = th as HTMLElement;
|
|
||||||
thEl.className =
|
|
||||||
"border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alternate row colors
|
|
||||||
table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
|
|
||||||
const rowEl = row as HTMLElement;
|
|
||||||
rowEl.className = "bg-muted/30";
|
|
||||||
});
|
|
||||||
|
|
||||||
sheetDiv.appendChild(table);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sheetDiv;
|
|
||||||
}
|
|
||||||
|
|
||||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
|
||||||
const binaryString = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderExtractedText() {
|
|
||||||
const container = this.querySelector("#pptx-container");
|
|
||||||
if (!container || !this.attachment) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Display the extracted text content
|
|
||||||
container.innerHTML = "";
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "p-6 overflow-auto";
|
|
||||||
|
|
||||||
// Create a pre element to preserve formatting
|
|
||||||
const pre = document.createElement("pre");
|
|
||||||
pre.className = "whitespace-pre-wrap text-sm text-foreground font-mono";
|
|
||||||
pre.textContent = this.attachment.extractedText || i18n("No text content available");
|
|
||||||
|
|
||||||
wrapper.appendChild(pre);
|
|
||||||
container.appendChild(wrapper);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error rendering extracted text:", error);
|
|
||||||
this.error = error?.message || i18n("Failed to display text content");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the custom element only once
|
|
||||||
if (!customElements.get("attachment-overlay")) {
|
|
||||||
customElements.define("attachment-overlay", AttachmentOverlay);
|
|
||||||
}
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
import { Badge, Button, DialogBase, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
|
|
||||||
import type { Model } from "@mariozechner/pi-ai";
|
|
||||||
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
|
|
||||||
import type { PropertyValues } from "lit";
|
|
||||||
import { customElement, state } from "lit/decorators.js";
|
|
||||||
import { createRef, ref } from "lit/directives/ref.js";
|
|
||||||
import { Brain, Image as ImageIcon } from "lucide";
|
|
||||||
import { Ollama } from "ollama/dist/browser.mjs";
|
|
||||||
import { Input } from "../components/Input.js";
|
|
||||||
import { formatModelCost } from "../utils/format.js";
|
|
||||||
import { i18n } from "../utils/i18n.js";
|
|
||||||
|
|
||||||
@customElement("agent-model-selector")
|
|
||||||
export class ModelSelector extends DialogBase {
|
|
||||||
@state() currentModel: Model<any> | null = null;
|
|
||||||
@state() searchQuery = "";
|
|
||||||
@state() filterThinking = false;
|
|
||||||
@state() filterVision = false;
|
|
||||||
@state() ollamaModels: Model<any>[] = [];
|
|
||||||
@state() ollamaError: string | null = null;
|
|
||||||
@state() selectedIndex = 0;
|
|
||||||
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
|
||||||
|
|
||||||
private onSelectCallback?: (model: Model<any>) => void;
|
|
||||||
private scrollContainerRef = createRef<HTMLDivElement>();
|
|
||||||
private searchInputRef = createRef<HTMLInputElement>();
|
|
||||||
private lastMousePosition = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
protected override modalWidth = "min(400px, 90vw)";
|
|
||||||
|
|
||||||
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
|
|
||||||
const selector = new ModelSelector();
|
|
||||||
selector.currentModel = currentModel;
|
|
||||||
selector.onSelectCallback = onSelect;
|
|
||||||
selector.open();
|
|
||||||
selector.fetchOllamaModels();
|
|
||||||
}
|
|
||||||
|
|
||||||
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
|
||||||
super.firstUpdated(changedProperties);
|
|
||||||
// Wait for dialog to be fully rendered
|
|
||||||
await this.updateComplete;
|
|
||||||
// Focus the search input when dialog opens
|
|
||||||
this.searchInputRef.value?.focus();
|
|
||||||
|
|
||||||
// Track actual mouse movement
|
|
||||||
this.addEventListener("mousemove", (e: MouseEvent) => {
|
|
||||||
// Check if mouse actually moved
|
|
||||||
if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
|
|
||||||
this.lastMousePosition = { x: e.clientX, y: e.clientY };
|
|
||||||
// Only switch to mouse mode on actual mouse movement
|
|
||||||
if (this.navigationMode === "keyboard") {
|
|
||||||
this.navigationMode = "mouse";
|
|
||||||
// Update selection to the item under the mouse
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const modelItem = target.closest("[data-model-item]");
|
|
||||||
if (modelItem) {
|
|
||||||
const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
|
|
||||||
if (allItems) {
|
|
||||||
const index = Array.from(allItems).indexOf(modelItem);
|
|
||||||
if (index !== -1) {
|
|
||||||
this.selectedIndex = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add global keyboard handler for the dialog
|
|
||||||
this.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
||||||
// Get filtered models to know the bounds
|
|
||||||
const filteredModels = this.getFilteredModels();
|
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
this.navigationMode = "keyboard";
|
|
||||||
this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
|
|
||||||
this.scrollToSelected();
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
this.navigationMode = "keyboard";
|
|
||||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
|
||||||
this.scrollToSelected();
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (filteredModels[this.selectedIndex]) {
|
|
||||||
this.handleSelect(filteredModels[this.selectedIndex].model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchOllamaModels() {
|
|
||||||
try {
|
|
||||||
// Create Ollama client
|
|
||||||
const ollama = new Ollama({ host: "http://localhost:11434" });
|
|
||||||
|
|
||||||
// Get list of available models
|
|
||||||
const { models } = await ollama.list();
|
|
||||||
|
|
||||||
// Fetch details for each model and convert to Model format
|
|
||||||
const ollamaModelPromises: Promise<Model<any> | null>[] = models
|
|
||||||
.map(async (model) => {
|
|
||||||
try {
|
|
||||||
// Get model details
|
|
||||||
const details = await ollama.show({
|
|
||||||
model: model.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Some Ollama servers don't report capabilities; don't filter on them
|
|
||||||
|
|
||||||
// Extract model info
|
|
||||||
const modelInfo: any = details.model_info || {};
|
|
||||||
|
|
||||||
// Get context window size - look for architecture-specific keys
|
|
||||||
const architecture = modelInfo["general.architecture"] || "";
|
|
||||||
const contextKey = `${architecture}.context_length`;
|
|
||||||
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
|
|
||||||
const maxTokens = 4096; // Default max output tokens
|
|
||||||
|
|
||||||
// Create Model object manually since ollama models aren't in MODELS constant
|
|
||||||
const ollamaModel: Model<any> = {
|
|
||||||
id: model.name,
|
|
||||||
name: model.name,
|
|
||||||
api: "openai-completions" as any,
|
|
||||||
provider: "ollama",
|
|
||||||
baseUrl: "http://localhost:11434/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: contextWindow,
|
|
||||||
maxTokens: maxTokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
return ollamaModel;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to fetch details for model ${model.name}:`, err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((m) => m !== null);
|
|
||||||
|
|
||||||
const results = await Promise.all(ollamaModelPromises);
|
|
||||||
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
|
|
||||||
} catch (err) {
|
|
||||||
// Ollama not available or other error - silently ignore
|
|
||||||
console.debug("Ollama not available:", err);
|
|
||||||
this.ollamaError = err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatTokens(tokens: number): string {
|
|
||||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
|
|
||||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
|
|
||||||
return String(tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSelect(model: Model<any>) {
|
|
||||||
if (model) {
|
|
||||||
this.onSelectCallback?.(model);
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
|
|
||||||
// Collect all models from all providers
|
|
||||||
const allModels: Array<{ provider: string; id: string; model: any }> = [];
|
|
||||||
for (const [provider, providerData] of Object.entries(MODELS)) {
|
|
||||||
for (const [modelId, model] of Object.entries(providerData)) {
|
|
||||||
allModels.push({ provider, id: modelId, model });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Ollama models
|
|
||||||
for (const ollamaModel of this.ollamaModels) {
|
|
||||||
allModels.push({
|
|
||||||
id: ollamaModel.id,
|
|
||||||
provider: "ollama",
|
|
||||||
model: ollamaModel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter models based on search and capability filters
|
|
||||||
let filteredModels = allModels;
|
|
||||||
|
|
||||||
// Apply search filter
|
|
||||||
if (this.searchQuery) {
|
|
||||||
filteredModels = filteredModels.filter(({ provider, id, model }) => {
|
|
||||||
const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
|
|
||||||
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
|
|
||||||
return searchTokens.every((token) => searchText.includes(token));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply capability filters
|
|
||||||
if (this.filterThinking) {
|
|
||||||
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
|
|
||||||
}
|
|
||||||
if (this.filterVision) {
|
|
||||||
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: current model first, then by provider
|
|
||||||
filteredModels.sort((a, b) => {
|
|
||||||
const aIsCurrent = this.currentModel?.id === a.model.id;
|
|
||||||
const bIsCurrent = this.currentModel?.id === b.model.id;
|
|
||||||
if (aIsCurrent && !bIsCurrent) return -1;
|
|
||||||
if (!aIsCurrent && bIsCurrent) return 1;
|
|
||||||
return a.provider.localeCompare(b.provider);
|
|
||||||
});
|
|
||||||
|
|
||||||
return filteredModels;
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollToSelected() {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const scrollContainer = this.scrollContainerRef.value;
|
|
||||||
const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
|
|
||||||
this.selectedIndex
|
|
||||||
] as HTMLElement;
|
|
||||||
if (selectedElement) {
|
|
||||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override renderContent(): TemplateResult {
|
|
||||||
const filteredModels = this.getFilteredModels();
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<!-- Header and Search -->
|
|
||||||
<div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
|
|
||||||
${DialogHeader({ title: i18n("Select Model") })}
|
|
||||||
${Input({
|
|
||||||
placeholder: i18n("Search models..."),
|
|
||||||
value: this.searchQuery,
|
|
||||||
inputRef: this.searchInputRef,
|
|
||||||
onInput: (e: Event) => {
|
|
||||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
|
||||||
this.selectedIndex = 0;
|
|
||||||
// Reset scroll position when search changes
|
|
||||||
if (this.scrollContainerRef.value) {
|
|
||||||
this.scrollContainerRef.value.scrollTop = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
${Button({
|
|
||||||
variant: this.filterThinking ? "default" : "secondary",
|
|
||||||
size: "sm",
|
|
||||||
onClick: () => {
|
|
||||||
this.filterThinking = !this.filterThinking;
|
|
||||||
this.selectedIndex = 0;
|
|
||||||
if (this.scrollContainerRef.value) {
|
|
||||||
this.scrollContainerRef.value.scrollTop = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
className: "rounded-full",
|
|
||||||
children: html`<span class="inline-flex items-center gap-1">${icon(Brain, "sm")} ${i18n("Thinking")}</span>`,
|
|
||||||
})}
|
|
||||||
${Button({
|
|
||||||
variant: this.filterVision ? "default" : "secondary",
|
|
||||||
size: "sm",
|
|
||||||
onClick: () => {
|
|
||||||
this.filterVision = !this.filterVision;
|
|
||||||
this.selectedIndex = 0;
|
|
||||||
if (this.scrollContainerRef.value) {
|
|
||||||
this.scrollContainerRef.value.scrollTop = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
className: "rounded-full",
|
|
||||||
children: html`<span class="inline-flex items-center gap-1">${icon(ImageIcon, "sm")} ${i18n("Vision")}</span>`,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scrollable model list -->
|
|
||||||
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
|
|
||||||
${filteredModels.map(({ provider, id, model }, index) => {
|
|
||||||
// Check if this is the current model by comparing IDs
|
|
||||||
const isCurrent = this.currentModel?.id === model.id;
|
|
||||||
const isSelected = index === this.selectedIndex;
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
data-model-item
|
|
||||||
class="px-4 py-3 ${
|
|
||||||
this.navigationMode === "mouse" ? "hover:bg-muted" : ""
|
|
||||||
} cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
|
|
||||||
@click=${() => this.handleSelect(model)}
|
|
||||||
@mouseenter=${() => {
|
|
||||||
// Only update selection in mouse mode
|
|
||||||
if (this.navigationMode === "mouse") {
|
|
||||||
this.selectedIndex = index;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-2 mb-1">
|
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<span class="text-sm font-medium text-foreground truncate">${id}</span>
|
|
||||||
${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
|
|
||||||
</div>
|
|
||||||
${Badge(provider, "outline")}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
|
|
||||||
<span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
|
|
||||||
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
|
|
||||||
</div>
|
|
||||||
<span>${formatModelCost(model.cost)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
import { Button, icon } from "@mariozechner/mini-lit";
|
import { Button, icon } from "@mariozechner/mini-lit";
|
||||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||||
|
import { ApiKeysDialog, ChromeStorageAdapter, LocalStorageKeyStore, setKeyStore } from "@mariozechner/pi-web-ui";
|
||||||
|
import "@mariozechner/pi-web-ui"; // Import all web-ui components
|
||||||
import { html, LitElement, render } from "lit";
|
import { html, LitElement, render } from "lit";
|
||||||
import { customElement, state } from "lit/decorators.js";
|
import { customElement, state } from "lit/decorators.js";
|
||||||
import { Plus, RefreshCw, Settings } from "lucide";
|
import { Plus, RefreshCw, Settings } from "lucide";
|
||||||
import "./ChatPanel.js";
|
import { browserJavaScriptTool } from "./tools/index.js";
|
||||||
import "./components/SandboxedIframe.js";
|
|
||||||
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
|
||||||
import "./utils/live-reload.js";
|
import "./utils/live-reload.js";
|
||||||
|
|
||||||
|
declare const browser: any;
|
||||||
|
|
||||||
|
// Initialize browser extension storage
|
||||||
|
setKeyStore(new LocalStorageKeyStore(new ChromeStorageAdapter()));
|
||||||
|
|
||||||
|
// Get sandbox URL for extension CSP restrictions
|
||||||
|
const getSandboxUrl = () => {
|
||||||
|
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
||||||
|
return isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
||||||
|
};
|
||||||
|
|
||||||
async function getDom() {
|
async function getDom() {
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
if (!tab || !tab.id) return;
|
if (!tab || !tab.id) return;
|
||||||
|
|
@ -98,6 +109,8 @@ class App extends LitElement {
|
||||||
const newPanel = document.createElement("pi-chat-panel") as any;
|
const newPanel = document.createElement("pi-chat-panel") as any;
|
||||||
newPanel.className = "flex-1 min-h-0";
|
newPanel.className = "flex-1 min-h-0";
|
||||||
newPanel.systemPrompt = systemPrompt;
|
newPanel.systemPrompt = systemPrompt;
|
||||||
|
newPanel.additionalTools = [browserJavaScriptTool];
|
||||||
|
newPanel.sandboxUrlProvider = getSandboxUrl;
|
||||||
|
|
||||||
const container = this.querySelector(".w-full");
|
const container = this.querySelector(".w-full");
|
||||||
if (container) {
|
if (container) {
|
||||||
|
|
@ -109,7 +122,12 @@ class App extends LitElement {
|
||||||
return html`
|
return html`
|
||||||
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
|
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
|
||||||
<pi-chat-header class="shrink-0" .onNewSession=${() => this.handleNewSession()}></pi-chat-header>
|
<pi-chat-header class="shrink-0" .onNewSession=${() => this.handleNewSession()}></pi-chat-header>
|
||||||
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
|
<pi-chat-panel
|
||||||
|
class="flex-1 min-h-0"
|
||||||
|
.systemPrompt=${systemPrompt}
|
||||||
|
.additionalTools=${[browserJavaScriptTool]}
|
||||||
|
.sandboxUrlProvider=${getSandboxUrl}
|
||||||
|
></pi-chat-panel>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { getProviders } from "@mariozechner/pi-ai";
|
|
||||||
|
|
||||||
declare const browser: any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for API key storage
|
|
||||||
*/
|
|
||||||
export interface KeyStore {
|
|
||||||
getKey(provider: string): Promise<string | null>;
|
|
||||||
setKey(provider: string, key: string): Promise<void>;
|
|
||||||
removeKey(provider: string): Promise<void>;
|
|
||||||
getAllKeys(): Promise<Record<string, boolean>>; // provider -> isConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-browser storage implementation of KeyStore
|
|
||||||
*/
|
|
||||||
class BrowserKeyStore implements KeyStore {
|
|
||||||
private readonly prefix = "apiKey_";
|
|
||||||
private readonly storage: typeof chrome.storage.local;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Use browser.storage in Firefox, chrome.storage in Chrome
|
|
||||||
const isFirefox = typeof browser !== "undefined" && browser.storage !== undefined;
|
|
||||||
this.storage = isFirefox ? browser.storage.local : chrome.storage.local;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getKey(provider: string): Promise<string | null> {
|
|
||||||
const key = `${this.prefix}${provider}`;
|
|
||||||
const result = await this.storage.get(key);
|
|
||||||
return result[key] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setKey(provider: string, key: string): Promise<void> {
|
|
||||||
const storageKey = `${this.prefix}${provider}`;
|
|
||||||
await this.storage.set({ [storageKey]: key });
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeKey(provider: string): Promise<void> {
|
|
||||||
const key = `${this.prefix}${provider}`;
|
|
||||||
await this.storage.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllKeys(): Promise<Record<string, boolean>> {
|
|
||||||
const providers = getProviders();
|
|
||||||
const storage = await this.storage.get();
|
|
||||||
const result: Record<string, boolean> = {};
|
|
||||||
|
|
||||||
for (const provider of providers) {
|
|
||||||
const key = `${this.prefix}${provider}`;
|
|
||||||
result[provider] = !!storage[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const keyStore = new BrowserKeyStore();
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
import type { Context } from "@mariozechner/pi-ai";
|
|
||||||
import {
|
|
||||||
type AgentTool,
|
|
||||||
type AssistantMessage as AssistantMessageType,
|
|
||||||
getModel,
|
|
||||||
type ImageContent,
|
|
||||||
type Message,
|
|
||||||
type Model,
|
|
||||||
type TextContent,
|
|
||||||
} from "@mariozechner/pi-ai";
|
|
||||||
import type { AppMessage } from "../components/Messages.js";
|
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
import { DirectTransport } from "./transports/DirectTransport.js";
|
|
||||||
import { ProxyTransport } from "./transports/ProxyTransport.js";
|
|
||||||
import type { AgentRunConfig, AgentTransport } from "./transports/types.js";
|
|
||||||
import type { DebugLogEntry } from "./types.js";
|
|
||||||
|
|
||||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
|
||||||
|
|
||||||
export interface AgentSessionState {
|
|
||||||
id: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
model: Model<any> | null;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
tools: AgentTool<any>[];
|
|
||||||
messages: AppMessage[];
|
|
||||||
isStreaming: boolean;
|
|
||||||
streamMessage: Message | null;
|
|
||||||
pendingToolCalls: Set<string>;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentSessionEvent =
|
|
||||||
| { type: "state-update"; state: AgentSessionState }
|
|
||||||
| { type: "error-no-model" }
|
|
||||||
| { type: "error-no-api-key"; provider: string };
|
|
||||||
|
|
||||||
export type TransportMode = "direct" | "proxy";
|
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
|
||||||
initialState?: Partial<AgentSessionState>;
|
|
||||||
messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
|
||||||
debugListener?: (entry: DebugLogEntry) => void;
|
|
||||||
transportMode?: TransportMode;
|
|
||||||
authTokenProvider?: () => Promise<string | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AgentSession {
|
|
||||||
private _state: AgentSessionState = {
|
|
||||||
id: "default",
|
|
||||||
systemPrompt: "",
|
|
||||||
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
|
|
||||||
thinkingLevel: "off",
|
|
||||||
tools: [],
|
|
||||||
messages: [],
|
|
||||||
isStreaming: false,
|
|
||||||
streamMessage: null,
|
|
||||||
pendingToolCalls: new Set<string>(),
|
|
||||||
error: undefined,
|
|
||||||
};
|
|
||||||
private listeners = new Set<(e: AgentSessionEvent) => void>();
|
|
||||||
private abortController?: AbortController;
|
|
||||||
private transport: AgentTransport;
|
|
||||||
private messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
|
||||||
private debugListener?: (entry: DebugLogEntry) => void;
|
|
||||||
|
|
||||||
constructor(opts: AgentSessionOptions = {}) {
|
|
||||||
this._state = { ...this._state, ...opts.initialState };
|
|
||||||
this.messagePreprocessor = opts.messagePreprocessor;
|
|
||||||
this.debugListener = opts.debugListener;
|
|
||||||
|
|
||||||
const mode = opts.transportMode || "direct";
|
|
||||||
|
|
||||||
if (mode === "proxy") {
|
|
||||||
this.transport = new ProxyTransport(async () => this.preprocessMessages());
|
|
||||||
} else {
|
|
||||||
this.transport = new DirectTransport(async () => this.preprocessMessages());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async preprocessMessages(): Promise<Message[]> {
|
|
||||||
const filtered = this._state.messages.map((m) => {
|
|
||||||
if (m.role === "user") {
|
|
||||||
const { attachments, ...rest } = m as AppMessage & { attachments?: Attachment[] };
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
return this.messagePreprocessor ? this.messagePreprocessor(filtered as AppMessage[]) : (filtered as Message[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
get state(): AgentSessionState {
|
|
||||||
return this._state;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(fn: (e: AgentSessionEvent) => void): () => void {
|
|
||||||
this.listeners.add(fn);
|
|
||||||
fn({ type: "state-update", state: this._state });
|
|
||||||
return () => this.listeners.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutators
|
|
||||||
setSystemPrompt(v: string) {
|
|
||||||
this.patch({ systemPrompt: v });
|
|
||||||
}
|
|
||||||
setModel(m: Model<any> | null) {
|
|
||||||
this.patch({ model: m });
|
|
||||||
}
|
|
||||||
setThinkingLevel(l: ThinkingLevel) {
|
|
||||||
this.patch({ thinkingLevel: l });
|
|
||||||
}
|
|
||||||
setTools(t: AgentTool<any>[]) {
|
|
||||||
this.patch({ tools: t });
|
|
||||||
}
|
|
||||||
replaceMessages(ms: AppMessage[]) {
|
|
||||||
this.patch({ messages: ms.slice() });
|
|
||||||
}
|
|
||||||
appendMessage(m: AppMessage) {
|
|
||||||
this.patch({ messages: [...this._state.messages, m] });
|
|
||||||
}
|
|
||||||
clearMessages() {
|
|
||||||
this.patch({ messages: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
abort() {
|
|
||||||
this.abortController?.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
async prompt(input: string, attachments?: Attachment[]) {
|
|
||||||
const model = this._state.model;
|
|
||||||
if (!model) {
|
|
||||||
this.emit({ type: "error-no-model" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build user message with attachments
|
|
||||||
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
|
||||||
if (attachments?.length) {
|
|
||||||
for (const a of attachments) {
|
|
||||||
if (a.type === "image") {
|
|
||||||
content.push({ type: "image", data: a.content, mimeType: a.mimeType });
|
|
||||||
} else if (a.type === "document" && a.extractedText) {
|
|
||||||
content.push({
|
|
||||||
type: "text",
|
|
||||||
text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`,
|
|
||||||
isDocument: true,
|
|
||||||
} as TextContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMessage: AppMessage = {
|
|
||||||
role: "user",
|
|
||||||
content,
|
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.abortController = new AbortController();
|
|
||||||
this.patch({ isStreaming: true, streamMessage: null, error: undefined });
|
|
||||||
|
|
||||||
const reasoning =
|
|
||||||
this._state.thinkingLevel === "off"
|
|
||||||
? undefined
|
|
||||||
: this._state.thinkingLevel === "minimal"
|
|
||||||
? "low"
|
|
||||||
: this._state.thinkingLevel;
|
|
||||||
const cfg: AgentRunConfig = {
|
|
||||||
systemPrompt: this._state.systemPrompt,
|
|
||||||
tools: this._state.tools,
|
|
||||||
model,
|
|
||||||
reasoning,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
let partial: Message | null = null;
|
|
||||||
let turnDebug: DebugLogEntry | null = null;
|
|
||||||
let turnStart = 0;
|
|
||||||
for await (const ev of this.transport.run(userMessage as Message, cfg, this.abortController.signal)) {
|
|
||||||
switch (ev.type) {
|
|
||||||
case "turn_start": {
|
|
||||||
turnStart = performance.now();
|
|
||||||
// Build request context snapshot
|
|
||||||
const existing = this._state.messages as Message[];
|
|
||||||
const ctx: Context = {
|
|
||||||
systemPrompt: this._state.systemPrompt,
|
|
||||||
messages: [...existing],
|
|
||||||
tools: this._state.tools,
|
|
||||||
};
|
|
||||||
turnDebug = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
request: {
|
|
||||||
provider: cfg.model.provider,
|
|
||||||
model: cfg.model.id,
|
|
||||||
context: { ...ctx },
|
|
||||||
},
|
|
||||||
sseEvents: [],
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message_start":
|
|
||||||
case "message_update": {
|
|
||||||
partial = ev.message;
|
|
||||||
// Collect SSE-like events for debug (drop heavy partial)
|
|
||||||
if (ev.type === "message_update" && ev.assistantMessageEvent && turnDebug) {
|
|
||||||
const copy: any = { ...ev.assistantMessageEvent };
|
|
||||||
if (copy && "partial" in copy) delete copy.partial;
|
|
||||||
turnDebug.sseEvents.push(JSON.stringify(copy));
|
|
||||||
if (!turnDebug.ttft) turnDebug.ttft = performance.now() - turnStart;
|
|
||||||
}
|
|
||||||
this.patch({ streamMessage: ev.message });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message_end": {
|
|
||||||
partial = null;
|
|
||||||
this.appendMessage(ev.message as AppMessage);
|
|
||||||
this.patch({ streamMessage: null });
|
|
||||||
if (turnDebug) {
|
|
||||||
if (ev.message.role !== "assistant" && ev.message.role !== "toolResult") {
|
|
||||||
turnDebug.request.context.messages.push(ev.message);
|
|
||||||
}
|
|
||||||
if (ev.message.role === "assistant") turnDebug.response = ev.message as any;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_execution_start": {
|
|
||||||
const s = new Set(this._state.pendingToolCalls);
|
|
||||||
s.add(ev.toolCallId);
|
|
||||||
this.patch({ pendingToolCalls: s });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_execution_end": {
|
|
||||||
const s = new Set(this._state.pendingToolCalls);
|
|
||||||
s.delete(ev.toolCallId);
|
|
||||||
this.patch({ pendingToolCalls: s });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "turn_end": {
|
|
||||||
// finalize current turn
|
|
||||||
if (turnDebug) {
|
|
||||||
turnDebug.totalTime = performance.now() - turnStart;
|
|
||||||
this.debugListener?.(turnDebug);
|
|
||||||
turnDebug = null;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "agent_end": {
|
|
||||||
this.patch({ streamMessage: null });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partial && partial.role === "assistant" && partial.content.length > 0) {
|
|
||||||
const onlyEmpty = !partial.content.some(
|
|
||||||
(c) =>
|
|
||||||
(c.type === "thinking" && c.thinking.trim().length > 0) ||
|
|
||||||
(c.type === "text" && c.text.trim().length > 0) ||
|
|
||||||
(c.type === "toolCall" && c.name.trim().length > 0),
|
|
||||||
);
|
|
||||||
if (!onlyEmpty) {
|
|
||||||
this.appendMessage(partial as AppMessage);
|
|
||||||
} else {
|
|
||||||
if (this.abortController?.signal.aborted) {
|
|
||||||
throw new Error("Request was aborted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (String(err?.message || err) === "no-api-key") {
|
|
||||||
this.emit({ type: "error-no-api-key", provider: model.provider });
|
|
||||||
} else {
|
|
||||||
const msg: AssistantMessageType = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "" }],
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
|
|
||||||
errorMessage: err?.message || String(err),
|
|
||||||
};
|
|
||||||
this.appendMessage(msg as AppMessage);
|
|
||||||
this.patch({ error: err?.message || String(err) });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() });
|
|
||||||
this.abortController = undefined;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
const { systemPrompt, model, messages } = this._state;
|
|
||||||
console.log("final state:", { systemPrompt, model, messages });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private patch(p: Partial<AgentSessionState>): void {
|
|
||||||
this._state = { ...this._state, ...p };
|
|
||||||
this.emit({ type: "state-update", state: this._state });
|
|
||||||
}
|
|
||||||
|
|
||||||
private emit(e: AgentSessionEvent) {
|
|
||||||
for (const listener of this.listeners) {
|
|
||||||
listener(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { keyStore } from "../KeyStore.js";
|
|
||||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
|
||||||
|
|
||||||
export class DirectTransport implements AgentTransport {
|
|
||||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
|
||||||
|
|
||||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
|
||||||
// Get API key from KeyStore
|
|
||||||
const apiKey = await keyStore.getKey(cfg.model.provider);
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error("no-api-key");
|
|
||||||
}
|
|
||||||
|
|
||||||
const context: AgentContext = {
|
|
||||||
systemPrompt: cfg.systemPrompt,
|
|
||||||
messages: await this.getMessages(),
|
|
||||||
tools: cfg.tools,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pc: PromptConfig = {
|
|
||||||
model: cfg.model,
|
|
||||||
reasoning: cfg.reasoning,
|
|
||||||
apiKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Yield events from agentLoop
|
|
||||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) {
|
|
||||||
yield ev;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
import type {
|
|
||||||
AgentContext,
|
|
||||||
AssistantMessage,
|
|
||||||
AssistantMessageEvent,
|
|
||||||
Context,
|
|
||||||
Message,
|
|
||||||
Model,
|
|
||||||
PromptConfig,
|
|
||||||
SimpleStreamOptions,
|
|
||||||
ToolCall,
|
|
||||||
UserMessage,
|
|
||||||
} from "@mariozechner/pi-ai";
|
|
||||||
import { agentLoop } from "@mariozechner/pi-ai";
|
|
||||||
import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js";
|
|
||||||
import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js";
|
|
||||||
import { clearAuthToken, getAuthToken } from "../../utils/auth-token.js";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import type { ProxyAssistantMessageEvent } from "./proxy-types.js";
|
|
||||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream function that proxies through a server instead of calling providers directly.
|
|
||||||
* The server strips the partial field from delta events to reduce bandwidth.
|
|
||||||
* We reconstruct the partial message client-side.
|
|
||||||
*/
|
|
||||||
function streamSimpleProxy(
|
|
||||||
model: Model<any>,
|
|
||||||
context: Context,
|
|
||||||
options: SimpleStreamOptions & { authToken: string },
|
|
||||||
proxyUrl: string,
|
|
||||||
): AssistantMessageEventStream {
|
|
||||||
const stream = new AssistantMessageEventStream();
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
// Initialize the partial message that we'll build up from events
|
|
||||||
const partial: AssistantMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
stopReason: "stop",
|
|
||||||
content: [],
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
||||||
|
|
||||||
// Set up abort handler to cancel the reader
|
|
||||||
const abortHandler = () => {
|
|
||||||
if (reader) {
|
|
||||||
reader.cancel("Request aborted by user").catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.signal) {
|
|
||||||
options.signal.addEventListener("abort", abortHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${proxyUrl}/api/stream`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${options.authToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
|
||||||
context,
|
|
||||||
options: {
|
|
||||||
temperature: options.temperature,
|
|
||||||
maxTokens: options.maxTokens,
|
|
||||||
reasoning: options.reasoning,
|
|
||||||
// Don't send apiKey or signal - those are added server-side
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
signal: options.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (errorData.error) {
|
|
||||||
errorMessage = `Proxy error: ${errorData.error}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Couldn't parse error response, use default message
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse SSE stream
|
|
||||||
reader = response.body!.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
// Check if aborted after reading
|
|
||||||
if (options.signal?.aborted) {
|
|
||||||
throw new Error("Request aborted by user");
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() || "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("data: ")) {
|
|
||||||
const data = line.slice(6).trim();
|
|
||||||
if (data) {
|
|
||||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
|
||||||
let event: AssistantMessageEvent | undefined;
|
|
||||||
|
|
||||||
// Handle different event types
|
|
||||||
// Server sends events with partial for non-delta events,
|
|
||||||
// and without partial for delta events
|
|
||||||
switch (proxyEvent.type) {
|
|
||||||
case "start":
|
|
||||||
event = { type: "start", partial };
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "text_start":
|
|
||||||
partial.content[proxyEvent.contentIndex] = {
|
|
||||||
type: "text",
|
|
||||||
text: "",
|
|
||||||
};
|
|
||||||
event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "text_delta": {
|
|
||||||
const content = partial.content[proxyEvent.contentIndex];
|
|
||||||
if (content?.type === "text") {
|
|
||||||
content.text += proxyEvent.delta;
|
|
||||||
event = {
|
|
||||||
type: "text_delta",
|
|
||||||
contentIndex: proxyEvent.contentIndex,
|
|
||||||
delta: proxyEvent.delta,
|
|
||||||
partial,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error("Received text_delta for non-text content");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "text_end": {
|
|
||||||
const content = partial.content[proxyEvent.contentIndex];
|
|
||||||
if (content?.type === "text") {
|
|
||||||
content.textSignature = proxyEvent.contentSignature;
|
|
||||||
event = {
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: proxyEvent.contentIndex,
|
|
||||||
content: content.text,
|
|
||||||
partial,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error("Received text_end for non-text content");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "thinking_start":
|
|
||||||
partial.content[proxyEvent.contentIndex] = {
|
|
||||||
type: "thinking",
|
|
||||||
thinking: "",
|
|
||||||
};
|
|
||||||
event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "thinking_delta": {
|
|
||||||
const content = partial.content[proxyEvent.contentIndex];
|
|
||||||
if (content?.type === "thinking") {
|
|
||||||
content.thinking += proxyEvent.delta;
|
|
||||||
event = {
|
|
||||||
type: "thinking_delta",
|
|
||||||
contentIndex: proxyEvent.contentIndex,
|
|
||||||
delta: proxyEvent.delta,
|
|
||||||
partial,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error("Received thinking_delta for non-thinking content");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "thinking_end": {
|
|
||||||
const content = partial.content[proxyEvent.contentIndex];
|
|
||||||
if (content?.type === "thinking") {
|
|
||||||
content.thinkingSignature = proxyEvent.contentSignature;
|
|
||||||
event = {
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: proxyEvent.contentIndex,
|
|
||||||
content: content.thinking,
|
|
||||||
partial,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error("Received thinking_end for non-thinking content");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "toolcall_start":
|
|
||||||
partial.content[proxyEvent.contentIndex] = {
|
|
||||||
type: "toolCall",
|
|
||||||
id: proxyEvent.id,
|
|
||||||
name: proxyEvent.toolName,
|
|
||||||
arguments: {},
|
|
||||||
partialJson: "",
|
|
||||||
} satisfies ToolCall & { partialJson: string } as ToolCall;
|
|
||||||
event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "toolcall_delta": {
|
|
||||||
const content = partial.content[proxyEvent.contentIndex];
|
|
||||||
if (content?.type === "toolCall") {
|
|
||||||
(content as any).partialJson += proxyEvent.delta;
|
|
||||||
content.arguments = parseStreamingJson((content as any).partialJson) || {};
|
|
||||||
event = {
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: proxyEvent.contentIndex,
|
|
||||||
delta: proxyEvent.delta,
|
|
||||||
partial,
|
|
||||||
};
|
|
||||||
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
|
||||||
} else {
|
|
||||||
throw new Error("Received toolcall_delta for non-toolCall content");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "toolcall_end": {
|
|
||||||
const content = partial.content[proxyEvent.contentIndex];
|
|
||||||
if (content?.type === "toolCall") {
|
|
||||||
delete (content as any).partialJson;
|
|
||||||
event = {
|
|
||||||
type: "toolcall_end",
|
|
||||||
contentIndex: proxyEvent.contentIndex,
|
|
||||||
toolCall: content,
|
|
||||||
partial,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "done":
|
|
||||||
partial.stopReason = proxyEvent.reason;
|
|
||||||
partial.usage = proxyEvent.usage;
|
|
||||||
event = { type: "done", reason: proxyEvent.reason, message: partial };
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "error":
|
|
||||||
partial.stopReason = proxyEvent.reason;
|
|
||||||
partial.errorMessage = proxyEvent.errorMessage;
|
|
||||||
partial.usage = proxyEvent.usage;
|
|
||||||
event = { type: "error", reason: proxyEvent.reason, error: partial };
|
|
||||||
break;
|
|
||||||
|
|
||||||
default: {
|
|
||||||
// Exhaustive check
|
|
||||||
const _exhaustiveCheck: never = proxyEvent;
|
|
||||||
console.warn(`Unhandled event type: ${(proxyEvent as any).type}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push the event to stream
|
|
||||||
if (event) {
|
|
||||||
stream.push(event);
|
|
||||||
} else {
|
|
||||||
throw new Error("Failed to create event from proxy event");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if aborted after reading
|
|
||||||
if (options.signal?.aborted) {
|
|
||||||
throw new Error("Request aborted by user");
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.end();
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
if (errorMessage.toLowerCase().includes("proxy") && errorMessage.includes("Unauthorized")) {
|
|
||||||
clearAuthToken();
|
|
||||||
}
|
|
||||||
partial.stopReason = options.signal?.aborted ? "aborted" : "error";
|
|
||||||
partial.errorMessage = errorMessage;
|
|
||||||
stream.push({
|
|
||||||
type: "error",
|
|
||||||
reason: partial.stopReason,
|
|
||||||
error: partial,
|
|
||||||
} satisfies AssistantMessageEvent);
|
|
||||||
stream.end();
|
|
||||||
} finally {
|
|
||||||
// Clean up abort handler
|
|
||||||
if (options.signal) {
|
|
||||||
options.signal.removeEventListener("abort", abortHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy transport executes the turn using a remote proxy server
|
|
||||||
export class ProxyTransport implements AgentTransport {
|
|
||||||
// Hardcoded proxy URL for now - will be made configurable later
|
|
||||||
private readonly proxyUrl = "https://genai.mariozechner.at";
|
|
||||||
|
|
||||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
|
||||||
|
|
||||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
|
||||||
const authToken = await getAuthToken();
|
|
||||||
if (!authToken) {
|
|
||||||
throw new Error(i18n("Auth token is required for proxy transport"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use proxy - no local API key needed
|
|
||||||
const streamFn = (model: Model<any>, context: Context, options: SimpleStreamOptions | undefined) => {
|
|
||||||
return streamSimpleProxy(
|
|
||||||
model,
|
|
||||||
context,
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
authToken,
|
|
||||||
},
|
|
||||||
this.proxyUrl,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const context: AgentContext = {
|
|
||||||
systemPrompt: cfg.systemPrompt,
|
|
||||||
messages: await this.getMessages(),
|
|
||||||
tools: cfg.tools,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pc: PromptConfig = {
|
|
||||||
model: cfg.model,
|
|
||||||
reasoning: cfg.reasoning,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Yield events from the upstream agentLoop iterator
|
|
||||||
// Pass streamFn as the 5th parameter to use proxy
|
|
||||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn)) {
|
|
||||||
yield ev;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from "./DirectTransport.js";
|
|
||||||
export * from "./ProxyTransport.js";
|
|
||||||
export * from "./types.js";
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import type { StopReason, Usage } from "@mariozechner/pi-ai";
|
|
||||||
|
|
||||||
export type ProxyAssistantMessageEvent =
|
|
||||||
| { type: "start" }
|
|
||||||
| { type: "text_start"; contentIndex: number }
|
|
||||||
| { type: "text_delta"; contentIndex: number; delta: string }
|
|
||||||
| { type: "text_end"; contentIndex: number; contentSignature?: string }
|
|
||||||
| { type: "thinking_start"; contentIndex: number }
|
|
||||||
| { type: "thinking_delta"; contentIndex: number; delta: string }
|
|
||||||
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
|
|
||||||
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
|
|
||||||
| { type: "toolcall_delta"; contentIndex: number; delta: string }
|
|
||||||
| { type: "toolcall_end"; contentIndex: number }
|
|
||||||
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; usage: Usage }
|
|
||||||
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; errorMessage: string; usage: Usage };
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import type { AgentEvent, AgentTool, Message, Model } from "@mariozechner/pi-ai";
|
|
||||||
|
|
||||||
// The minimal configuration needed to run a turn.
|
|
||||||
export interface AgentRunConfig {
|
|
||||||
systemPrompt: string;
|
|
||||||
tools: AgentTool<any>[];
|
|
||||||
model: Model<any>;
|
|
||||||
reasoning?: "low" | "medium" | "high";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Events yielded by transports must match the @mariozechner/pi-ai prompt() events.
|
|
||||||
// We re-export the Message type above; consumers should use the upstream AgentEvent type.
|
|
||||||
|
|
||||||
export interface AgentTransport {
|
|
||||||
run(userMessage: Message, config: AgentRunConfig, signal?: AbortSignal): AsyncIterable<AgentEvent>; // passthrough of AgentEvent from upstream
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import type { AssistantMessage, Context } from "@mariozechner/pi-ai";
|
|
||||||
|
|
||||||
export interface DebugLogEntry {
|
|
||||||
timestamp: string;
|
|
||||||
request: { provider: string; model: string; context: Context };
|
|
||||||
response?: AssistantMessage;
|
|
||||||
error?: unknown;
|
|
||||||
sseEvents: string[];
|
|
||||||
ttft?: number;
|
|
||||||
totalTime?: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { LitElement, type TemplateResult } from "lit";
|
|
||||||
|
|
||||||
export abstract class ArtifactElement extends LitElement {
|
|
||||||
public filename = "";
|
|
||||||
public displayTitle = "";
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this; // light DOM for shared styles
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract get content(): string;
|
|
||||||
public abstract set content(value: string);
|
|
||||||
|
|
||||||
abstract getHeaderButtons(): TemplateResult | HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
|
|
||||||
import hljs from "highlight.js";
|
|
||||||
import { html } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
||||||
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
|
|
||||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import "../../components/SandboxedIframe.js";
|
|
||||||
import { ArtifactElement } from "./ArtifactElement.js";
|
|
||||||
|
|
||||||
@customElement("html-artifact")
|
|
||||||
export class HtmlArtifact extends ArtifactElement {
|
|
||||||
@property() override filename = "";
|
|
||||||
@property({ attribute: false }) override displayTitle = "";
|
|
||||||
@property({ attribute: false }) attachments: Attachment[] = [];
|
|
||||||
|
|
||||||
private _content = "";
|
|
||||||
private logs: Array<{ type: "log" | "error"; text: string }> = [];
|
|
||||||
|
|
||||||
// Refs for DOM elements
|
|
||||||
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
|
|
||||||
private consoleLogsRef: Ref<HTMLDivElement> = createRef();
|
|
||||||
private consoleButtonRef: Ref<HTMLButtonElement> = createRef();
|
|
||||||
|
|
||||||
// Store message handler so we can remove it
|
|
||||||
private messageHandler?: (e: MessageEvent) => void;
|
|
||||||
|
|
||||||
@state() private viewMode: "preview" | "code" = "preview";
|
|
||||||
@state() private consoleOpen = false;
|
|
||||||
|
|
||||||
private setViewMode(mode: "preview" | "code") {
|
|
||||||
this.viewMode = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getHeaderButtons() {
|
|
||||||
const toggle = new PreviewCodeToggle();
|
|
||||||
toggle.mode = this.viewMode;
|
|
||||||
toggle.addEventListener("mode-change", (e: Event) => {
|
|
||||||
this.setViewMode((e as CustomEvent).detail);
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyButton = new CopyButton();
|
|
||||||
copyButton.text = this._content;
|
|
||||||
copyButton.title = i18n("Copy HTML");
|
|
||||||
copyButton.showText = false;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${toggle}
|
|
||||||
${copyButton}
|
|
||||||
${DownloadButton({ content: this._content, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
override set content(value: string) {
|
|
||||||
const oldValue = this._content;
|
|
||||||
this._content = value;
|
|
||||||
if (oldValue !== value) {
|
|
||||||
// Reset logs when content changes
|
|
||||||
this.logs = [];
|
|
||||||
if (this.consoleLogsRef.value) {
|
|
||||||
this.consoleLogsRef.value.innerHTML = "";
|
|
||||||
}
|
|
||||||
this.requestUpdate();
|
|
||||||
// Execute content in sandbox if it exists
|
|
||||||
if (this.sandboxIframeRef.value && value) {
|
|
||||||
this.updateConsoleButton();
|
|
||||||
this.executeContent(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private executeContent(html: string) {
|
|
||||||
const sandbox = this.sandboxIframeRef.value;
|
|
||||||
if (!sandbox) return;
|
|
||||||
|
|
||||||
// Remove previous message handler if it exists
|
|
||||||
if (this.messageHandler) {
|
|
||||||
window.removeEventListener("message", this.messageHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sandboxId = `artifact-${this.filename}`;
|
|
||||||
|
|
||||||
// Set up message listener to collect logs
|
|
||||||
this.messageHandler = (e: MessageEvent) => {
|
|
||||||
if (e.data.sandboxId !== sandboxId) return;
|
|
||||||
|
|
||||||
if (e.data.type === "console") {
|
|
||||||
this.logs.push({
|
|
||||||
type: e.data.method === "error" ? "error" : "log",
|
|
||||||
text: e.data.text,
|
|
||||||
});
|
|
||||||
this.updateConsoleButton();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("message", this.messageHandler);
|
|
||||||
|
|
||||||
// Load content (iframe persists, doesn't get removed)
|
|
||||||
sandbox.loadContent(sandboxId, html, this.attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
override get content(): string {
|
|
||||||
return this._content;
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
// Clean up message handler when element is removed from DOM
|
|
||||||
if (this.messageHandler) {
|
|
||||||
window.removeEventListener("message", this.messageHandler);
|
|
||||||
this.messageHandler = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override firstUpdated() {
|
|
||||||
// Execute initial content
|
|
||||||
if (this._content && this.sandboxIframeRef.value) {
|
|
||||||
this.executeContent(this._content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override updated(changedProperties: Map<string | number | symbol, unknown>) {
|
|
||||||
super.updated(changedProperties);
|
|
||||||
// If we have content but haven't executed yet (e.g., during reconstruction),
|
|
||||||
// execute when the iframe ref becomes available
|
|
||||||
if (this._content && this.sandboxIframeRef.value && this.logs.length === 0) {
|
|
||||||
this.executeContent(this._content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateConsoleButton() {
|
|
||||||
const button = this.consoleButtonRef.value;
|
|
||||||
if (!button) return;
|
|
||||||
|
|
||||||
const errorCount = this.logs.filter((l) => l.type === "error").length;
|
|
||||||
const text =
|
|
||||||
errorCount > 0
|
|
||||||
? `${i18n("console")} <span class="text-destructive">${errorCount} errors</span>`
|
|
||||||
: `${i18n("console")} (${this.logs.length})`;
|
|
||||||
button.innerHTML = `<span>${text}</span><span>${this.consoleOpen ? "▼" : "▶"}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private toggleConsole() {
|
|
||||||
this.consoleOpen = !this.consoleOpen;
|
|
||||||
this.requestUpdate();
|
|
||||||
|
|
||||||
// Populate console logs if opening
|
|
||||||
if (this.consoleOpen) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this.consoleLogsRef.value) {
|
|
||||||
// Populate with existing logs
|
|
||||||
this.consoleLogsRef.value.innerHTML = "";
|
|
||||||
this.logs.forEach((log) => {
|
|
||||||
const logEl = document.createElement("div");
|
|
||||||
logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
|
|
||||||
logEl.textContent = `[${log.type}] ${log.text}`;
|
|
||||||
this.consoleLogsRef.value!.appendChild(logEl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getLogs(): string {
|
|
||||||
if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename);
|
|
||||||
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
return html`
|
|
||||||
<div class="h-full flex flex-col">
|
|
||||||
<div class="flex-1 overflow-hidden relative">
|
|
||||||
<!-- Preview container - always in DOM, just hidden when not active -->
|
|
||||||
<div class="absolute inset-0 flex flex-col" style="display: ${this.viewMode === "preview" ? "flex" : "none"}">
|
|
||||||
<sandbox-iframe class="flex-1" ${ref(this.sandboxIframeRef)}></sandbox-iframe>
|
|
||||||
${
|
|
||||||
this.logs.length > 0
|
|
||||||
? html`
|
|
||||||
<div class="border-t border-border">
|
|
||||||
<button
|
|
||||||
@click=${() => this.toggleConsole()}
|
|
||||||
class="w-full px-3 py-1 text-xs text-left hover:bg-muted flex items-center justify-between"
|
|
||||||
${ref(this.consoleButtonRef)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
>${i18n("console")}
|
|
||||||
${
|
|
||||||
this.logs.filter((l) => l.type === "error").length > 0
|
|
||||||
? html`<span class="text-destructive">${this.logs.filter((l) => l.type === "error").length} errors</span>`
|
|
||||||
: `(${this.logs.length})`
|
|
||||||
}</span
|
|
||||||
>
|
|
||||||
<span>${this.consoleOpen ? "▼" : "▶"}</span>
|
|
||||||
</button>
|
|
||||||
${this.consoleOpen ? html` <div class="max-h-48 overflow-y-auto bg-muted/50 p-2" ${ref(this.consoleLogsRef)}></div> ` : ""}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Code view - always in DOM, just hidden when not active -->
|
|
||||||
<div class="absolute inset-0 overflow-auto bg-background" style="display: ${this.viewMode === "code" ? "block" : "none"}">
|
|
||||||
<pre class="m-0 p-4 text-xs"><code class="hljs language-html">${unsafeHTML(
|
|
||||||
hljs.highlight(this._content, { language: "html" }).value,
|
|
||||||
)}</code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
|
|
||||||
import hljs from "highlight.js";
|
|
||||||
import { html } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
|
||||||
import { ArtifactElement } from "./ArtifactElement.js";
|
|
||||||
|
|
||||||
@customElement("markdown-artifact")
|
|
||||||
export class MarkdownArtifact extends ArtifactElement {
|
|
||||||
@property() override filename = "";
|
|
||||||
@property({ attribute: false }) override displayTitle = "";
|
|
||||||
|
|
||||||
private _content = "";
|
|
||||||
override get content(): string {
|
|
||||||
return this._content;
|
|
||||||
}
|
|
||||||
override set content(value: string) {
|
|
||||||
this._content = value;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
@state() private viewMode: "preview" | "code" = "preview";
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this; // light DOM
|
|
||||||
}
|
|
||||||
|
|
||||||
private setViewMode(mode: "preview" | "code") {
|
|
||||||
this.viewMode = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getHeaderButtons() {
|
|
||||||
const toggle = new PreviewCodeToggle();
|
|
||||||
toggle.mode = this.viewMode;
|
|
||||||
toggle.addEventListener("mode-change", (e: Event) => {
|
|
||||||
this.setViewMode((e as CustomEvent).detail);
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyButton = new CopyButton();
|
|
||||||
copyButton.text = this._content;
|
|
||||||
copyButton.title = i18n("Copy Markdown");
|
|
||||||
copyButton.showText = false;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${toggle}
|
|
||||||
${copyButton}
|
|
||||||
${DownloadButton({
|
|
||||||
content: this._content,
|
|
||||||
filename: this.filename,
|
|
||||||
mimeType: "text/markdown",
|
|
||||||
title: i18n("Download Markdown"),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
return html`
|
|
||||||
<div class="h-full flex flex-col">
|
|
||||||
<div class="flex-1 overflow-auto">
|
|
||||||
${
|
|
||||||
this.viewMode === "preview"
|
|
||||||
? html`<div class="p-4"><markdown-block .content=${this.content}></markdown-block></div>`
|
|
||||||
: html`<pre class="m-0 p-4 text-xs whitespace-pre-wrap break-words"><code class="hljs language-markdown">${unsafeHTML(
|
|
||||||
hljs.highlight(this.content, { language: "markdown", ignoreIllegals: true }).value,
|
|
||||||
)}</code></pre>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
"markdown-artifact": MarkdownArtifact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
|
|
||||||
import hljs from "highlight.js";
|
|
||||||
import { html } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import { ArtifactElement } from "./ArtifactElement.js";
|
|
||||||
|
|
||||||
@customElement("svg-artifact")
|
|
||||||
export class SvgArtifact extends ArtifactElement {
|
|
||||||
@property() override filename = "";
|
|
||||||
@property({ attribute: false }) override displayTitle = "";
|
|
||||||
|
|
||||||
private _content = "";
|
|
||||||
override get content(): string {
|
|
||||||
return this._content;
|
|
||||||
}
|
|
||||||
override set content(value: string) {
|
|
||||||
this._content = value;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
@state() private viewMode: "preview" | "code" = "preview";
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this; // light DOM
|
|
||||||
}
|
|
||||||
|
|
||||||
private setViewMode(mode: "preview" | "code") {
|
|
||||||
this.viewMode = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getHeaderButtons() {
|
|
||||||
const toggle = new PreviewCodeToggle();
|
|
||||||
toggle.mode = this.viewMode;
|
|
||||||
toggle.addEventListener("mode-change", (e: Event) => {
|
|
||||||
this.setViewMode((e as CustomEvent).detail);
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyButton = new CopyButton();
|
|
||||||
copyButton.text = this._content;
|
|
||||||
copyButton.title = i18n("Copy SVG");
|
|
||||||
copyButton.showText = false;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${toggle}
|
|
||||||
${copyButton}
|
|
||||||
${DownloadButton({ content: this._content, filename: this.filename, mimeType: "image/svg+xml", title: i18n("Download SVG") })}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
return html`
|
|
||||||
<div class="h-full flex flex-col">
|
|
||||||
<div class="flex-1 overflow-auto">
|
|
||||||
${
|
|
||||||
this.viewMode === "preview"
|
|
||||||
? html`<div class="h-full flex items-center justify-center">
|
|
||||||
${unsafeHTML(this.content.replace(/<svg(\s|>)/i, (_m, p1) => `<svg class="w-full h-full"${p1}`))}
|
|
||||||
</div>`
|
|
||||||
: html`<pre class="m-0 p-4 text-xs"><code class="hljs language-xml">${unsafeHTML(
|
|
||||||
hljs.highlight(this.content, { language: "xml", ignoreIllegals: true }).value,
|
|
||||||
)}</code></pre>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
"svg-artifact": SvgArtifact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import { CopyButton, DownloadButton } from "@mariozechner/mini-lit";
|
|
||||||
import hljs from "highlight.js";
|
|
||||||
import { html } from "lit";
|
|
||||||
import { customElement, property } from "lit/decorators.js";
|
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import { ArtifactElement } from "./ArtifactElement.js";
|
|
||||||
|
|
||||||
// Known code file extensions for highlighting
|
|
||||||
const CODE_EXTENSIONS = [
|
|
||||||
"js",
|
|
||||||
"javascript",
|
|
||||||
"ts",
|
|
||||||
"typescript",
|
|
||||||
"jsx",
|
|
||||||
"tsx",
|
|
||||||
"py",
|
|
||||||
"python",
|
|
||||||
"java",
|
|
||||||
"c",
|
|
||||||
"cpp",
|
|
||||||
"cs",
|
|
||||||
"php",
|
|
||||||
"rb",
|
|
||||||
"ruby",
|
|
||||||
"go",
|
|
||||||
"rust",
|
|
||||||
"swift",
|
|
||||||
"kotlin",
|
|
||||||
"scala",
|
|
||||||
"dart",
|
|
||||||
"html",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"sass",
|
|
||||||
"less",
|
|
||||||
"json",
|
|
||||||
"xml",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"toml",
|
|
||||||
"sql",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"ps1",
|
|
||||||
"bat",
|
|
||||||
"r",
|
|
||||||
"matlab",
|
|
||||||
"julia",
|
|
||||||
"lua",
|
|
||||||
"perl",
|
|
||||||
"vue",
|
|
||||||
"svelte",
|
|
||||||
];
|
|
||||||
|
|
||||||
@customElement("text-artifact")
|
|
||||||
export class TextArtifact extends ArtifactElement {
|
|
||||||
@property() override filename = "";
|
|
||||||
@property({ attribute: false }) override displayTitle = "";
|
|
||||||
|
|
||||||
private _content = "";
|
|
||||||
override get content(): string {
|
|
||||||
return this._content;
|
|
||||||
}
|
|
||||||
override set content(value: string) {
|
|
||||||
this._content = value;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this; // light DOM
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCode(): boolean {
|
|
||||||
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
|
|
||||||
return CODE_EXTENSIONS.includes(ext);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLanguageFromExtension(ext: string): string {
|
|
||||||
const languageMap: Record<string, string> = {
|
|
||||||
js: "javascript",
|
|
||||||
ts: "typescript",
|
|
||||||
py: "python",
|
|
||||||
rb: "ruby",
|
|
||||||
yml: "yaml",
|
|
||||||
ps1: "powershell",
|
|
||||||
bat: "batch",
|
|
||||||
};
|
|
||||||
return languageMap[ext] || ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMimeType(): string {
|
|
||||||
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
|
|
||||||
if (ext === "svg") return "image/svg+xml";
|
|
||||||
if (ext === "md" || ext === "markdown") return "text/markdown";
|
|
||||||
return "text/plain";
|
|
||||||
}
|
|
||||||
|
|
||||||
public getHeaderButtons() {
|
|
||||||
const copyButton = new CopyButton();
|
|
||||||
copyButton.text = this.content;
|
|
||||||
copyButton.title = i18n("Copy");
|
|
||||||
copyButton.showText = false;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
${copyButton}
|
|
||||||
${DownloadButton({
|
|
||||||
content: this.content,
|
|
||||||
filename: this.filename,
|
|
||||||
mimeType: this.getMimeType(),
|
|
||||||
title: i18n("Download"),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
const isCode = this.isCode();
|
|
||||||
const ext = this.filename.split(".").pop() || "";
|
|
||||||
return html`
|
|
||||||
<div class="h-full flex flex-col">
|
|
||||||
<div class="flex-1 overflow-auto">
|
|
||||||
${
|
|
||||||
isCode
|
|
||||||
? html`
|
|
||||||
<pre class="m-0 p-4 text-xs"><code class="hljs language-${this.getLanguageFromExtension(
|
|
||||||
ext.toLowerCase(),
|
|
||||||
)}">${unsafeHTML(
|
|
||||||
hljs.highlight(this.content, {
|
|
||||||
language: this.getLanguageFromExtension(ext.toLowerCase()),
|
|
||||||
ignoreIllegals: true,
|
|
||||||
}).value,
|
|
||||||
)}</code></pre>
|
|
||||||
`
|
|
||||||
: html` <pre class="m-0 p-4 text-xs font-mono">${this.content}</pre> `
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
"text-artifact": TextArtifact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,882 +0,0 @@
|
||||||
import { Button, Diff, icon } from "@mariozechner/mini-lit";
|
|
||||||
import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
|
||||||
import { html, LitElement, type TemplateResult } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
|
||||||
import { X } from "lucide";
|
|
||||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import type { ToolRenderer } from "../types.js";
|
|
||||||
import type { ArtifactElement } from "./ArtifactElement.js";
|
|
||||||
import { HtmlArtifact } from "./HtmlArtifact.js";
|
|
||||||
import { MarkdownArtifact } from "./MarkdownArtifact.js";
|
|
||||||
import { SvgArtifact } from "./SvgArtifact.js";
|
|
||||||
import { TextArtifact } from "./TextArtifact.js";
|
|
||||||
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
|
||||||
import "@mariozechner/mini-lit/dist/CodeBlock.js";
|
|
||||||
|
|
||||||
// Simple artifact model
|
|
||||||
export interface Artifact {
|
|
||||||
filename: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON-schema friendly parameters object (LLM-facing)
|
|
||||||
const artifactsParamsSchema = Type.Object({
|
|
||||||
command: StringEnum(["create", "update", "rewrite", "get", "delete", "logs"], {
|
|
||||||
description: "The operation to perform",
|
|
||||||
}),
|
|
||||||
filename: Type.String({ description: "Filename including extension (e.g., 'index.html', 'script.js')" }),
|
|
||||||
title: Type.Optional(Type.String({ description: "Display title for the tab (defaults to filename)" })),
|
|
||||||
content: Type.Optional(Type.String({ description: "File content" })),
|
|
||||||
old_str: Type.Optional(Type.String({ description: "String to replace (for update command)" })),
|
|
||||||
new_str: Type.Optional(Type.String({ description: "Replacement string (for update command)" })),
|
|
||||||
});
|
|
||||||
export type ArtifactsParams = Static<typeof artifactsParamsSchema>;
|
|
||||||
|
|
||||||
// Minimal helper to render plain text outputs consistently
|
|
||||||
function plainOutput(text: string): TemplateResult {
|
|
||||||
return html`<div class="text-xs text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("artifacts-panel")
|
|
||||||
export class ArtifactsPanel extends LitElement implements ToolRenderer<ArtifactsParams, undefined> {
|
|
||||||
@state() private _artifacts = new Map<string, Artifact>();
|
|
||||||
@state() private _activeFilename: string | null = null;
|
|
||||||
|
|
||||||
// Programmatically managed artifact elements
|
|
||||||
private artifactElements = new Map<string, ArtifactElement>();
|
|
||||||
private contentRef: Ref<HTMLDivElement> = createRef();
|
|
||||||
|
|
||||||
// External provider for attachments (decouples panel from AgentInterface)
|
|
||||||
@property({ attribute: false }) attachmentsProvider?: () => Attachment[];
|
|
||||||
// Callbacks
|
|
||||||
@property({ attribute: false }) onArtifactsChange?: () => void;
|
|
||||||
@property({ attribute: false }) onClose?: () => void;
|
|
||||||
@property({ attribute: false }) onOpen?: () => void;
|
|
||||||
// Collapsed mode: hides panel content but can show a floating reopen pill
|
|
||||||
@property({ type: Boolean }) collapsed = false;
|
|
||||||
// Overlay mode: when true, panel renders full-screen overlay (mobile)
|
|
||||||
@property({ type: Boolean }) overlay = false;
|
|
||||||
|
|
||||||
// Public getter for artifacts
|
|
||||||
get artifacts() {
|
|
||||||
return this._artifacts;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
|
||||||
return this; // light DOM for shared styles
|
|
||||||
}
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.style.display = "block";
|
|
||||||
// Reattach existing artifact elements when panel is re-inserted into the DOM
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const container = this.contentRef.value;
|
|
||||||
if (!container) return;
|
|
||||||
// Ensure we have an active filename
|
|
||||||
if (!this._activeFilename && this._artifacts.size > 0) {
|
|
||||||
this._activeFilename = Array.from(this._artifacts.keys())[0];
|
|
||||||
}
|
|
||||||
this.artifactElements.forEach((element, name) => {
|
|
||||||
if (!element.parentElement) container.appendChild(element);
|
|
||||||
element.style.display = name === this._activeFilename ? "block" : "none";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
// Do not tear down artifact elements; keep them to restore on next mount
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to determine file type from extension
|
|
||||||
private getFileType(filename: string): "html" | "svg" | "markdown" | "text" {
|
|
||||||
const ext = filename.split(".").pop()?.toLowerCase();
|
|
||||||
if (ext === "html") return "html";
|
|
||||||
if (ext === "svg") return "svg";
|
|
||||||
if (ext === "md" || ext === "markdown") return "markdown";
|
|
||||||
return "text";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to determine language for syntax highlighting
|
|
||||||
private getLanguageFromFilename(filename?: string): string {
|
|
||||||
if (!filename) return "text";
|
|
||||||
const ext = filename.split(".").pop()?.toLowerCase();
|
|
||||||
const languageMap: Record<string, string> = {
|
|
||||||
js: "javascript",
|
|
||||||
jsx: "javascript",
|
|
||||||
ts: "typescript",
|
|
||||||
tsx: "typescript",
|
|
||||||
html: "html",
|
|
||||||
css: "css",
|
|
||||||
scss: "scss",
|
|
||||||
json: "json",
|
|
||||||
py: "python",
|
|
||||||
md: "markdown",
|
|
||||||
svg: "xml",
|
|
||||||
xml: "xml",
|
|
||||||
yaml: "yaml",
|
|
||||||
yml: "yaml",
|
|
||||||
sh: "bash",
|
|
||||||
bash: "bash",
|
|
||||||
sql: "sql",
|
|
||||||
java: "java",
|
|
||||||
c: "c",
|
|
||||||
cpp: "cpp",
|
|
||||||
cs: "csharp",
|
|
||||||
go: "go",
|
|
||||||
rs: "rust",
|
|
||||||
php: "php",
|
|
||||||
rb: "ruby",
|
|
||||||
swift: "swift",
|
|
||||||
kt: "kotlin",
|
|
||||||
r: "r",
|
|
||||||
};
|
|
||||||
return languageMap[ext || ""] || "text";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create artifact element
|
|
||||||
private getOrCreateArtifactElement(filename: string, content: string, title: string): ArtifactElement {
|
|
||||||
let element = this.artifactElements.get(filename);
|
|
||||||
|
|
||||||
if (!element) {
|
|
||||||
const type = this.getFileType(filename);
|
|
||||||
if (type === "html") {
|
|
||||||
element = new HtmlArtifact();
|
|
||||||
(element as HtmlArtifact).attachments = this.attachmentsProvider?.() || [];
|
|
||||||
} else if (type === "svg") {
|
|
||||||
element = new SvgArtifact();
|
|
||||||
} else if (type === "markdown") {
|
|
||||||
element = new MarkdownArtifact();
|
|
||||||
} else {
|
|
||||||
element = new TextArtifact();
|
|
||||||
}
|
|
||||||
element.filename = filename;
|
|
||||||
element.displayTitle = title;
|
|
||||||
element.content = content;
|
|
||||||
element.style.display = "none";
|
|
||||||
element.style.height = "100%";
|
|
||||||
|
|
||||||
// Store element
|
|
||||||
this.artifactElements.set(filename, element);
|
|
||||||
|
|
||||||
// Add to DOM - try immediately if container exists, otherwise schedule
|
|
||||||
const newElement = element;
|
|
||||||
if (this.contentRef.value) {
|
|
||||||
this.contentRef.value.appendChild(newElement);
|
|
||||||
} else {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this.contentRef.value && !newElement.parentElement) {
|
|
||||||
this.contentRef.value.appendChild(newElement);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Just update content
|
|
||||||
element.content = content;
|
|
||||||
element.displayTitle = title;
|
|
||||||
if (element instanceof HtmlArtifact) {
|
|
||||||
element.attachments = this.attachmentsProvider?.() || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide artifact elements
|
|
||||||
private showArtifact(filename: string) {
|
|
||||||
// Ensure the active element is in the DOM
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.artifactElements.forEach((element, name) => {
|
|
||||||
if (this.contentRef.value && !element.parentElement) {
|
|
||||||
this.contentRef.value.appendChild(element);
|
|
||||||
}
|
|
||||||
element.style.display = name === filename ? "block" : "none";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this._activeFilename = filename;
|
|
||||||
this.requestUpdate(); // Only for tab bar update
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open panel and focus an artifact tab by filename
|
|
||||||
private openArtifact(filename: string) {
|
|
||||||
if (this._artifacts.has(filename)) {
|
|
||||||
this.showArtifact(filename);
|
|
||||||
// Ask host to open panel (AgentInterface demo listens to onOpen)
|
|
||||||
this.onOpen?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the AgentTool (no details payload; return only output strings)
|
|
||||||
public get tool(): AgentTool<typeof artifactsParamsSchema, undefined> {
|
|
||||||
return {
|
|
||||||
label: "Artifacts",
|
|
||||||
name: "artifacts",
|
|
||||||
description: `Creates and manages file artifacts. Each artifact is a file with a filename and content.
|
|
||||||
|
|
||||||
IMPORTANT: Always prefer updating existing files over creating new ones. Check available files first.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
1. create: Create a new file
|
|
||||||
- filename: Name with extension (required, e.g., 'index.html', 'script.js', 'README.md')
|
|
||||||
- title: Display name for the tab (optional, defaults to filename)
|
|
||||||
- content: File content (required)
|
|
||||||
|
|
||||||
2. update: Update part of an existing file
|
|
||||||
- filename: File to update (required)
|
|
||||||
- old_str: Exact string to replace (required)
|
|
||||||
- new_str: Replacement string (required)
|
|
||||||
|
|
||||||
3. rewrite: Completely replace a file's content
|
|
||||||
- filename: File to rewrite (required)
|
|
||||||
- content: New content (required)
|
|
||||||
- title: Optionally update display title
|
|
||||||
|
|
||||||
4. get: Retrieve the full content of a file
|
|
||||||
- filename: File to retrieve (required)
|
|
||||||
- Returns the complete file content
|
|
||||||
|
|
||||||
5. delete: Delete a file
|
|
||||||
- filename: File to delete (required)
|
|
||||||
|
|
||||||
6. logs: Get console logs and errors (HTML files only)
|
|
||||||
- filename: HTML file to get logs for (required)
|
|
||||||
- Returns all console output and runtime errors
|
|
||||||
|
|
||||||
For text/html artifacts with attachments:
|
|
||||||
- HTML artifacts automatically have access to user attachments via JavaScript
|
|
||||||
- Available global functions in HTML artifacts:
|
|
||||||
* listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
|
|
||||||
* readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
|
|
||||||
* readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
|
|
||||||
- Example HTML artifact that processes a CSV attachment:
|
|
||||||
<script>
|
|
||||||
// List available files
|
|
||||||
const files = listFiles();
|
|
||||||
console.log('Available files:', files);
|
|
||||||
|
|
||||||
// Find CSV file
|
|
||||||
const csvFile = files.find(f => f.mimeType === 'text/csv');
|
|
||||||
if (csvFile) {
|
|
||||||
const csvContent = readTextFile(csvFile.id);
|
|
||||||
// Process CSV data...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display image
|
|
||||||
const imageFile = files.find(f => f.mimeType.startsWith('image/'));
|
|
||||||
if (imageFile) {
|
|
||||||
const bytes = readBinaryFile(imageFile.id);
|
|
||||||
const blob = new Blob([bytes], {type: imageFile.mimeType});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
document.body.innerHTML = '<img src="' + url + '">';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
For text/html artifacts:
|
|
||||||
- Must be a single self-contained file
|
|
||||||
- External scripts: Use CDNs like https://esm.sh, https://unpkg.com, or https://cdnjs.cloudflare.com
|
|
||||||
- Preferred: Use https://esm.sh for npm packages (e.g., https://esm.sh/three for Three.js)
|
|
||||||
- For ES modules, use: <script type="module">import * as THREE from 'https://esm.sh/three';</script>
|
|
||||||
- For Three.js specifically: import from 'https://esm.sh/three' or 'https://esm.sh/three@0.160.0'
|
|
||||||
- For addons: import from 'https://esm.sh/three/examples/jsm/controls/OrbitControls.js'
|
|
||||||
- No localStorage/sessionStorage - use in-memory variables only
|
|
||||||
- CSS should be included inline
|
|
||||||
- CRITICAL REMINDER FOR HTML ARTIFACTS:
|
|
||||||
- ALWAYS set a background color inline in <style> or directly on body element
|
|
||||||
- Failure to set a background color is a COMPLIANCE ERROR
|
|
||||||
- Background color MUST be explicitly defined to ensure visibility and proper rendering
|
|
||||||
- Can embed base64 images directly in img tags
|
|
||||||
- Ensure the layout is responsive as the iframe might be resized
|
|
||||||
- Note: Network errors (404s) for external scripts may not be captured in logs due to browser security
|
|
||||||
|
|
||||||
For application/vnd.ant.code artifacts:
|
|
||||||
- Include the language parameter for syntax highlighting
|
|
||||||
- Supports all major programming languages
|
|
||||||
|
|
||||||
For text/markdown:
|
|
||||||
- Standard markdown syntax
|
|
||||||
- Will be rendered with full formatting
|
|
||||||
- Can include base64 images using markdown syntax
|
|
||||||
|
|
||||||
For image/svg+xml:
|
|
||||||
- Complete SVG markup
|
|
||||||
- Will be rendered inline
|
|
||||||
- Can embed raster images as base64 in SVG
|
|
||||||
|
|
||||||
CRITICAL REMINDER FOR ALL ARTIFACTS:
|
|
||||||
- Prefer to update existing files rather than creating new ones
|
|
||||||
- Keep filenames consistent and descriptive
|
|
||||||
- Use appropriate file extensions
|
|
||||||
- Ensure HTML artifacts have a defined background color
|
|
||||||
`,
|
|
||||||
parameters: artifactsParamsSchema,
|
|
||||||
// Execute mutates our local store and returns a plain output
|
|
||||||
execute: async (_toolCallId: string, args: Static<typeof artifactsParamsSchema>, _signal?: AbortSignal) => {
|
|
||||||
const output = await this.executeCommand(args);
|
|
||||||
return { output, details: undefined };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolRenderer implementation
|
|
||||||
renderParams(params: ArtifactsParams, isStreaming?: boolean): TemplateResult {
|
|
||||||
if (isStreaming && !params.command) {
|
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Processing artifact...")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let commandLabel = i18n("Processing");
|
|
||||||
if (params.command) {
|
|
||||||
switch (params.command) {
|
|
||||||
case "create":
|
|
||||||
commandLabel = i18n("Create");
|
|
||||||
break;
|
|
||||||
case "update":
|
|
||||||
commandLabel = i18n("Update");
|
|
||||||
break;
|
|
||||||
case "rewrite":
|
|
||||||
commandLabel = i18n("Rewrite");
|
|
||||||
break;
|
|
||||||
case "get":
|
|
||||||
commandLabel = i18n("Get");
|
|
||||||
break;
|
|
||||||
case "delete":
|
|
||||||
commandLabel = i18n("Delete");
|
|
||||||
break;
|
|
||||||
case "logs":
|
|
||||||
commandLabel = i18n("Get logs");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
commandLabel = params.command.charAt(0).toUpperCase() + params.command.slice(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const filename = params.filename || "";
|
|
||||||
|
|
||||||
switch (params.command) {
|
|
||||||
case "create":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">${i18n("Create")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
params.content
|
|
||||||
? html`<code-block
|
|
||||||
.code=${params.content}
|
|
||||||
language=${this.getLanguageFromFilename(params.filename)}
|
|
||||||
class="mt-2"
|
|
||||||
></code-block>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "update":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">${i18n("Update")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
params.old_str !== undefined && params.new_str !== undefined
|
|
||||||
? Diff({ oldText: params.old_str, newText: params.new_str, className: "mt-2" })
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "rewrite":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">${i18n("Rewrite")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
params.content
|
|
||||||
? html`<code-block
|
|
||||||
.code=${params.content}
|
|
||||||
language=${this.getLanguageFromFilename(params.filename)}
|
|
||||||
class="mt-2"
|
|
||||||
></code-block>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "get":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${i18n("Get")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "delete":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${i18n("Delete")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "logs":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${i18n("Get logs")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
default:
|
|
||||||
// Fallback for any command not yet handled during streaming
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${commandLabel}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(params: ArtifactsParams, result: ToolResultMessage<undefined>): TemplateResult {
|
|
||||||
// Make result clickable to focus the referenced file when applicable
|
|
||||||
const content = result.output || i18n("(no output)");
|
|
||||||
return html`
|
|
||||||
<div class="cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1" @click=${() => this.openArtifact(params.filename)}>
|
|
||||||
${plainOutput(content)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-apply artifacts by scanning a message list (optional utility)
|
|
||||||
public async reconstructFromMessages(messages: Array<Message | { role: "aborted" }>): Promise<void> {
|
|
||||||
const toolCalls = new Map<string, ToolCall>();
|
|
||||||
const artifactToolName = "artifacts";
|
|
||||||
|
|
||||||
// 1) Collect tool calls from assistant messages
|
|
||||||
for (const message of messages) {
|
|
||||||
if (message.role === "assistant") {
|
|
||||||
for (const block of message.content) {
|
|
||||||
if (block.type === "toolCall" && block.name === artifactToolName) {
|
|
||||||
toolCalls.set(block.id, block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Build an ordered list of successful artifact operations
|
|
||||||
const operations: Array<ArtifactsParams> = [];
|
|
||||||
for (const m of messages) {
|
|
||||||
if ((m as any).role === "toolResult" && (m as any).toolName === artifactToolName && !(m as any).isError) {
|
|
||||||
const toolCallId = (m as any).toolCallId as string;
|
|
||||||
const call = toolCalls.get(toolCallId);
|
|
||||||
if (!call) continue;
|
|
||||||
const params = call.arguments as ArtifactsParams;
|
|
||||||
if (params.command === "get" || params.command === "logs") continue; // no state change
|
|
||||||
operations.push(params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Compute final state per filename by simulating operations in-memory
|
|
||||||
type FinalArtifact = { title: string; content: string };
|
|
||||||
const finalArtifacts = new Map<string, FinalArtifact>();
|
|
||||||
for (const op of operations) {
|
|
||||||
const filename = op.filename;
|
|
||||||
switch (op.command) {
|
|
||||||
case "create": {
|
|
||||||
if (op.content) {
|
|
||||||
finalArtifacts.set(filename, { title: op.title || filename, content: op.content });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "rewrite": {
|
|
||||||
if (op.content) {
|
|
||||||
// If file didn't exist earlier but rewrite succeeded, treat as fresh content
|
|
||||||
const existing = finalArtifacts.get(filename);
|
|
||||||
finalArtifacts.set(filename, { title: op.title || existing?.title || filename, content: op.content });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "update": {
|
|
||||||
const existing = finalArtifacts.get(filename);
|
|
||||||
if (!existing) break; // skip invalid update (shouldn't happen for successful results)
|
|
||||||
if (op.old_str !== undefined && op.new_str !== undefined) {
|
|
||||||
existing.content = existing.content.replace(op.old_str, op.new_str);
|
|
||||||
finalArtifacts.set(filename, existing);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "delete": {
|
|
||||||
finalArtifacts.delete(filename);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "get":
|
|
||||||
case "logs":
|
|
||||||
// Ignored above, just for completeness
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Reset current UI state before bulk create
|
|
||||||
this._artifacts.clear();
|
|
||||||
this.artifactElements.forEach((el) => {
|
|
||||||
el.remove();
|
|
||||||
});
|
|
||||||
this.artifactElements.clear();
|
|
||||||
this._activeFilename = null;
|
|
||||||
this._artifacts = new Map(this._artifacts);
|
|
||||||
|
|
||||||
// 5) Create artifacts in a single pass without waiting for iframe execution or tab switching
|
|
||||||
for (const [filename, { title, content }] of finalArtifacts.entries()) {
|
|
||||||
const createParams: ArtifactsParams = { command: "create", filename, title, content } as const;
|
|
||||||
try {
|
|
||||||
await this.createArtifact(createParams, { skipWait: true, silent: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore failures during reconstruction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Show first artifact if any exist, and notify listeners once
|
|
||||||
if (!this._activeFilename && this._artifacts.size > 0) {
|
|
||||||
this.showArtifact(Array.from(this._artifacts.keys())[0]);
|
|
||||||
}
|
|
||||||
this.onArtifactsChange?.();
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Core command executor
|
|
||||||
private async executeCommand(
|
|
||||||
params: ArtifactsParams,
|
|
||||||
options: { skipWait?: boolean; silent?: boolean } = {},
|
|
||||||
): Promise<string> {
|
|
||||||
switch (params.command) {
|
|
||||||
case "create":
|
|
||||||
return await this.createArtifact(params, options);
|
|
||||||
case "update":
|
|
||||||
return await this.updateArtifact(params, options);
|
|
||||||
case "rewrite":
|
|
||||||
return await this.rewriteArtifact(params, options);
|
|
||||||
case "get":
|
|
||||||
return this.getArtifact(params);
|
|
||||||
case "delete":
|
|
||||||
return this.deleteArtifact(params);
|
|
||||||
case "logs":
|
|
||||||
return this.getLogs(params);
|
|
||||||
default:
|
|
||||||
// Should never happen with TypeBox validation
|
|
||||||
return `Error: Unknown command ${(params as any).command}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for HTML artifact execution and get logs
|
|
||||||
private async waitForHtmlExecution(filename: string): Promise<string> {
|
|
||||||
const element = this.artifactElements.get(filename);
|
|
||||||
if (!(element instanceof HtmlArtifact)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let resolved = false;
|
|
||||||
|
|
||||||
// Listen for the execution-complete message
|
|
||||||
const messageHandler = (event: MessageEvent) => {
|
|
||||||
if (event.data?.type === "execution-complete" && event.data?.artifactId === filename) {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
window.removeEventListener("message", messageHandler);
|
|
||||||
|
|
||||||
// Get the logs from the element
|
|
||||||
const logs = element.getLogs();
|
|
||||||
if (logs.includes("[error]")) {
|
|
||||||
resolve(`\n\nExecution completed with errors:\n${logs}`);
|
|
||||||
} else if (logs !== `No logs for ${filename}`) {
|
|
||||||
resolve(`\n\nExecution logs:\n${logs}`);
|
|
||||||
} else {
|
|
||||||
resolve("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("message", messageHandler);
|
|
||||||
|
|
||||||
// Fallback timeout in case the message never arrives
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
window.removeEventListener("message", messageHandler);
|
|
||||||
|
|
||||||
// Get whatever logs we have so far
|
|
||||||
const logs = element.getLogs();
|
|
||||||
if (logs.includes("[error]")) {
|
|
||||||
resolve(`\n\nExecution timed out with errors:\n${logs}`);
|
|
||||||
} else if (logs !== `No logs for ${filename}`) {
|
|
||||||
resolve(`\n\nExecution timed out. Partial logs:\n${logs}`);
|
|
||||||
} else {
|
|
||||||
resolve("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createArtifact(
|
|
||||||
params: ArtifactsParams,
|
|
||||||
options: { skipWait?: boolean; silent?: boolean } = {},
|
|
||||||
): Promise<string> {
|
|
||||||
if (!params.filename || !params.content) {
|
|
||||||
return "Error: create command requires filename and content";
|
|
||||||
}
|
|
||||||
if (this._artifacts.has(params.filename)) {
|
|
||||||
return `Error: File ${params.filename} already exists`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = params.title || params.filename;
|
|
||||||
const artifact: Artifact = {
|
|
||||||
filename: params.filename,
|
|
||||||
title: title,
|
|
||||||
content: params.content,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
this._artifacts.set(params.filename, artifact);
|
|
||||||
this._artifacts = new Map(this._artifacts);
|
|
||||||
|
|
||||||
// Create or update element
|
|
||||||
this.getOrCreateArtifactElement(params.filename, params.content, title);
|
|
||||||
if (!options.silent) {
|
|
||||||
this.showArtifact(params.filename);
|
|
||||||
this.onArtifactsChange?.();
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For HTML files, wait for execution
|
|
||||||
let result = `Created file ${params.filename}`;
|
|
||||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
|
||||||
const logs = await this.waitForHtmlExecution(params.filename);
|
|
||||||
result += logs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateArtifact(
|
|
||||||
params: ArtifactsParams,
|
|
||||||
options: { skipWait?: boolean; silent?: boolean } = {},
|
|
||||||
): Promise<string> {
|
|
||||||
const artifact = this._artifacts.get(params.filename);
|
|
||||||
if (!artifact) {
|
|
||||||
const files = Array.from(this._artifacts.keys());
|
|
||||||
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
|
|
||||||
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
|
|
||||||
}
|
|
||||||
if (!params.old_str || params.new_str === undefined) {
|
|
||||||
return "Error: update command requires old_str and new_str";
|
|
||||||
}
|
|
||||||
if (!artifact.content.includes(params.old_str)) {
|
|
||||||
return `Error: String not found in file. Here is the full content:\n\n${artifact.content}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
artifact.content = artifact.content.replace(params.old_str, params.new_str);
|
|
||||||
artifact.updatedAt = new Date();
|
|
||||||
this._artifacts.set(params.filename, artifact);
|
|
||||||
|
|
||||||
// Update element
|
|
||||||
this.getOrCreateArtifactElement(params.filename, artifact.content, artifact.title);
|
|
||||||
if (!options.silent) {
|
|
||||||
this.onArtifactsChange?.();
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the artifact
|
|
||||||
this.showArtifact(params.filename);
|
|
||||||
|
|
||||||
// For HTML files, wait for execution
|
|
||||||
let result = `Updated file ${params.filename}`;
|
|
||||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
|
||||||
const logs = await this.waitForHtmlExecution(params.filename);
|
|
||||||
result += logs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async rewriteArtifact(
|
|
||||||
params: ArtifactsParams,
|
|
||||||
options: { skipWait?: boolean; silent?: boolean } = {},
|
|
||||||
): Promise<string> {
|
|
||||||
const artifact = this._artifacts.get(params.filename);
|
|
||||||
if (!artifact) {
|
|
||||||
const files = Array.from(this._artifacts.keys());
|
|
||||||
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
|
|
||||||
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
|
|
||||||
}
|
|
||||||
if (!params.content) {
|
|
||||||
return "Error: rewrite command requires content";
|
|
||||||
}
|
|
||||||
|
|
||||||
artifact.content = params.content;
|
|
||||||
if (params.title) artifact.title = params.title;
|
|
||||||
artifact.updatedAt = new Date();
|
|
||||||
this._artifacts.set(params.filename, artifact);
|
|
||||||
|
|
||||||
// Update element
|
|
||||||
this.getOrCreateArtifactElement(params.filename, artifact.content, artifact.title);
|
|
||||||
if (!options.silent) {
|
|
||||||
this.onArtifactsChange?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the artifact
|
|
||||||
this.showArtifact(params.filename);
|
|
||||||
|
|
||||||
// For HTML files, wait for execution
|
|
||||||
let result = `Rewrote file ${params.filename}`;
|
|
||||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
|
||||||
const logs = await this.waitForHtmlExecution(params.filename);
|
|
||||||
result += logs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArtifact(params: ArtifactsParams): string {
|
|
||||||
const artifact = this._artifacts.get(params.filename);
|
|
||||||
if (!artifact) {
|
|
||||||
const files = Array.from(this._artifacts.keys());
|
|
||||||
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
|
|
||||||
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
|
|
||||||
}
|
|
||||||
return artifact.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
private deleteArtifact(params: ArtifactsParams): string {
|
|
||||||
const artifact = this._artifacts.get(params.filename);
|
|
||||||
if (!artifact) {
|
|
||||||
const files = Array.from(this._artifacts.keys());
|
|
||||||
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
|
|
||||||
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._artifacts.delete(params.filename);
|
|
||||||
this._artifacts = new Map(this._artifacts);
|
|
||||||
|
|
||||||
// Remove element
|
|
||||||
const element = this.artifactElements.get(params.filename);
|
|
||||||
if (element) {
|
|
||||||
element.remove();
|
|
||||||
this.artifactElements.delete(params.filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show another artifact if this was active
|
|
||||||
if (this._activeFilename === params.filename) {
|
|
||||||
const remaining = Array.from(this._artifacts.keys());
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
this.showArtifact(remaining[0]);
|
|
||||||
} else {
|
|
||||||
this._activeFilename = null;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.onArtifactsChange?.();
|
|
||||||
this.requestUpdate();
|
|
||||||
|
|
||||||
return `Deleted file ${params.filename}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLogs(params: ArtifactsParams): string {
|
|
||||||
const element = this.artifactElements.get(params.filename);
|
|
||||||
if (!element) {
|
|
||||||
const files = Array.from(this._artifacts.keys());
|
|
||||||
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
|
|
||||||
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(element instanceof HtmlArtifact)) {
|
|
||||||
return `Error: File ${params.filename} is not an HTML file. Logs are only available for HTML files.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return element.getLogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
override render(): TemplateResult {
|
|
||||||
const artifacts = Array.from(this._artifacts.values());
|
|
||||||
|
|
||||||
// Panel is hidden when collapsed OR when there are no artifacts
|
|
||||||
const showPanel = artifacts.length > 0 && !this.collapsed;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="${showPanel ? "" : "hidden"} ${
|
|
||||||
this.overlay ? "fixed inset-0 z-40 pointer-events-auto backdrop-blur-sm bg-background/95" : "relative"
|
|
||||||
} h-full flex flex-col bg-background text-card-foreground ${
|
|
||||||
!this.overlay ? "border-l border-border" : ""
|
|
||||||
} overflow-hidden shadow-xl"
|
|
||||||
>
|
|
||||||
<!-- Tab bar (always shown when there are artifacts) -->
|
|
||||||
<div class="flex items-center justify-between border-b border-border bg-background">
|
|
||||||
<div class="flex overflow-x-auto">
|
|
||||||
${artifacts.map((a) => {
|
|
||||||
const isActive = a.filename === this._activeFilename;
|
|
||||||
const activeClass = isActive
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground";
|
|
||||||
return html`
|
|
||||||
<button
|
|
||||||
class="px-3 py-2 whitespace-nowrap border-b-2 ${activeClass}"
|
|
||||||
@click=${() => this.showArtifact(a.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-mono text-xs">${a.filename}</span>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 px-2">
|
|
||||||
${(() => {
|
|
||||||
const active = this._activeFilename ? this.artifactElements.get(this._activeFilename) : undefined;
|
|
||||||
return active ? active.getHeaderButtons() : "";
|
|
||||||
})()}
|
|
||||||
${Button({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "sm",
|
|
||||||
onClick: () => this.onClose?.(),
|
|
||||||
title: i18n("Close artifacts"),
|
|
||||||
children: icon(X, "sm"),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content area where artifact elements are added programmatically -->
|
|
||||||
<div class="flex-1 overflow-hidden" ${ref(this.contentRef)}></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
"artifacts-panel": ArtifactsPanel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export { ArtifactElement } from "./ArtifactElement.js";
|
|
||||||
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js";
|
|
||||||
export { HtmlArtifact } from "./HtmlArtifact.js";
|
|
||||||
export { MarkdownArtifact } from "./MarkdownArtifact.js";
|
|
||||||
export { SvgArtifact } from "./SvgArtifact.js";
|
|
||||||
export { TextArtifact } from "./TextArtifact.js";
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { type Attachment, registerToolRenderer, type ToolRenderer } from "@mariozechner/pi-web-ui";
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import "../components/ConsoleBlock.js"; // Ensure console-block is registered
|
import "@mariozechner/pi-web-ui"; // Ensure all components are registered
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
import { registerToolRenderer } from "./renderer-registry.js";
|
|
||||||
import type { ToolRenderer } from "./types.js";
|
|
||||||
|
|
||||||
// Cross-browser API compatibility
|
// Cross-browser API compatibility
|
||||||
// @ts-expect-error - browser global exists in Firefox, chrome in Chrome
|
// @ts-expect-error - browser global exists in Firefox, chrome in Chrome
|
||||||
|
|
@ -211,13 +209,111 @@ This ensures reliable execution.`,
|
||||||
const canUseEval = cspCheckResults[0]?.result?.canEval ?? false;
|
const canUseEval = cspCheckResults[0]?.result?.canEval ?? false;
|
||||||
const canUseScriptTag = cspCheckResults[0]?.result?.canUseScriptTag ?? false;
|
const canUseScriptTag = cspCheckResults[0]?.result?.canUseScriptTag ?? false;
|
||||||
|
|
||||||
// If neither method works, return error immediately
|
// If neither method works, fallback to JailJS via content script
|
||||||
if (!canUseEval && !canUseScriptTag) {
|
if (!canUseEval && !canUseScriptTag) {
|
||||||
|
console.log("[pi-ai] CSP blocks eval and script injection, falling back to JailJS");
|
||||||
|
|
||||||
|
// Send execution request to content script
|
||||||
|
const response = await new Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: unknown;
|
||||||
|
console?: Array<{ type: string; args: unknown[] }>;
|
||||||
|
files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
|
||||||
|
error?: string;
|
||||||
|
stack?: string;
|
||||||
|
}>((resolve) => {
|
||||||
|
browser.tabs.sendMessage(
|
||||||
|
tab.id,
|
||||||
|
{
|
||||||
|
type: "EXECUTE_CODE",
|
||||||
|
mode: "jailjs",
|
||||||
|
code: args.code,
|
||||||
|
},
|
||||||
|
resolve,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
output: `JailJS Execution Error: ${response.error}\n\nStack:\n${response.stack || "No stack trace"}`,
|
||||||
|
isError: true,
|
||||||
|
details: { files: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format console output
|
||||||
|
const formatArg = (arg: unknown): string => {
|
||||||
|
if (arg === null) return "null";
|
||||||
|
if (arg === undefined) return "undefined";
|
||||||
|
if (typeof arg === "string") return arg;
|
||||||
|
if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build output with console logs
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
// Add console output
|
||||||
|
if (response.console && response.console.length > 0) {
|
||||||
|
for (const entry of response.console) {
|
||||||
|
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
|
||||||
|
const formattedArgs = entry.args.map(formatArg).join(" ");
|
||||||
|
const line = prefix ? `${prefix} ${formattedArgs}` : formattedArgs;
|
||||||
|
output += line + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file notifications
|
||||||
|
if (response.files && response.files.length > 0) {
|
||||||
|
output += `\n[Files returned: ${response.files.length}]\n`;
|
||||||
|
for (const file of response.files) {
|
||||||
|
output += ` - ${file.fileName} (${file.mimeType})\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert files to base64 for transport
|
||||||
|
const files = (response.files || []).map(
|
||||||
|
(f: { fileName: string; content: string | Uint8Array; mimeType: string }) => {
|
||||||
|
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 {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { base64, size } = toBase64(f.content);
|
||||||
|
return {
|
||||||
|
fileName: f.fileName || "file",
|
||||||
|
mimeType: f.mimeType || "application/octet-stream",
|
||||||
|
size,
|
||||||
|
contentBase64: base64,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
output:
|
output:
|
||||||
"Cannot execute JavaScript on this page. The page's Content Security Policy blocks both eval() and inline script injection. This is common on sites with strict CSP.",
|
output.trim() ||
|
||||||
isError: true,
|
"Code executed successfully (no output)\n\n⚠️ Note: CSP blocked direct execution. Code ran via JailJS interpreter.",
|
||||||
details: { files: [] },
|
isError: false,
|
||||||
|
details: { files },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { TemplateResult } from "@mariozechner/mini-lit";
|
import {
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
BashRenderer,
|
||||||
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
|
CalculateRenderer,
|
||||||
import { BashRenderer } from "./renderers/BashRenderer.js";
|
createJavaScriptReplTool,
|
||||||
import { CalculateRenderer } from "./renderers/CalculateRenderer.js";
|
GetCurrentTimeRenderer,
|
||||||
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
|
javascriptReplTool,
|
||||||
import { GetCurrentTimeRenderer } from "./renderers/GetCurrentTimeRenderer.js";
|
registerToolRenderer,
|
||||||
import "./javascript-repl.js"; // Import for side effects (registers renderer)
|
} from "@mariozechner/pi-web-ui";
|
||||||
import "./browser-javascript.js"; // Import for side effects (registers renderer)
|
import "./browser-javascript.js"; // Import for side effects (registers renderer)
|
||||||
|
|
||||||
// Register all built-in tool renderers
|
// Register all built-in tool renderers
|
||||||
|
|
@ -13,30 +13,6 @@ registerToolRenderer("calculate", new CalculateRenderer());
|
||||||
registerToolRenderer("get_current_time", new GetCurrentTimeRenderer());
|
registerToolRenderer("get_current_time", new GetCurrentTimeRenderer());
|
||||||
registerToolRenderer("bash", new BashRenderer());
|
registerToolRenderer("bash", new BashRenderer());
|
||||||
|
|
||||||
const defaultRenderer = new DefaultRenderer();
|
// Re-export for convenience
|
||||||
|
export { createJavaScriptReplTool, javascriptReplTool };
|
||||||
/**
|
|
||||||
* Render tool call parameters
|
|
||||||
*/
|
|
||||||
export function renderToolParams(toolName: string, params: any, isStreaming?: boolean): TemplateResult {
|
|
||||||
const renderer = getToolRenderer(toolName);
|
|
||||||
if (renderer) {
|
|
||||||
return renderer.renderParams(params, isStreaming);
|
|
||||||
}
|
|
||||||
return defaultRenderer.renderParams(params, isStreaming);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render tool result
|
|
||||||
*/
|
|
||||||
export function renderToolResult(toolName: string, params: any, result: ToolResultMessage): TemplateResult {
|
|
||||||
const renderer = getToolRenderer(toolName);
|
|
||||||
if (renderer) {
|
|
||||||
return renderer.renderResult(params, result);
|
|
||||||
}
|
|
||||||
return defaultRenderer.renderResult(params, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { registerToolRenderer, getToolRenderer };
|
|
||||||
export { browserJavaScriptTool } from "./browser-javascript.js";
|
export { browserJavaScriptTool } from "./browser-javascript.js";
|
||||||
export { createJavaScriptReplTool, javascriptReplTool } from "./javascript-repl.js";
|
|
||||||
|
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
import { html, i18n, type TemplateResult } from "@mariozechner/mini-lit";
|
|
||||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
|
||||||
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
|
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
|
||||||
|
|
||||||
import { registerToolRenderer } from "./renderer-registry.js";
|
|
||||||
import type { ToolRenderer } from "./types.js";
|
|
||||||
import "../components/ConsoleBlock.js"; // Ensure console-block is registered
|
|
||||||
|
|
||||||
// Execute JavaScript code with attachments using SandboxedIframe
|
|
||||||
export async function executeJavaScript(
|
|
||||||
code: string,
|
|
||||||
attachments: any[] = [],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): 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();
|
|
||||||
sandbox.style.display = "none";
|
|
||||||
document.body.appendChild(sandbox);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sandboxId = `repl-${Date.now()}`;
|
|
||||||
const result: SandboxResult = await sandbox.execute(sandboxId, code, attachments, signal);
|
|
||||||
|
|
||||||
// Remove the sandbox iframe after execution
|
|
||||||
sandbox.remove();
|
|
||||||
|
|
||||||
// Return plain text output
|
|
||||||
if (!result.success) {
|
|
||||||
// Return error as plain text
|
|
||||||
return {
|
|
||||||
output: `Error: ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const prefix = entry.type === "error" ? "[ERROR]" : "";
|
|
||||||
output += (prefix ? `${prefix} ` : "") + entry.text + "\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: any) {
|
|
||||||
// Clean up on error
|
|
||||||
sandbox.remove();
|
|
||||||
throw new Error(error.message || "Execution failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JavaScriptReplToolResult = {
|
|
||||||
files?:
|
|
||||||
| {
|
|
||||||
fileName: string;
|
|
||||||
contentBase64: any;
|
|
||||||
mimeType: string;
|
|
||||||
}[]
|
|
||||||
| undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const javascriptReplSchema = Type.Object({
|
|
||||||
code: Type.String({ description: "JavaScript code to execute" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
|
|
||||||
attachmentsProvider?: () => any[];
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
label: "JavaScript REPL",
|
|
||||||
name: "javascript_repl",
|
|
||||||
attachmentsProvider: () => [], // default to empty array
|
|
||||||
description: `Execute JavaScript code in a sandboxed browser environment with full modern browser capabilities.
|
|
||||||
|
|
||||||
Environment: Modern browser with ALL Web APIs available:
|
|
||||||
- ES2023+ JavaScript (async/await, optional chaining, nullish coalescing, etc.)
|
|
||||||
- DOM APIs (document, window, Canvas, WebGL, etc.)
|
|
||||||
- Fetch API for HTTP requests
|
|
||||||
|
|
||||||
Loading external libraries via dynamic imports (use esm.run):
|
|
||||||
- XLSX (Excel files): const XLSX = await import('https://esm.run/xlsx');
|
|
||||||
- Papa Parse (CSV): const Papa = (await import('https://esm.run/papaparse')).default;
|
|
||||||
- Lodash: const _ = await import('https://esm.run/lodash-es');
|
|
||||||
- D3.js: const d3 = await import('https://esm.run/d3');
|
|
||||||
- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default;
|
|
||||||
- Three.js: const THREE = await import('https://esm.run/three');
|
|
||||||
- Any npm package: await import('https://esm.run/package-name')
|
|
||||||
|
|
||||||
IMPORTANT for graphics/canvas:
|
|
||||||
- Use fixed dimensions like 400x400 or 800x600, NOT window.innerWidth/Height
|
|
||||||
- For Three.js: renderer.setSize(400, 400) and camera aspect ratio of 1
|
|
||||||
- For Chart.js: Set options: { responsive: false, animation: false } to ensure immediate rendering
|
|
||||||
- Web Storage (localStorage, sessionStorage, IndexedDB)
|
|
||||||
- Web Workers, WebAssembly, WebSockets
|
|
||||||
- Media APIs (Audio, Video, WebRTC)
|
|
||||||
- File APIs (Blob, FileReader, etc.)
|
|
||||||
- Crypto API for cryptography
|
|
||||||
- And much more - anything a modern browser supports!
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- console.log() - All output is captured as text
|
|
||||||
- await returnFile(filename, content, mimeType?) - Create downloadable files (async function!)
|
|
||||||
* Always use await with returnFile
|
|
||||||
* REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
|
|
||||||
If omitted, the REPL throws an Error with stack trace pointing to the offending line.
|
|
||||||
* Strings without a MIME default to text/plain.
|
|
||||||
* Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
|
|
||||||
* Canvas images: Use toBlob() with await Promise wrapper
|
|
||||||
* Examples:
|
|
||||||
- await returnFile('data.txt', 'Hello World', 'text/plain')
|
|
||||||
- await returnFile('data.json', {key: 'value'}, 'application/json')
|
|
||||||
- await returnFile('data.csv', 'name,age\\nJohn,30', 'text/csv')
|
|
||||||
- Chart.js example:
|
|
||||||
const Chart = (await import('https://esm.run/chart.js/auto')).default;
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = 400; canvas.height = 300;
|
|
||||||
document.body.appendChild(canvas);
|
|
||||||
new Chart(canvas, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: ['Jan', 'Feb', 'Mar', 'Apr'],
|
|
||||||
datasets: [{ label: 'Sales', data: [10, 20, 15, 25], borderColor: 'blue' }]
|
|
||||||
},
|
|
||||||
options: { responsive: false, animation: false }
|
|
||||||
});
|
|
||||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
|
||||||
await returnFile('chart.png', blob, 'image/png');
|
|
||||||
|
|
||||||
Global variables:
|
|
||||||
- attachments[] - Array of attachment objects from user messages
|
|
||||||
* Properties:
|
|
||||||
- id: string (unique identifier)
|
|
||||||
- fileName: string (e.g., "data.xlsx")
|
|
||||||
- mimeType: string (e.g., "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
||||||
- size: number (bytes)
|
|
||||||
* Helper functions:
|
|
||||||
- listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
|
|
||||||
- readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
|
|
||||||
- readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
|
|
||||||
* Examples:
|
|
||||||
- const files = listFiles();
|
|
||||||
- const csvContent = readTextFile(files[0].id); // Read CSV as text
|
|
||||||
- const xlsxBytes = readBinaryFile(files[0].id); // Read Excel as binary
|
|
||||||
- All standard browser globals (window, document, fetch, etc.)`,
|
|
||||||
parameters: javascriptReplSchema,
|
|
||||||
execute: async function (_toolCallId: string, args: Static<typeof javascriptReplSchema>, signal?: AbortSignal) {
|
|
||||||
const attachments = this.attachmentsProvider?.() || [];
|
|
||||||
const result = await executeJavaScript(args.code, attachments, signal);
|
|
||||||
// Convert files to JSON-serializable with base64 payloads
|
|
||||||
const files = (result.files || []).map((f) => {
|
|
||||||
const toBase64 = (input: any): { 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 as any).content);
|
|
||||||
return {
|
|
||||||
fileName: (f as any).fileName || "file",
|
|
||||||
mimeType: (f as any).mimeType || "application/octet-stream",
|
|
||||||
size,
|
|
||||||
contentBase64: base64,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { output: result.output, details: { files } };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export a default instance for backward compatibility
|
|
||||||
export const javascriptReplTool = createJavaScriptReplTool();
|
|
||||||
|
|
||||||
// JavaScript REPL renderer with streaming support
|
|
||||||
|
|
||||||
interface JavaScriptReplParams {
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JavaScriptReplResult {
|
|
||||||
output?: string;
|
|
||||||
files?: Array<{
|
|
||||||
fileName: string;
|
|
||||||
mimeType: string;
|
|
||||||
size: number;
|
|
||||||
contentBase64: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const javascriptReplRenderer: ToolRenderer<JavaScriptReplParams, JavaScriptReplResult> = {
|
|
||||||
renderParams(params: JavaScriptReplParams, isStreaming?: boolean): TemplateResult {
|
|
||||||
if (isStreaming && (!params.code || params.code.length === 0)) {
|
|
||||||
return html`<div class="text-sm text-muted-foreground">${"Writing JavaScript code..."}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="text-sm text-muted-foreground mb-2">${i18n("Executing JavaScript")}</div>
|
|
||||||
<code-block .code=${params.code || ""} language="javascript"></code-block>
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(_params: JavaScriptReplParams, result: ToolResultMessage<JavaScriptReplResult>): TemplateResult {
|
|
||||||
// Console output is in the main output field, files are in details
|
|
||||||
const output = result.output || "";
|
|
||||||
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 html`
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
${output ? html`<console-block .content=${output}></console-block>` : ""}
|
|
||||||
${
|
|
||||||
attachments.length
|
|
||||||
? html`<div class="flex flex-wrap gap-2">
|
|
||||||
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
|
|
||||||
</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-register the renderer
|
|
||||||
registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import type { ToolRenderer } from "../types.js";
|
|
||||||
|
|
||||||
interface BashParams {
|
|
||||||
command: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bash tool has undefined details (only uses output)
|
|
||||||
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
|
|
||||||
renderParams(params: BashParams, isStreaming?: boolean): TemplateResult {
|
|
||||||
if (isStreaming && (!params.command || params.command.length === 0)) {
|
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing command...")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<span>${i18n("Running command:")}</span>
|
|
||||||
<code class="ml-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.command}</code>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: BashParams, result: ToolResultMessage<undefined>): TemplateResult {
|
|
||||||
const output = result.output || "";
|
|
||||||
const isError = result.isError === true;
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return html`
|
|
||||||
<div class="text-sm">
|
|
||||||
<div class="text-destructive font-medium mb-1">${i18n("Command failed:")}</div>
|
|
||||||
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display the command output
|
|
||||||
return html`
|
|
||||||
<div class="text-sm">
|
|
||||||
<pre class="text-xs font-mono text-foreground bg-muted/50 p-2 rounded overflow-x-auto">${output}</pre>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import type { ToolRenderer } from "../types.js";
|
|
||||||
|
|
||||||
interface CalculateParams {
|
|
||||||
expression: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate tool has undefined details (only uses output)
|
|
||||||
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
|
|
||||||
renderParams(params: CalculateParams, isStreaming?: boolean): TemplateResult {
|
|
||||||
if (isStreaming && !params.expression) {
|
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing expression...")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<span>${i18n("Calculating")}</span>
|
|
||||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.expression}</code>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: CalculateParams, result: ToolResultMessage<undefined>): TemplateResult {
|
|
||||||
// Parse the output to make it look nicer
|
|
||||||
const output = result.output || "";
|
|
||||||
const isError = result.isError === true;
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to split on = to show expression and result separately
|
|
||||||
const parts = output.split(" = ");
|
|
||||||
if (parts.length === 2) {
|
|
||||||
return html`
|
|
||||||
<div class="text-sm font-mono">
|
|
||||||
<span class="text-muted-foreground">${parts[0]}</span>
|
|
||||||
<span class="text-muted-foreground mx-1">=</span>
|
|
||||||
<span class="text-foreground font-semibold">${parts[1]}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to showing the whole output
|
|
||||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import type { ToolRenderer } from "../types.js";
|
|
||||||
|
|
||||||
export class DefaultRenderer implements ToolRenderer {
|
|
||||||
renderParams(params: any, isStreaming?: boolean): TemplateResult {
|
|
||||||
let text: string;
|
|
||||||
let isJson = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
text = JSON.stringify(JSON.parse(params), null, 2);
|
|
||||||
isJson = true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
text = JSON.stringify(params, null, 2);
|
|
||||||
isJson = true;
|
|
||||||
} catch {
|
|
||||||
text = String(params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStreaming && (!text || text === "{}" || text === "null")) {
|
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`<console-block .content=${text}></console-block>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: any, result: ToolResultMessage): TemplateResult {
|
|
||||||
// Just show the output field - that's what was sent to the LLM
|
|
||||||
const text = result.output || i18n("(no output)");
|
|
||||||
|
|
||||||
return html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import { i18n } from "../../utils/i18n.js";
|
|
||||||
import type { ToolRenderer } from "../types.js";
|
|
||||||
|
|
||||||
interface GetCurrentTimeParams {
|
|
||||||
timezone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentTime tool has undefined details (only uses output)
|
|
||||||
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
|
|
||||||
renderParams(params: GetCurrentTimeParams, isStreaming?: boolean): TemplateResult {
|
|
||||||
if (params.timezone) {
|
|
||||||
return html`
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<span>${i18n("Getting current time in")}</span>
|
|
||||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.timezone}</code>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<span>${i18n("Getting current date and time")}${isStreaming ? "..." : ""}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: GetCurrentTimeParams, result: ToolResultMessage<undefined>): TemplateResult {
|
|
||||||
const output = result.output || "";
|
|
||||||
const isError = result.isError === true;
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display the date/time result
|
|
||||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
|
||||||
import type { TemplateResult } from "lit";
|
|
||||||
|
|
||||||
export interface ToolRenderer<TParams = any, TDetails = any> {
|
|
||||||
renderParams(params: TParams, isStreaming?: boolean): TemplateResult;
|
|
||||||
renderResult(params: TParams, result: ToolResultMessage<TDetails>): TemplateResult;
|
|
||||||
}
|
|
||||||
|
|
@ -1,472 +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,22 +0,0 @@
|
||||||
import { PromptDialog } from "@mariozechner/mini-lit";
|
|
||||||
import { i18n } from "./i18n.js";
|
|
||||||
|
|
||||||
export async function getAuthToken(): Promise<string | undefined> {
|
|
||||||
let authToken: string | undefined = localStorage.getItem(`auth-token`) || "";
|
|
||||||
if (authToken) return authToken;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
authToken = (
|
|
||||||
await PromptDialog.ask(i18n("Enter Auth Token"), i18n("Please enter your auth token."), "", true)
|
|
||||||
)?.trim();
|
|
||||||
if (authToken) {
|
|
||||||
localStorage.setItem(`auth-token`, authToken);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return authToken?.trim() || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearAuthToken() {
|
|
||||||
localStorage.removeItem(`auth-token`);
|
|
||||||
}
|
|
||||||
|
|
@ -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,308 +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;
|
|
||||||
"No content available": string;
|
|
||||||
"Failed to display text content": string;
|
|
||||||
"API keys are required to use AI models. Get your keys from the provider's website.": string;
|
|
||||||
console: string;
|
|
||||||
"Copy output": string;
|
|
||||||
"Copied!": string;
|
|
||||||
"Error:": string;
|
|
||||||
"Request aborted": string;
|
|
||||||
Call: string;
|
|
||||||
Result: string;
|
|
||||||
"(no result)": string;
|
|
||||||
"Waiting for tool result…": string;
|
|
||||||
"Call was aborted; no result.": string;
|
|
||||||
"No session available": string;
|
|
||||||
"No session set": string;
|
|
||||||
"Preparing tool parameters...": string;
|
|
||||||
"(no output)": string;
|
|
||||||
"Writing expression...": string;
|
|
||||||
Calculating: string;
|
|
||||||
"Getting current time in": string;
|
|
||||||
"Getting current date and time": string;
|
|
||||||
"Writing command...": string;
|
|
||||||
"Running command:": string;
|
|
||||||
"Command failed:": string;
|
|
||||||
"Enter Auth Token": string;
|
|
||||||
"Please enter your auth token.": string;
|
|
||||||
"Auth token is required for proxy transport": string;
|
|
||||||
// JavaScript REPL strings
|
|
||||||
"Execution aborted": string;
|
|
||||||
"Code parameter is required": string;
|
|
||||||
"Unknown error": string;
|
|
||||||
"Code executed successfully (no output)": string;
|
|
||||||
"Execution failed": string;
|
|
||||||
"JavaScript REPL": string;
|
|
||||||
"JavaScript code to execute": string;
|
|
||||||
"Writing JavaScript code...": string;
|
|
||||||
"Executing JavaScript": string;
|
|
||||||
// Artifacts strings
|
|
||||||
"Processing artifact...": string;
|
|
||||||
Processing: string;
|
|
||||||
Create: string;
|
|
||||||
Rewrite: string;
|
|
||||||
Get: string;
|
|
||||||
Delete: string;
|
|
||||||
"Get logs": string;
|
|
||||||
"Show artifacts": string;
|
|
||||||
"Close artifacts": string;
|
|
||||||
Artifacts: string;
|
|
||||||
"Copy HTML": string;
|
|
||||||
"Download HTML": string;
|
|
||||||
"Copy SVG": string;
|
|
||||||
"Download SVG": string;
|
|
||||||
"Copy Markdown": string;
|
|
||||||
"Download Markdown": string;
|
|
||||||
Download: string;
|
|
||||||
"No logs for {filename}": string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations = {
|
|
||||||
en: {
|
|
||||||
...defaultEnglish,
|
|
||||||
Free: "Free",
|
|
||||||
"Input Required": "Input Required",
|
|
||||||
Cancel: "Cancel",
|
|
||||||
Confirm: "Confirm",
|
|
||||||
"Select Model": "Select Model",
|
|
||||||
"Search models...": "Search models...",
|
|
||||||
Format: "Format",
|
|
||||||
Thinking: "Thinking",
|
|
||||||
Vision: "Vision",
|
|
||||||
You: "You",
|
|
||||||
Assistant: "Assistant",
|
|
||||||
"Thinking...": "Thinking...",
|
|
||||||
"Type your message...": "Type your message...",
|
|
||||||
"API Keys Configuration": "API Keys Configuration",
|
|
||||||
"Configure API keys for LLM providers. Keys are stored locally in your browser.":
|
|
||||||
"Configure API keys for LLM providers. Keys are stored locally in your browser.",
|
|
||||||
Configured: "Configured",
|
|
||||||
"Not configured": "Not configured",
|
|
||||||
"✓ Valid": "✓ Valid",
|
|
||||||
"✗ Invalid": "✗ Invalid",
|
|
||||||
"Testing...": "Testing...",
|
|
||||||
Update: "Update",
|
|
||||||
Test: "Test",
|
|
||||||
Remove: "Remove",
|
|
||||||
Save: "Save",
|
|
||||||
"Update API key": "Update API key",
|
|
||||||
"Enter API key": "Enter API key",
|
|
||||||
"Type a message...": "Type a message...",
|
|
||||||
"Failed to fetch file": "Failed to fetch file",
|
|
||||||
"Invalid source type": "Invalid source type",
|
|
||||||
PDF: "PDF",
|
|
||||||
Document: "Document",
|
|
||||||
Presentation: "Presentation",
|
|
||||||
Spreadsheet: "Spreadsheet",
|
|
||||||
Text: "Text",
|
|
||||||
"Error loading file": "Error loading file",
|
|
||||||
"No text content available": "No text content available",
|
|
||||||
"Failed to load PDF": "Failed to load PDF",
|
|
||||||
"Failed to load document": "Failed to load document",
|
|
||||||
"Failed to load spreadsheet": "Failed to load spreadsheet",
|
|
||||||
"No content available": "No content available",
|
|
||||||
"Failed to display text content": "Failed to display text content",
|
|
||||||
"API keys are required to use AI models. Get your keys from the provider's website.":
|
|
||||||
"API keys are required to use AI models. Get your keys from the provider's website.",
|
|
||||||
console: "console",
|
|
||||||
"Copy output": "Copy output",
|
|
||||||
"Copied!": "Copied!",
|
|
||||||
"Error:": "Error:",
|
|
||||||
"Request aborted": "Request aborted",
|
|
||||||
Call: "Call",
|
|
||||||
Result: "Result",
|
|
||||||
"(no result)": "(no result)",
|
|
||||||
"Waiting for tool result…": "Waiting for tool result…",
|
|
||||||
"Call was aborted; no result.": "Call was aborted; no result.",
|
|
||||||
"No session available": "No session available",
|
|
||||||
"No session set": "No session set",
|
|
||||||
"Preparing tool parameters...": "Preparing tool parameters...",
|
|
||||||
"(no output)": "(no output)",
|
|
||||||
"Writing expression...": "Writing expression...",
|
|
||||||
Calculating: "Calculating",
|
|
||||||
"Getting current time in": "Getting current time in",
|
|
||||||
"Getting current date and time": "Getting current date and time",
|
|
||||||
"Writing command...": "Writing command...",
|
|
||||||
"Running command:": "Running command:",
|
|
||||||
"Command failed:": "Command failed:",
|
|
||||||
"Enter Auth Token": "Enter Auth Token",
|
|
||||||
"Please enter your auth token.": "Please enter your auth token.",
|
|
||||||
"Auth token is required for proxy transport": "Auth token is required for proxy transport",
|
|
||||||
// JavaScript REPL strings
|
|
||||||
"Execution aborted": "Execution aborted",
|
|
||||||
"Code parameter is required": "Code parameter is required",
|
|
||||||
"Unknown error": "Unknown error",
|
|
||||||
"Code executed successfully (no output)": "Code executed successfully (no output)",
|
|
||||||
"Execution failed": "Execution failed",
|
|
||||||
"JavaScript REPL": "JavaScript REPL",
|
|
||||||
"JavaScript code to execute": "JavaScript code to execute",
|
|
||||||
"Writing JavaScript code...": "Writing JavaScript code...",
|
|
||||||
"Executing JavaScript": "Executing JavaScript",
|
|
||||||
// Artifacts strings
|
|
||||||
"Processing artifact...": "Processing artifact...",
|
|
||||||
Processing: "Processing",
|
|
||||||
Create: "Create",
|
|
||||||
Rewrite: "Rewrite",
|
|
||||||
Get: "Get",
|
|
||||||
Delete: "Delete",
|
|
||||||
"Get logs": "Get logs",
|
|
||||||
"Show artifacts": "Show artifacts",
|
|
||||||
"Close artifacts": "Close artifacts",
|
|
||||||
Artifacts: "Artifacts",
|
|
||||||
"Copy HTML": "Copy HTML",
|
|
||||||
"Download HTML": "Download HTML",
|
|
||||||
"Copy SVG": "Copy SVG",
|
|
||||||
"Download SVG": "Download SVG",
|
|
||||||
"Copy Markdown": "Copy Markdown",
|
|
||||||
"Download Markdown": "Download Markdown",
|
|
||||||
Download: "Download",
|
|
||||||
"No logs for {filename}": "No logs for {filename}",
|
|
||||||
},
|
|
||||||
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...": "Testet...",
|
|
||||||
Update: "Aktualisieren",
|
|
||||||
Test: "Testen",
|
|
||||||
Remove: "Entfernen",
|
|
||||||
Save: "Speichern",
|
|
||||||
"Update API key": "API-Schlüssel aktualisieren",
|
|
||||||
"Enter API key": "API-Schlüssel eingeben",
|
|
||||||
"Type a message...": "Nachricht eingeben...",
|
|
||||||
"Failed to fetch file": "Datei konnte nicht abgerufen werden",
|
|
||||||
"Invalid source type": "Ungültiger Quellentyp",
|
|
||||||
PDF: "PDF",
|
|
||||||
Document: "Dokument",
|
|
||||||
Presentation: "Präsentation",
|
|
||||||
Spreadsheet: "Tabelle",
|
|
||||||
Text: "Text",
|
|
||||||
"Error loading file": "Fehler beim Laden der Datei",
|
|
||||||
"No text content available": "Kein Textinhalt verfügbar",
|
|
||||||
"Failed to load PDF": "PDF konnte nicht geladen werden",
|
|
||||||
"Failed to load document": "Dokument konnte nicht geladen werden",
|
|
||||||
"Failed to load spreadsheet": "Tabelle konnte nicht geladen werden",
|
|
||||||
"No content available": "Kein Inhalt verfügbar",
|
|
||||||
"Failed to display text content": "Textinhalt konnte nicht angezeigt werden",
|
|
||||||
"API keys are required to use AI models. Get your keys from the provider's website.":
|
|
||||||
"API-Schlüssel sind erforderlich, um KI-Modelle zu verwenden. Holen Sie sich Ihre Schlüssel von der Website des Anbieters.",
|
|
||||||
console: "Konsole",
|
|
||||||
"Copy output": "Ausgabe kopieren",
|
|
||||||
"Copied!": "Kopiert!",
|
|
||||||
"Error:": "Fehler:",
|
|
||||||
"Request aborted": "Anfrage abgebrochen",
|
|
||||||
Call: "Aufruf",
|
|
||||||
Result: "Ergebnis",
|
|
||||||
"(no result)": "(kein Ergebnis)",
|
|
||||||
"Waiting for tool result…": "Warte auf Tool-Ergebnis…",
|
|
||||||
"Call was aborted; no result.": "Aufruf wurde abgebrochen; kein Ergebnis.",
|
|
||||||
"No session available": "Keine Sitzung verfügbar",
|
|
||||||
"No session set": "Keine Sitzung gesetzt",
|
|
||||||
"Preparing tool parameters...": "Bereite Tool-Parameter vor...",
|
|
||||||
"(no output)": "(keine Ausgabe)",
|
|
||||||
"Writing expression...": "Schreibe Ausdruck...",
|
|
||||||
Calculating: "Berechne",
|
|
||||||
"Getting current time in": "Hole aktuelle Zeit in",
|
|
||||||
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
|
|
||||||
"Writing command...": "Schreibe Befehl...",
|
|
||||||
"Running command:": "Führe Befehl aus:",
|
|
||||||
"Command failed:": "Befehl fehlgeschlagen:",
|
|
||||||
"Enter Auth Token": "Auth-Token eingeben",
|
|
||||||
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
|
|
||||||
"Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich",
|
|
||||||
// JavaScript REPL strings
|
|
||||||
"Execution aborted": "Ausführung abgebrochen",
|
|
||||||
"Code parameter is required": "Code-Parameter ist erforderlich",
|
|
||||||
"Unknown error": "Unbekannter Fehler",
|
|
||||||
"Code executed successfully (no output)": "Code erfolgreich ausgeführt (keine Ausgabe)",
|
|
||||||
"Execution failed": "Ausführung fehlgeschlagen",
|
|
||||||
"JavaScript REPL": "JavaScript REPL",
|
|
||||||
"JavaScript code to execute": "Auszuführender JavaScript-Code",
|
|
||||||
"Writing JavaScript code...": "Schreibe JavaScript-Code...",
|
|
||||||
"Executing JavaScript": "Führe JavaScript aus",
|
|
||||||
// Artifacts strings
|
|
||||||
"Processing artifact...": "Verarbeite Artefakt...",
|
|
||||||
Processing: "Verarbeitung",
|
|
||||||
Create: "Erstellen",
|
|
||||||
Rewrite: "Überschreiben",
|
|
||||||
Get: "Abrufen",
|
|
||||||
Delete: "Löschen",
|
|
||||||
"Get logs": "Logs abrufen",
|
|
||||||
"Show artifacts": "Artefakte anzeigen",
|
|
||||||
"Close artifacts": "Artefakte schließen",
|
|
||||||
Artifacts: "Artefakte",
|
|
||||||
"Copy HTML": "HTML kopieren",
|
|
||||||
"Download HTML": "HTML herunterladen",
|
|
||||||
"Copy SVG": "SVG kopieren",
|
|
||||||
"Download SVG": "SVG herunterladen",
|
|
||||||
"Copy Markdown": "Markdown kopieren",
|
|
||||||
"Download Markdown": "Markdown herunterladen",
|
|
||||||
Download: "Herunterladen",
|
|
||||||
"No logs for {filename}": "Keine Logs für {filename}",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
setTranslations(translations);
|
|
||||||
|
|
||||||
export * from "@mariozechner/mini-lit/dist/i18n.js";
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -9,7 +9,6 @@ import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
|
||||||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
||||||
import { getAuthToken } from "./utils/auth-token.js";
|
import { getAuthToken } from "./utils/auth-token.js";
|
||||||
import { i18n } from "./utils/i18n.js";
|
import { i18n } from "./utils/i18n.js";
|
||||||
import { simpleHtml } from "./utils/test-sessions.js";
|
|
||||||
|
|
||||||
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
|
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
|
||||||
|
|
||||||
|
|
@ -23,6 +22,7 @@ export class ChatPanel extends LitElement {
|
||||||
@state() private windowWidth = window.innerWidth;
|
@state() private windowWidth = window.innerWidth;
|
||||||
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
|
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
|
||||||
@property({ type: Array }) additionalTools: AgentTool<any, any>[] = [];
|
@property({ type: Array }) additionalTools: AgentTool<any, any>[] = [];
|
||||||
|
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
||||||
|
|
||||||
private resizeHandler = () => {
|
private resizeHandler = () => {
|
||||||
this.windowWidth = window.innerWidth;
|
this.windowWidth = window.innerWidth;
|
||||||
|
|
@ -45,8 +45,17 @@ export class ChatPanel extends LitElement {
|
||||||
this.style.height = "100%";
|
this.style.height = "100%";
|
||||||
this.style.minHeight = "0";
|
this.style.minHeight = "0";
|
||||||
|
|
||||||
|
// Create JavaScript REPL tool with attachments provider
|
||||||
|
const javascriptReplTool = createJavaScriptReplTool();
|
||||||
|
if (this.sandboxUrlProvider) {
|
||||||
|
javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||||
|
}
|
||||||
|
|
||||||
// Set up artifacts panel
|
// Set up artifacts panel
|
||||||
this.artifactsPanel = new ArtifactsPanel();
|
this.artifactsPanel = new ArtifactsPanel();
|
||||||
|
if (this.sandboxUrlProvider) {
|
||||||
|
this.artifactsPanel.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||||
|
}
|
||||||
registerToolRenderer("artifacts", this.artifactsPanel);
|
registerToolRenderer("artifacts", this.artifactsPanel);
|
||||||
|
|
||||||
// Attachments provider for both REPL and artifacts
|
// Attachments provider for both REPL and artifacts
|
||||||
|
|
@ -72,11 +81,8 @@ export class ChatPanel extends LitElement {
|
||||||
return attachments;
|
return attachments;
|
||||||
};
|
};
|
||||||
|
|
||||||
this.artifactsPanel.attachmentsProvider = getAttachments;
|
|
||||||
|
|
||||||
// Set up JavaScript REPL tool with attachments provider
|
|
||||||
const javascriptReplTool = createJavaScriptReplTool();
|
|
||||||
javascriptReplTool.attachmentsProvider = getAttachments;
|
javascriptReplTool.attachmentsProvider = getAttachments;
|
||||||
|
this.artifactsPanel.attachmentsProvider = getAttachments;
|
||||||
|
|
||||||
this.artifactsPanel.onArtifactsChange = () => {
|
this.artifactsPanel.onArtifactsChange = () => {
|
||||||
const count = this.artifactsPanel.artifacts?.size ?? 0;
|
const count = this.artifactsPanel.artifacts?.size ?? 0;
|
||||||
|
|
@ -101,14 +107,14 @@ export class ChatPanel extends LitElement {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
let initialState = {
|
const initialState = {
|
||||||
systemPrompt: this.systemPrompt,
|
systemPrompt: this.systemPrompt,
|
||||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||||
tools: [...this.additionalTools, javascriptReplTool, this.artifactsPanel.tool],
|
tools: [...this.additionalTools, javascriptReplTool, this.artifactsPanel.tool],
|
||||||
thinkingLevel: "off" as ThinkingLevel,
|
thinkingLevel: "off" as ThinkingLevel,
|
||||||
messages: [],
|
messages: [],
|
||||||
} satisfies Partial<AgentSessionState>;
|
} satisfies Partial<AgentSessionState>;
|
||||||
initialState = { ...initialState, ...(simpleHtml as any) };
|
// initialState = { ...initialState, ...(simpleHtml as any) };
|
||||||
// initialState = { ...initialState, ...(longSession as any) };
|
// initialState = { ...initialState, ...(longSession as any) };
|
||||||
|
|
||||||
// Create agent session first so attachments provider works
|
// Create agent session first so attachments provider works
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import "./MessageEditor.js";
|
||||||
import "./MessageList.js";
|
import "./MessageList.js";
|
||||||
import "./Messages.js"; // Import for side effects to register the custom elements
|
import "./Messages.js"; // Import for side effects to register the custom elements
|
||||||
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
|
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
|
||||||
import { keyStore } from "../state/KeyStore.js";
|
import { getKeyStore } from "../state/key-store.js";
|
||||||
import "./StreamingMessageContainer.js";
|
import "./StreamingMessageContainer.js";
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
import type { Attachment } from "../utils/attachment-utils.js";
|
||||||
import { formatUsage } from "../utils/format.js";
|
import { formatUsage } from "../utils/format.js";
|
||||||
|
|
@ -166,13 +166,13 @@ export class AgentInterface extends LitElement {
|
||||||
|
|
||||||
// Check if API key exists for the provider (only needed in direct mode)
|
// Check if API key exists for the provider (only needed in direct mode)
|
||||||
const provider = session.state.model.provider;
|
const provider = session.state.model.provider;
|
||||||
let apiKey = await keyStore.getKey(provider);
|
let apiKey = await getKeyStore().getKey(provider);
|
||||||
|
|
||||||
// If no API key, open the API keys dialog
|
// If no API key, open the API keys dialog
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
await ApiKeysDialog.open();
|
await ApiKeysDialog.open();
|
||||||
// Check again after dialog closes
|
// Check again after dialog closes
|
||||||
apiKey = await keyStore.getKey(provider);
|
apiKey = await getKeyStore().getKey(provider);
|
||||||
// If still no API key, abort the send
|
// If still no API key, abort the send
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,19 @@ import { type Context, complete, getModel, getProviders } from "@mariozechner/pi
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { customElement, state } from "lit/decorators.js";
|
import { customElement, state } from "lit/decorators.js";
|
||||||
import { Input } from "../components/Input.js";
|
import { Input } from "../components/Input.js";
|
||||||
import { keyStore } from "../state/KeyStore.js";
|
import { getKeyStore } from "../state/key-store.js";
|
||||||
import { i18n } from "../utils/i18n.js";
|
import { i18n } from "../utils/i18n.js";
|
||||||
|
|
||||||
// Test models for each provider - known to be reliable and cheap
|
// Test models for each provider - known to be reliable and cheap
|
||||||
const TEST_MODELS: Record<string, string> = {
|
const TEST_MODELS: Record<string, string> = {
|
||||||
anthropic: "claude-3-5-haiku-20241022",
|
anthropic: "claude-3-5-haiku-20241022",
|
||||||
openai: "gpt-4o-mini",
|
openai: "gpt-4o-mini",
|
||||||
google: "gemini-2.0-flash-exp",
|
google: "gemini-2.5-flash",
|
||||||
groq: "llama-3.3-70b-versatile",
|
groq: "openai/gpt-oss-20b",
|
||||||
openrouter: "openai/gpt-4o-mini",
|
openrouter: "z-ai/glm-4.6",
|
||||||
cerebras: "llama3.1-8b",
|
cerebras: "gpt-oss-120b",
|
||||||
xai: "grok-2-1212",
|
xai: "grok-4-fast-non-reasoning",
|
||||||
zai: "glm-4-plus",
|
zai: "glm-4.5-air",
|
||||||
};
|
};
|
||||||
|
|
||||||
@customElement("api-keys-dialog")
|
@customElement("api-keys-dialog")
|
||||||
|
|
@ -42,7 +42,7 @@ export class ApiKeysDialog extends DialogBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadKeys() {
|
private async loadKeys() {
|
||||||
this.apiKeys = await keyStore.getAllKeys();
|
this.apiKeys = await getKeyStore().getAllKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
||||||
|
|
@ -69,13 +69,7 @@ export class ApiKeysDialog extends DialogBase {
|
||||||
maxTokens: 10, // Keep it minimal for testing
|
maxTokens: 10, // Keep it minimal for testing
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Check if response contains expected text
|
return true;
|
||||||
const text = response.content
|
|
||||||
.filter((b) => b.type === "text")
|
|
||||||
.map((b) => b.text)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return text.toLowerCase().includes("test successful");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`API key test failed for ${provider}:`, error);
|
console.error(`API key test failed for ${provider}:`, error);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -95,7 +89,7 @@ export class ApiKeysDialog extends DialogBase {
|
||||||
const isValid = await this.testApiKey(provider, key);
|
const isValid = await this.testApiKey(provider, key);
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
await keyStore.setKey(provider, key);
|
await getKeyStore().setKey(provider, key);
|
||||||
this.apiKeyInputs[provider] = ""; // Clear input
|
this.apiKeyInputs[provider] = ""; // Clear input
|
||||||
await this.loadKeys();
|
await this.loadKeys();
|
||||||
this.testResults[provider] = "success";
|
this.testResults[provider] = "success";
|
||||||
|
|
@ -123,7 +117,7 @@ export class ApiKeysDialog extends DialogBase {
|
||||||
this.error = "";
|
this.error = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKey = await keyStore.getKey(provider);
|
const apiKey = await getKeyStore().getKey(provider);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
this.testResults[provider] = "error";
|
this.testResults[provider] = "error";
|
||||||
this.error = `No API key found for ${provider}`;
|
this.error = `No API key found for ${provider}`;
|
||||||
|
|
@ -155,7 +149,7 @@ export class ApiKeysDialog extends DialogBase {
|
||||||
private async removeKey(provider: string) {
|
private async removeKey(provider: string) {
|
||||||
if (!confirm(`Remove API key for ${provider}?`)) return;
|
if (!confirm(`Remove API key for ${provider}?`)) return;
|
||||||
|
|
||||||
await keyStore.removeKey(provider);
|
await getKeyStore().removeKey(provider);
|
||||||
this.apiKeyInputs[provider] = "";
|
this.apiKeyInputs[provider] = "";
|
||||||
await this.loadKeys();
|
await this.loadKeys();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,13 @@ export { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
||||||
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
|
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
|
||||||
// Dialogs
|
// Dialogs
|
||||||
export { ModelSelector } from "./dialogs/ModelSelector.js";
|
export { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||||
|
export type { AgentSessionState, ThinkingLevel } from "./state/agent-session.js";
|
||||||
// State management
|
// State management
|
||||||
export { AgentSession } from "./state/agent-session.js";
|
export { AgentSession } from "./state/agent-session.js";
|
||||||
export type { KeyStore, StorageAdapter } from "./state/KeyStore.js";
|
export type { KeyStore } from "./state/key-store.js";
|
||||||
export { KeyStoreImpl, keyStore } from "./state/KeyStore.js";
|
export { getKeyStore, LocalStorageKeyStore, setKeyStore } from "./state/key-store.js";
|
||||||
|
export type { StorageAdapter } from "./state/storage-adapter.js";
|
||||||
|
export { ChromeStorageAdapter, LocalStorageAdapter } from "./state/storage-adapter.js";
|
||||||
|
|
||||||
// Transports
|
// Transports
|
||||||
export { DirectTransport } from "./state/transports/DirectTransport.js";
|
export { DirectTransport } from "./state/transports/DirectTransport.js";
|
||||||
|
|
@ -41,6 +43,7 @@ export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
|
||||||
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
|
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
|
||||||
// Tools
|
// Tools
|
||||||
export { getToolRenderer, registerToolRenderer, renderToolParams, renderToolResult } from "./tools/index.js";
|
export { getToolRenderer, registerToolRenderer, renderToolParams, renderToolResult } from "./tools/index.js";
|
||||||
|
export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.js";
|
||||||
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
|
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
|
||||||
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
|
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
|
||||||
// Tool renderers
|
// Tool renderers
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { getProviders } from "@mariozechner/pi-ai";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic storage adapter interface
|
|
||||||
*/
|
|
||||||
export interface StorageAdapter {
|
|
||||||
get(key: string): Promise<string | null>;
|
|
||||||
set(key: string, value: string): Promise<void>;
|
|
||||||
remove(key: string): Promise<void>;
|
|
||||||
getAll(): Promise<Record<string, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for API key storage
|
|
||||||
*/
|
|
||||||
export interface KeyStore {
|
|
||||||
getKey(provider: string): Promise<string | null>;
|
|
||||||
setKey(provider: string, key: string): Promise<void>;
|
|
||||||
removeKey(provider: string): Promise<void>;
|
|
||||||
getAllKeys(): Promise<Record<string, boolean>>; // provider -> isConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default localStorage implementation for web
|
|
||||||
*/
|
|
||||||
class LocalStorageAdapter implements StorageAdapter {
|
|
||||||
async get(key: string): Promise<string | null> {
|
|
||||||
return localStorage.getItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async set(key: string, value: string): Promise<void> {
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll(): Promise<Record<string, string>> {
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key) {
|
|
||||||
const value = localStorage.getItem(key);
|
|
||||||
if (value) result[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic KeyStore implementation
|
|
||||||
*/
|
|
||||||
class GenericKeyStore implements KeyStore {
|
|
||||||
private readonly prefix = "apiKey_";
|
|
||||||
private readonly storage: StorageAdapter;
|
|
||||||
|
|
||||||
constructor(storage?: StorageAdapter) {
|
|
||||||
this.storage = storage || new LocalStorageAdapter();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getKey(provider: string): Promise<string | null> {
|
|
||||||
const key = `${this.prefix}${provider}`;
|
|
||||||
return await this.storage.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setKey(provider: string, key: string): Promise<void> {
|
|
||||||
const storageKey = `${this.prefix}${provider}`;
|
|
||||||
await this.storage.set(storageKey, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeKey(provider: string): Promise<void> {
|
|
||||||
const key = `${this.prefix}${provider}`;
|
|
||||||
await this.storage.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllKeys(): Promise<Record<string, boolean>> {
|
|
||||||
const providers = getProviders();
|
|
||||||
const allStorage = await this.storage.getAll();
|
|
||||||
const result: Record<string, boolean> = {};
|
|
||||||
|
|
||||||
for (const provider of providers) {
|
|
||||||
const key = `${this.prefix}${provider}`;
|
|
||||||
result[provider] = !!allStorage[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance (uses localStorage by default)
|
|
||||||
export const keyStore = new GenericKeyStore();
|
|
||||||
|
|
||||||
// Export class for custom storage implementations
|
|
||||||
export { GenericKeyStore as KeyStoreImpl };
|
|
||||||
67
packages/web-ui/src/state/key-store.ts
Normal file
67
packages/web-ui/src/state/key-store.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { getProviders } from "@mariozechner/pi-ai";
|
||||||
|
import { LocalStorageAdapter, type StorageAdapter } from "./storage-adapter.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key storage interface
|
||||||
|
*/
|
||||||
|
export interface KeyStore {
|
||||||
|
getKey(provider: string): Promise<string | null>;
|
||||||
|
setKey(provider: string, key: string): Promise<void>;
|
||||||
|
removeKey(provider: string): Promise<void>;
|
||||||
|
getAllKeys(): Promise<Record<string, boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key storage implementation using a pluggable storage adapter
|
||||||
|
*/
|
||||||
|
export class LocalStorageKeyStore implements KeyStore {
|
||||||
|
private readonly prefix = "apiKey_";
|
||||||
|
|
||||||
|
constructor(private readonly storage: StorageAdapter) {}
|
||||||
|
|
||||||
|
async getKey(provider: string): Promise<string | null> {
|
||||||
|
const key = `${this.prefix}${provider}`;
|
||||||
|
return await this.storage.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setKey(provider: string, key: string): Promise<void> {
|
||||||
|
const storageKey = `${this.prefix}${provider}`;
|
||||||
|
await this.storage.set(storageKey, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeKey(provider: string): Promise<void> {
|
||||||
|
const key = `${this.prefix}${provider}`;
|
||||||
|
await this.storage.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllKeys(): Promise<Record<string, boolean>> {
|
||||||
|
const providers = getProviders();
|
||||||
|
const allStorage = await this.storage.getAll();
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const key = `${this.prefix}${provider}`;
|
||||||
|
result[provider] = !!allStorage[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default instance using localStorage
|
||||||
|
let _keyStore: KeyStore = new LocalStorageKeyStore(new LocalStorageAdapter());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current KeyStore instance
|
||||||
|
*/
|
||||||
|
export function getKeyStore(): KeyStore {
|
||||||
|
return _keyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a custom KeyStore implementation
|
||||||
|
* Call this once at application startup before any components are initialized
|
||||||
|
*/
|
||||||
|
export function setKeyStore(store: KeyStore): void {
|
||||||
|
_keyStore = store;
|
||||||
|
}
|
||||||
77
packages/web-ui/src/state/storage-adapter.ts
Normal file
77
packages/web-ui/src/state/storage-adapter.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* Generic storage adapter interface for key/value persistence
|
||||||
|
*/
|
||||||
|
export interface StorageAdapter {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
set(key: string, value: string): Promise<void>;
|
||||||
|
remove(key: string): Promise<void>;
|
||||||
|
getAll(): Promise<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalStorage implementation
|
||||||
|
*/
|
||||||
|
export class LocalStorageAdapter implements StorageAdapter {
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string): Promise<void> {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<Record<string, string>> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key) {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
if (value) result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome/Firefox extension storage implementation
|
||||||
|
*/
|
||||||
|
export class ChromeStorageAdapter implements StorageAdapter {
|
||||||
|
private readonly storage: any;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const isBrowser = typeof globalThis !== "undefined";
|
||||||
|
const hasChrome = isBrowser && (globalThis as any).chrome?.storage;
|
||||||
|
const hasBrowser = isBrowser && (globalThis as any).browser?.storage;
|
||||||
|
|
||||||
|
if (hasBrowser) {
|
||||||
|
this.storage = (globalThis as any).browser.storage.local;
|
||||||
|
} else if (hasChrome) {
|
||||||
|
this.storage = (globalThis as any).chrome.storage.local;
|
||||||
|
} else {
|
||||||
|
throw new Error("Chrome/Browser storage not available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
const result = await this.storage.get(key);
|
||||||
|
return result[key] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string): Promise<void> {
|
||||||
|
await this.storage.set({ [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
await this.storage.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<Record<string, string>> {
|
||||||
|
const result = await this.storage.get();
|
||||||
|
return result || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
|
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
|
||||||
import { keyStore } from "../KeyStore.js";
|
import { getKeyStore } from "../key-store.js";
|
||||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||||
|
|
||||||
export class DirectTransport implements AgentTransport {
|
export class DirectTransport implements AgentTransport {
|
||||||
|
|
@ -7,7 +7,7 @@ export class DirectTransport implements AgentTransport {
|
||||||
|
|
||||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||||
// Get API key from KeyStore
|
// Get API key from KeyStore
|
||||||
const apiKey = await keyStore.getKey(cfg.model.provider);
|
const apiKey = await getKeyStore().getKey(cfg.model.provider);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("no-api-key");
|
throw new Error("no-api-key");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export async function executeJavaScript(
|
||||||
code: string,
|
code: string,
|
||||||
attachments: Attachment[] = [],
|
attachments: Attachment[] = [],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
sandboxUrlProvider?: () => string,
|
||||||
): Promise<{ output: string; files?: SandboxFile[] }> {
|
): Promise<{ output: string; files?: SandboxFile[] }> {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new Error("Code parameter is required");
|
throw new Error("Code parameter is required");
|
||||||
|
|
@ -24,6 +25,9 @@ export async function executeJavaScript(
|
||||||
|
|
||||||
// Create a SandboxedIframe instance for execution
|
// Create a SandboxedIframe instance for execution
|
||||||
const sandbox = new SandboxIframe();
|
const sandbox = new SandboxIframe();
|
||||||
|
if (sandboxUrlProvider) {
|
||||||
|
sandbox.sandboxUrlProvider = sandboxUrlProvider;
|
||||||
|
}
|
||||||
sandbox.style.display = "none";
|
sandbox.style.display = "none";
|
||||||
document.body.appendChild(sandbox);
|
document.body.appendChild(sandbox);
|
||||||
|
|
||||||
|
|
@ -93,11 +97,13 @@ const javascriptReplSchema = Type.Object({
|
||||||
|
|
||||||
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
|
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
|
||||||
attachmentsProvider?: () => Attachment[];
|
attachmentsProvider?: () => Attachment[];
|
||||||
|
sandboxUrlProvider?: () => string;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
label: "JavaScript REPL",
|
label: "JavaScript REPL",
|
||||||
name: "javascript_repl",
|
name: "javascript_repl",
|
||||||
attachmentsProvider: () => [], // default to empty array
|
attachmentsProvider: () => [], // default to empty array
|
||||||
|
sandboxUrlProvider: undefined, // optional, for browser extensions
|
||||||
description: `Execute JavaScript code in a sandboxed browser environment with full modern browser capabilities.
|
description: `Execute JavaScript code in a sandboxed browser environment with full modern browser capabilities.
|
||||||
|
|
||||||
Environment: Modern browser with ALL Web APIs available:
|
Environment: Modern browser with ALL Web APIs available:
|
||||||
|
|
@ -173,7 +179,7 @@ Global variables:
|
||||||
parameters: javascriptReplSchema,
|
parameters: javascriptReplSchema,
|
||||||
execute: async function (_toolCallId: string, args: Static<typeof javascriptReplSchema>, signal?: AbortSignal) {
|
execute: async function (_toolCallId: string, args: Static<typeof javascriptReplSchema>, signal?: AbortSignal) {
|
||||||
const attachments = this.attachmentsProvider?.() || [];
|
const attachments = this.attachmentsProvider?.() || [];
|
||||||
const result = await executeJavaScript(args.code, attachments, signal);
|
const result = await executeJavaScript(args.code, attachments, signal, this.sandboxUrlProvider);
|
||||||
// Convert files to JSON-serializable with base64 payloads
|
// Convert files to JSON-serializable with base64 payloads
|
||||||
const files = (result.files || []).map((f) => {
|
const files = (result.files || []).map((f) => {
|
||||||
const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {
|
const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue