mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 06:02:42 +00:00
More browser extension work, disable ajv validation in browser extensions, it uses eval/new Function, which is not allowed in manifest v3 extensions
This commit is contained in:
parent
fc1ef04b6d
commit
0e932a97df
14 changed files with 1151 additions and 11 deletions
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -3144,6 +3144,18 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-interpreter": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-interpreter/-/js-interpreter-6.0.1.tgz",
|
||||
"integrity": "sha512-XfPw6y1FzFwHcGYB62jzPUoSCoCSIL+dICMjRJx6f8V/AmTczeodDOaVxWc4GU4p7qeN7ieuMXNKxScoaBkJ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8"
|
||||
},
|
||||
"bin": {
|
||||
"js-interpreter": "lib/cli.min.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
|
|
@ -3603,7 +3615,6 @@
|
|||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
|
|
@ -5389,6 +5400,7 @@
|
|||
"@mariozechner/mini-lit": "^0.1.4",
|
||||
"@mariozechner/pi-ai": "^0.5.43",
|
||||
"docx-preview": "^0.3.7",
|
||||
"js-interpreter": "^6.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lit": "^3.3.1",
|
||||
"lucide": "^0.544.0",
|
||||
|
|
|
|||
|
|
@ -7,9 +7,25 @@ const addFormats = (addFormatsModule as any).default || addFormatsModule;
|
|||
|
||||
import type { Tool, ToolCall } from "../types.js";
|
||||
|
||||
// Create a singleton AJV instance with formats
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
// Detect if we're in a browser extension environment with strict CSP
|
||||
// Chrome extensions with Manifest V3 don't allow eval/Function constructor
|
||||
const isBrowserExtension = typeof globalThis !== "undefined" && (globalThis as any).chrome?.runtime?.id !== undefined;
|
||||
|
||||
// Create a singleton AJV instance with formats (only if not in browser extension)
|
||||
// AJV requires 'unsafe-eval' CSP which is not allowed in Manifest V3
|
||||
let ajv: any = null;
|
||||
if (!isBrowserExtension) {
|
||||
try {
|
||||
ajv = new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
});
|
||||
addFormats(ajv);
|
||||
} catch (e) {
|
||||
// AJV initialization failed (likely CSP restriction)
|
||||
console.warn("AJV validation disabled due to CSP restrictions");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates tool call arguments against the tool's TypeBox schema
|
||||
|
|
@ -19,6 +35,13 @@ addFormats(ajv);
|
|||
* @throws Error with formatted message if validation fails
|
||||
*/
|
||||
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
|
||||
// Skip validation in browser extension environment (CSP restrictions prevent AJV from working)
|
||||
if (!ajv || isBrowserExtension) {
|
||||
// Trust the LLM's output without validation
|
||||
// Browser extensions can't use AJV due to Manifest V3 CSP restrictions
|
||||
return toolCall.arguments;
|
||||
}
|
||||
|
||||
// Compile the schema
|
||||
const validate = ajv.compile(tool.parameters);
|
||||
|
||||
|
|
|
|||
|
|
@ -28,5 +28,11 @@
|
|||
"https://*/*",
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
]
|
||||
],
|
||||
"sandbox": {
|
||||
"pages": ["sandbox.html"]
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
}
|
||||
}
|
||||
|
|
@ -39,5 +39,11 @@
|
|||
"id": "pi-reader@mariozechner.at",
|
||||
"strict_min_version": "115.0"
|
||||
}
|
||||
},
|
||||
"sandbox": {
|
||||
"pages": ["sandbox.html"]
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"@mariozechner/mini-lit": "^0.1.4",
|
||||
"@mariozechner/pi-ai": "^0.5.43",
|
||||
"docx-preview": "^0.3.7",
|
||||
"js-interpreter": "^6.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lit": "^3.3.1",
|
||||
"lucide": "^0.544.0",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ const copyStatic = () => {
|
|||
"icon-16.png",
|
||||
"icon-48.png",
|
||||
"icon-128.png",
|
||||
join("src", "sidepanel.html")
|
||||
join("src", "sidepanel.html"),
|
||||
join("src", "sandbox.html")
|
||||
];
|
||||
|
||||
for (const relative of filesToCopy) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { LitElement } from "lit";
|
|||
import { customElement, state } from "lit/decorators.js";
|
||||
import "./AgentInterface.js";
|
||||
import { AgentSession } from "./state/agent-session.js";
|
||||
import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
|
||||
import { getAuthToken } from "./utils/auth-token.js";
|
||||
|
||||
@customElement("pi-chat-panel")
|
||||
|
|
@ -23,17 +24,44 @@ export class ChatPanel extends LitElement {
|
|||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
|
||||
// Create JavaScript REPL tool with attachments provider
|
||||
const javascriptReplTool = createJavaScriptReplTool();
|
||||
|
||||
// Create agent session with default settings
|
||||
this.session = new AgentSession({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful AI assistant.",
|
||||
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
|
||||
tools: [calculateTool, getCurrentTimeTool],
|
||||
tools: [calculateTool, getCurrentTimeTool, browserJavaScriptTool, javascriptReplTool],
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
authTokenProvider: async () => getAuthToken(),
|
||||
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
|
||||
});
|
||||
|
||||
// Wire up attachments provider for JavaScript REPL tool
|
||||
// We'll need to get attachments from the AgentInterface
|
||||
javascriptReplTool.attachmentsProvider = () => {
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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 { BookOpen, 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";
|
||||
|
|
@ -194,7 +194,7 @@ export class MessageEditor extends LitElement {
|
|||
|
||||
<!-- Button Row -->
|
||||
<div class="px-2 pb-2 flex items-center justify-between">
|
||||
<!-- Left side - attachment button -->
|
||||
<!-- Left side - attachment and quick action buttons -->
|
||||
<div class="flex gap-2 items-center">
|
||||
${
|
||||
this.showAttachmentButton
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ export class StreamingMessageContainer extends LitElement {
|
|||
requestAnimationFrame(async () => {
|
||||
// Only apply the update if we haven't been cleared
|
||||
if (!this._immediateUpdate && this._pendingMessage !== null) {
|
||||
this._message = this._pendingMessage;
|
||||
// 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
|
||||
|
|
|
|||
174
packages/browser-extension/src/sandbox.html
Normal file
174
packages/browser-extension/src/sandbox.html
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script>
|
||||
// Store files and console output for current execution
|
||||
let returnedFiles = [];
|
||||
let consoleOutput = [];
|
||||
|
||||
// Store attachments with helper methods
|
||||
window.attachments = [];
|
||||
|
||||
// Helper function to list available files
|
||||
window.listFiles = function() {
|
||||
return window.attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size
|
||||
}));
|
||||
};
|
||||
|
||||
// Helper function to read text file by ID
|
||||
window.readTextFile = function(attachmentId) {
|
||||
const attachment = window.attachments.find(a => a.id === attachmentId);
|
||||
if (!attachment) {
|
||||
throw new Error('Attachment not found: ' + attachmentId);
|
||||
}
|
||||
// If extractedText exists, return it
|
||||
if (attachment.extractedText) {
|
||||
return attachment.extractedText;
|
||||
}
|
||||
// Otherwise decode base64 content
|
||||
try {
|
||||
return atob(attachment.content);
|
||||
} catch (e) {
|
||||
throw new Error('Failed to decode text content for: ' + attachmentId);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to read binary file as Uint8Array
|
||||
window.readBinaryFile = function(attachmentId) {
|
||||
const attachment = window.attachments.find(a => a.id === attachmentId);
|
||||
if (!attachment) {
|
||||
throw new Error('Attachment not found: ' + attachmentId);
|
||||
}
|
||||
const binaryString = atob(attachment.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
// Override console
|
||||
['log', 'error', 'warn', 'info', 'debug', 'table'].forEach(method => {
|
||||
const original = console[method];
|
||||
console[method] = (...args) => {
|
||||
// Convert args to strings for plain text output
|
||||
const stringArgs = args.map(arg => {
|
||||
try {
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (arg === null) return 'null';
|
||||
if (typeof arg === 'function') return arg.toString();
|
||||
if (typeof arg === 'symbol') return arg.toString();
|
||||
if (arg instanceof Error) return arg.stack || arg.message;
|
||||
if (typeof arg === 'object') return JSON.stringify(arg, null, 2);
|
||||
return String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
});
|
||||
consoleOutput.push({ type: method, args: stringArgs });
|
||||
original.apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
// Single return function for files only
|
||||
window.returnFile = async (fileName, content, mimeType) => {
|
||||
// Validate mimeType for binary-like content to guide the LLM
|
||||
const isBinaryLike = (val) => val instanceof Blob || val instanceof Uint8Array;
|
||||
if (isBinaryLike(content) && (!mimeType || typeof mimeType !== 'string' || !mimeType.includes('/'))) {
|
||||
const err = new Error('returnFile: mimeType is required for Blob/Uint8Array content (e.g., "image/png").');
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Default for plain string content when not specified
|
||||
if (typeof content === 'string' && (!mimeType || typeof mimeType !== 'string' || mimeType === '')) {
|
||||
mimeType = 'text/plain';
|
||||
}
|
||||
|
||||
// Handle data URLs - extract the data and mime type
|
||||
if (typeof content === 'string' && content.startsWith('data:')) {
|
||||
const matches = content.match(/^data:([^;,]+)(;base64)?,(.*)$/);
|
||||
if (matches) {
|
||||
if (mimeType === 'text/plain' && matches[1]) {
|
||||
mimeType = matches[1];
|
||||
}
|
||||
const isBase64 = !!matches[2];
|
||||
const data = matches[3];
|
||||
if (isBase64) {
|
||||
const binaryString = atob(data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
content = bytes;
|
||||
} else {
|
||||
content = decodeURIComponent(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert Blob to Uint8Array for postMessage
|
||||
else if (content instanceof Blob) {
|
||||
const arrayBuffer = await content.arrayBuffer();
|
||||
content = new Uint8Array(arrayBuffer);
|
||||
}
|
||||
// Handle different content types
|
||||
else if (typeof content === 'object' && !(content instanceof Uint8Array)) {
|
||||
content = JSON.stringify(content, null, 2);
|
||||
if (!mimeType || mimeType === 'text/plain') {
|
||||
mimeType = 'application/json';
|
||||
}
|
||||
}
|
||||
returnedFiles.push({ fileName, content, mimeType });
|
||||
};
|
||||
|
||||
// Message handler
|
||||
window.addEventListener('message', async (e) => {
|
||||
if (e.data.type === 'setAttachments') {
|
||||
window.attachments = e.data.attachments;
|
||||
parent.postMessage({ type: 'ready' }, '*');
|
||||
} else if (e.data.type === 'execute') {
|
||||
// Reset for new execution
|
||||
returnedFiles = [];
|
||||
consoleOutput = [];
|
||||
|
||||
try {
|
||||
// Execute code in global scope to persist variables
|
||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||
const func = new AsyncFunction(e.data.code);
|
||||
const result = await func();
|
||||
|
||||
// If there was a direct return value, log it
|
||||
if (result !== undefined) {
|
||||
console.log('Return value:', result);
|
||||
}
|
||||
|
||||
parent.postMessage({
|
||||
type: 'result',
|
||||
success: true,
|
||||
files: returnedFiles,
|
||||
console: consoleOutput
|
||||
}, '*');
|
||||
} catch (error) {
|
||||
parent.postMessage({
|
||||
type: 'error',
|
||||
success: false,
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name
|
||||
},
|
||||
console: consoleOutput
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
424
packages/browser-extension/src/tools/browser-javascript.ts
Normal file
424
packages/browser-extension/src/tools/browser-javascript.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import "../ConsoleBlock.js"; // Ensure console-block is registered
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { registerToolRenderer } from "./renderer-registry.js";
|
||||
import type { ToolRenderer } from "./types.js";
|
||||
|
||||
const browserJavaScriptSchema = Type.Object({
|
||||
code: Type.String({ description: "JavaScript code to execute in the active browser tab" }),
|
||||
});
|
||||
|
||||
export type BrowserJavaScriptToolResult = {
|
||||
files?:
|
||||
| {
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}[]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const browserJavaScriptTool: AgentTool<typeof browserJavaScriptSchema, BrowserJavaScriptToolResult> = {
|
||||
label: "Browser JavaScript",
|
||||
name: "browser_javascript",
|
||||
description: `Execute JavaScript code in the context of the active browser tab.
|
||||
|
||||
Environment: The current page's JavaScript context with full access to:
|
||||
- The page's DOM (document, window, all elements)
|
||||
- The page's JavaScript variables and functions
|
||||
- All web APIs available to the page
|
||||
- localStorage, sessionStorage, cookies
|
||||
- Page frameworks (React, Vue, Angular, etc.)
|
||||
- Can modify the page, read data, interact with page scripts
|
||||
|
||||
The code is executed using eval() in the page context, so it can:
|
||||
- Access and modify global variables
|
||||
- Call page functions
|
||||
- Read/write to localStorage, cookies, etc.
|
||||
- Make fetch requests from the page's origin
|
||||
- Interact with page frameworks (React, Vue, etc.)
|
||||
|
||||
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, 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('page-screenshot.png', blob, 'image/png')
|
||||
- Extract page data to CSV:
|
||||
const links = Array.from(document.querySelectorAll('a')).map(a => ({text: a.textContent, href: a.href}));
|
||||
const csv = 'text,href\\n' + links.map(l => \`"\${l.text}","\${l.href}"\`).join('\\n');
|
||||
await returnFile('links.csv', csv, 'text/csv');
|
||||
|
||||
Examples:
|
||||
- Get page title: document.title
|
||||
- Get all links: Array.from(document.querySelectorAll('a')).map(a => ({text: a.textContent, href: a.href}))
|
||||
- Extract all text: document.body.innerText
|
||||
- Modify page: document.body.style.backgroundColor = 'lightblue'
|
||||
- Read page data: window.myAppData
|
||||
- Get cookies: document.cookie
|
||||
- Execute page functions: window.myPageFunction()
|
||||
- Access React/Vue instances: window.__REACT_DEVTOOLS_GLOBAL_HOOK__, window.$vm
|
||||
|
||||
Note: This requires the activeTab permission and only works on http/https pages, not on chrome:// URLs.`,
|
||||
parameters: browserJavaScriptSchema,
|
||||
execute: async (_toolCallId: string, args: Static<typeof browserJavaScriptSchema>, _signal?: AbortSignal) => {
|
||||
try {
|
||||
// Get the active tab
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab || !tab.id) {
|
||||
return {
|
||||
output: "Error: No active tab found",
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we can execute scripts on this tab
|
||||
if (tab.url?.startsWith("chrome://") || tab.url?.startsWith("chrome-extension://")) {
|
||||
return {
|
||||
output: `Error: Cannot execute scripts on ${tab.url}. Chrome extension pages and chrome:// URLs are protected.`,
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
// Execute the JavaScript in the tab context using MAIN world to bypass CSP
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN", // Execute in page context, bypasses CSP
|
||||
func: (code: string) => {
|
||||
return new Promise((resolve) => {
|
||||
// Capture console output
|
||||
const consoleOutput: Array<{ type: string; args: unknown[] }> = [];
|
||||
const files: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }> = [];
|
||||
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
|
||||
// Override console methods to capture output
|
||||
console.log = (...args: unknown[]) => {
|
||||
consoleOutput.push({ type: "log", args });
|
||||
originalConsole.log(...args);
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
consoleOutput.push({ type: "warn", args });
|
||||
originalConsole.warn(...args);
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
consoleOutput.push({ type: "error", args });
|
||||
originalConsole.error(...args);
|
||||
};
|
||||
|
||||
// Create returnFile function
|
||||
(window as any).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,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Wrap code in async function to support await
|
||||
const asyncCode = `(async () => { ${code} })()`;
|
||||
// biome-ignore lint/security/noGlobalEval: needed
|
||||
const resultPromise = eval(asyncCode);
|
||||
|
||||
// Wait for async code to complete
|
||||
Promise.resolve(resultPromise)
|
||||
.then(() => {
|
||||
// Restore console
|
||||
console.log = originalConsole.log;
|
||||
console.warn = originalConsole.warn;
|
||||
console.error = originalConsole.error;
|
||||
|
||||
// Clean up returnFile
|
||||
delete (window as any).returnFile;
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
console: consoleOutput,
|
||||
files: files,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// Restore console
|
||||
console.log = originalConsole.log;
|
||||
console.warn = originalConsole.warn;
|
||||
console.error = originalConsole.error;
|
||||
|
||||
// Clean up returnFile
|
||||
delete (window as any).returnFile;
|
||||
|
||||
const err = error as Error;
|
||||
resolve({
|
||||
success: false,
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
console: consoleOutput,
|
||||
});
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Restore console
|
||||
console.log = originalConsole.log;
|
||||
console.warn = originalConsole.warn;
|
||||
console.error = originalConsole.error;
|
||||
|
||||
// Clean up returnFile
|
||||
delete (window as any).returnFile;
|
||||
|
||||
const err = error as Error;
|
||||
resolve({
|
||||
success: false,
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
console: consoleOutput,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
args: [args.code],
|
||||
});
|
||||
|
||||
const result = results[0]?.result as
|
||||
| {
|
||||
success: boolean;
|
||||
console?: Array<{ type: string; args: unknown[] }>;
|
||||
files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
|
||||
error?: string;
|
||||
stack?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
output: "Error: No result returned from script execution",
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
// Build error output with console logs if any
|
||||
let errorOutput = `Error: ${result.error}\n\nStack trace:\n${result.stack || "No stack trace available"}`;
|
||||
|
||||
if (result.console && result.console.length > 0) {
|
||||
errorOutput += "\n\nConsole output:\n";
|
||||
for (const entry of result.console) {
|
||||
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "[LOG]";
|
||||
const line = `${prefix} ${entry.args.join(" ")}`;
|
||||
errorOutput += line + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output: errorOutput,
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
// Build output with console logs
|
||||
let output = "";
|
||||
|
||||
// Add console output
|
||||
if (result.console && result.console.length > 0) {
|
||||
for (const entry of result.console) {
|
||||
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
|
||||
const line = prefix ? `${prefix} ${entry.args.join(" ")}` : entry.args.join(" ");
|
||||
output += line + "\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`;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert files to base64 for transport
|
||||
const files = (result.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 {
|
||||
output: output.trim() || "Code executed successfully (no output)",
|
||||
isError: false,
|
||||
details: { files },
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return {
|
||||
output: `Error executing script: ${err.message}`,
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Browser JavaScript renderer
|
||||
interface BrowserJavaScriptParams {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface BrowserJavaScriptResult {
|
||||
files?: Array<{
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
contentBase64: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const browserJavaScriptRenderer: ToolRenderer<BrowserJavaScriptParams, BrowserJavaScriptResult> = {
|
||||
renderParams(params: BrowserJavaScriptParams, 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">Executing in active tab</div>
|
||||
<code-block .code=${params.code || ""} language="javascript"></code-block>
|
||||
`;
|
||||
},
|
||||
|
||||
renderResult(_params: BrowserJavaScriptParams, result: ToolResultMessage<BrowserJavaScriptResult>): TemplateResult {
|
||||
const output = result.output || "";
|
||||
const files = result.details?.files || [];
|
||||
const isError = result.isError === true;
|
||||
|
||||
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: `browser-js-${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,
|
||||
};
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return html`
|
||||
<div class="text-sm">
|
||||
<div class="text-destructive font-medium mb-1">Execution failed:</div>
|
||||
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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(browserJavaScriptTool.name, browserJavaScriptRenderer);
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
|
||||
import { BashRenderer } from "./renderers/BashRenderer.js";
|
||||
import { CalculateRenderer } from "./renderers/CalculateRenderer.js";
|
||||
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
|
||||
import { GetCurrentTimeRenderer } from "./renderers/GetCurrentTimeRenderer.js";
|
||||
import "./javascript-repl.js"; // Import for side effects (registers renderer)
|
||||
import "./browser-javascript.js"; // Import for side effects (registers renderer)
|
||||
|
||||
// Register all built-in tool renderers
|
||||
registerToolRenderer("calculate", new CalculateRenderer());
|
||||
|
|
@ -36,3 +38,5 @@ export function renderToolResult(toolName: string, params: any, result: ToolResu
|
|||
}
|
||||
|
||||
export { registerToolRenderer, getToolRenderer };
|
||||
export { browserJavaScriptTool } from "./browser-javascript.js";
|
||||
export { createJavaScriptReplTool, javascriptReplTool } from "./javascript-repl.js";
|
||||
|
|
|
|||
429
packages/browser-extension/src/tools/javascript-repl.ts
Normal file
429
packages/browser-extension/src/tools/javascript-repl.ts
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
|
||||
import { registerToolRenderer } from "./renderer-registry.js";
|
||||
import type { ToolRenderer } from "./types.js";
|
||||
import "../ConsoleBlock.js"; // Ensure console-block is registered
|
||||
|
||||
// Core JavaScript REPL execution logic without UI dependencies
|
||||
export interface ReplExecuteResult {
|
||||
success: boolean;
|
||||
console?: Array<{ type: string; args: any[] }>;
|
||||
files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
|
||||
error?: { message: string; stack: string };
|
||||
}
|
||||
|
||||
export class ReplExecutor {
|
||||
private iframe: HTMLIFrameElement;
|
||||
private ready: boolean = false;
|
||||
private attachments: any[] = [];
|
||||
// biome-ignore lint/complexity/noBannedTypes: fine here
|
||||
private currentExecution: { resolve: Function; reject: Function } | null = null;
|
||||
|
||||
constructor(attachments: any[]) {
|
||||
this.attachments = attachments;
|
||||
this.iframe = this.createIframe();
|
||||
this.setupMessageHandler();
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private createIframe(): HTMLIFrameElement {
|
||||
const iframe = document.createElement("iframe");
|
||||
// Use the sandboxed page from the manifest
|
||||
iframe.src = chrome.runtime.getURL("sandbox.html");
|
||||
iframe.style.display = "none";
|
||||
document.body.appendChild(iframe);
|
||||
return iframe;
|
||||
}
|
||||
|
||||
private setupMessageHandler() {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.source !== this.iframe.contentWindow) return;
|
||||
|
||||
if (event.data.type === "ready") {
|
||||
this.ready = true;
|
||||
} else if (event.data.type === "result" && this.currentExecution) {
|
||||
const { resolve } = this.currentExecution;
|
||||
this.currentExecution = null;
|
||||
resolve(event.data);
|
||||
this.cleanup();
|
||||
} else if (event.data.type === "error" && this.currentExecution) {
|
||||
const { resolve } = this.currentExecution;
|
||||
this.currentExecution = null;
|
||||
resolve({
|
||||
success: false,
|
||||
error: event.data.error,
|
||||
console: event.data.console || [],
|
||||
});
|
||||
this.cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handler);
|
||||
// Store handler reference for cleanup
|
||||
(this.iframe as any).__messageHandler = handler;
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
// Send attachments once iframe is loaded
|
||||
this.iframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
this.iframe.contentWindow?.postMessage(
|
||||
{
|
||||
type: "setAttachments",
|
||||
attachments: this.attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Remove message handler
|
||||
const handler = (this.iframe as any).__messageHandler;
|
||||
if (handler) {
|
||||
window.removeEventListener("message", handler);
|
||||
}
|
||||
// Remove iframe
|
||||
this.iframe.remove();
|
||||
|
||||
// If there's a pending execution, reject it
|
||||
if (this.currentExecution) {
|
||||
this.currentExecution.reject(new Error("Execution aborted"));
|
||||
this.currentExecution = null;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(code: string): Promise<ReplExecuteResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.currentExecution = { resolve, reject };
|
||||
|
||||
// Wait for iframe to be ready
|
||||
const checkReady = () => {
|
||||
if (this.ready) {
|
||||
this.iframe.contentWindow?.postMessage(
|
||||
{
|
||||
type: "execute",
|
||||
code: code,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else {
|
||||
setTimeout(checkReady, 10);
|
||||
}
|
||||
};
|
||||
checkReady();
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.currentExecution?.resolve === resolve) {
|
||||
this.currentExecution = null;
|
||||
resolve({
|
||||
success: false,
|
||||
error: { message: "Execution timeout (30s)", stack: "" },
|
||||
});
|
||||
this.cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute JavaScript code with attachments
|
||||
export async function executeJavaScript(
|
||||
code: string,
|
||||
attachments: any[] = [],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ output: string; files?: Array<{ fileName: string; content: any; mimeType: string }> }> {
|
||||
if (!code) {
|
||||
throw new Error("Code parameter is required");
|
||||
}
|
||||
|
||||
// Check for abort before starting
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Execution aborted");
|
||||
}
|
||||
|
||||
// Create a one-shot executor
|
||||
const executor = new ReplExecutor(attachments);
|
||||
|
||||
// Listen for abort signal
|
||||
const abortHandler = () => {
|
||||
executor.cleanup();
|
||||
};
|
||||
signal?.addEventListener("abort", abortHandler);
|
||||
|
||||
try {
|
||||
const result = await executor.execute(code);
|
||||
|
||||
// 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
|
||||
if (result.console && result.console.length > 0) {
|
||||
for (const entry of result.console) {
|
||||
const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
|
||||
const line = prefix ? `${prefix} ${entry.args.join(" ")}` : entry.args.join(" ");
|
||||
output += line + "\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) {
|
||||
throw new Error(error.message || "Execution failed");
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
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">${"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);
|
||||
|
|
@ -68,6 +68,16 @@ declare module "@mariozechner/mini-lit" {
|
|||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +152,16 @@ const translations = {
|
|||
"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",
|
||||
},
|
||||
de: {
|
||||
...defaultGerman,
|
||||
|
|
@ -213,6 +233,16 @@ const translations = {
|
|||
"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",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue