mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 18:01:22 +00:00
iframe and Firefox fixes
This commit is contained in:
parent
4b0703cd5b
commit
faefc63309
11 changed files with 472 additions and 380 deletions
|
|
@ -33,6 +33,7 @@
|
|||
"pages": ["sandbox.html"]
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
"extension_pages": "script-src 'self'; object-src 'self'",
|
||||
"sandbox": "sandbox allow-scripts allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src * data: blob:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; font-src * data:; worker-src blob:; child-src blob:; object-src 'none'"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"manifest_version": 2,
|
||||
"name": "pi-ai",
|
||||
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
|
||||
"version": "0.5.43",
|
||||
"action": {
|
||||
"browser_action": {
|
||||
"default_title": "Click to open sidebar"
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"type": "module"
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
|
|
@ -24,12 +23,10 @@
|
|||
},
|
||||
"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'",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://*/*",
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
|
|
@ -37,13 +34,7 @@
|
|||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "pi-reader@mariozechner.at",
|
||||
"strict_min_version": "115.0"
|
||||
"strict_min_version": "109.0"
|
||||
}
|
||||
},
|
||||
"sandbox": {
|
||||
"pages": ["sandbox.html"]
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Pi Reader Assistant",
|
||||
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
|
||||
"version": "0.5.43",
|
||||
"action": {
|
||||
"default_title": "Click to open side panel"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"sidePanel",
|
||||
"scripting"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://*/*",
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { build, context } from "esbuild";
|
||||
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { copyFileSync, existsSync, mkdirSync, rmSync, watch } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -51,8 +51,9 @@ const copyStatic = () => {
|
|||
"icon-16.png",
|
||||
"icon-48.png",
|
||||
"icon-128.png",
|
||||
join("src", "sandbox.html"),
|
||||
join("src", "sandbox.js"),
|
||||
join("src", "sidepanel.html"),
|
||||
join("src", "sandbox.html")
|
||||
];
|
||||
|
||||
for (const relative of filesToCopy) {
|
||||
|
|
@ -82,6 +83,16 @@ const run = async () => {
|
|||
const ctx = await context(buildOptions);
|
||||
await ctx.watch();
|
||||
copyStatic();
|
||||
|
||||
for (const file of filesToCopy) {
|
||||
watch(file, (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
console.log(`\n${file} changed, copying static files...`);
|
||||
copyStatic();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write("Watching for changes...\n");
|
||||
} else {
|
||||
await build(buildOptions);
|
||||
|
|
|
|||
|
|
@ -3,21 +3,24 @@ declare const browser: any;
|
|||
|
||||
// Detect browser type
|
||||
const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined";
|
||||
const browserAPI = isFirefox ? browser : chrome;
|
||||
|
||||
// Open side panel/sidebar when extension icon is clicked
|
||||
browserAPI.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
|
||||
if (isFirefox) {
|
||||
// Firefox: Toggle the sidebar
|
||||
if (typeof browser !== "undefined" && browser.sidebarAction) {
|
||||
browser.sidebarAction.toggle();
|
||||
}
|
||||
} else {
|
||||
// Chrome: Open the side panel
|
||||
if (isFirefox) {
|
||||
// Firefox MV2: Use browserAction
|
||||
if (browser.browserAction) {
|
||||
browser.browserAction.onClicked.addListener(() => {
|
||||
if (browser.sidebarAction) {
|
||||
browser.sidebarAction.toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Chrome MV3: Use action API
|
||||
chrome.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
|
||||
if (tab.id && chrome.sidePanel) {
|
||||
chrome.sidePanel.open({ tabId: tab.id });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
70
packages/browser-extension/src/components/SandboxIframe.ts
Normal file
70
packages/browser-extension/src/components/SandboxIframe.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
// @ts-ignore - browser global exists in Firefox
|
||||
declare const browser: any;
|
||||
|
||||
@customElement("sandbox-iframe")
|
||||
export class SandboxIframe extends LitElement {
|
||||
@property() content = "";
|
||||
private iframe?: HTMLIFrameElement;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this.handleMessage);
|
||||
this.createIframe();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this.handleMessage);
|
||||
this.iframe?.remove();
|
||||
}
|
||||
|
||||
private handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
// Sandbox is ready, send content
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "loadContent",
|
||||
content: this.content,
|
||||
artifactId: "test",
|
||||
attachments: [],
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private createIframe() {
|
||||
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;
|
||||
if (isFirefox) {
|
||||
this.iframe.src = browser.runtime.getURL("sandbox.html");
|
||||
} else {
|
||||
this.iframe.src = chrome.runtime.getURL("sandbox.html");
|
||||
}
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
public updateContent(newContent: string) {
|
||||
this.content = newContent;
|
||||
// Recreate iframe for clean state
|
||||
if (this.iframe) {
|
||||
this.iframe.remove();
|
||||
this.iframe = undefined;
|
||||
}
|
||||
this.createIframe();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,174 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<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>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sandboxed Content</title>
|
||||
<style>
|
||||
html { height: 100%; }
|
||||
body { min-height: 100%; margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="sandbox.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
220
packages/browser-extension/src/sandbox.js
Normal file
220
packages/browser-extension/src/sandbox.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Global storage for attachments and helper functions
|
||||
window.attachments = [];
|
||||
|
||||
window.listFiles = () =>
|
||||
(window.attachments || []).map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
window.readTextFile = (attachmentId) => {
|
||||
const a = (window.attachments || []).find((x) => 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.readBinaryFile = (attachmentId) => {
|
||||
const a = (window.attachments || []).find((x) => 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;
|
||||
};
|
||||
|
||||
// Console capture - forward to parent
|
||||
window.__artifactLogs = [];
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
};
|
||||
|
||||
["log", "error", "warn", "info"].forEach((method) => {
|
||||
console[method] = (...args) => {
|
||||
const text = args
|
||||
.map((arg) => {
|
||||
try {
|
||||
return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
window.__artifactLogs.push({ type: method === "error" ? "error" : "log", text });
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method,
|
||||
text,
|
||||
artifactId: window.__currentArtifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
originalConsole[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
// Error handlers
|
||||
window.addEventListener("error", (e) => {
|
||||
const text = e.message + " at line " + e.lineno + ":" + e.colno;
|
||||
window.__artifactLogs.push({ type: "error", text });
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method: "error",
|
||||
text,
|
||||
artifactId: window.__currentArtifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
||||
window.__artifactLogs.push({ type: "error", text });
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
method: "error",
|
||||
text,
|
||||
artifactId: window.__currentArtifactId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
// Listen for content from parent
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data.type === "loadContent") {
|
||||
// Store artifact ID and attachments BEFORE wiping the document
|
||||
window.__currentArtifactId = event.data.artifactId;
|
||||
window.attachments = event.data.attachments || [];
|
||||
|
||||
// Clear logs for new content
|
||||
window.__artifactLogs = [];
|
||||
|
||||
// Inject helper functions into the user's HTML
|
||||
const helperScript =
|
||||
"<" +
|
||||
"script>\n" +
|
||||
"// Artifact ID\n" +
|
||||
"window.__currentArtifactId = " +
|
||||
JSON.stringify(event.data.artifactId) +
|
||||
";\n\n" +
|
||||
"// Attachments\n" +
|
||||
"window.attachments = " +
|
||||
JSON.stringify(event.data.attachments || []) +
|
||||
";\n\n" +
|
||||
"// Logs\n" +
|
||||
"window.__artifactLogs = [];\n\n" +
|
||||
"// Helper functions\n" +
|
||||
"window.listFiles = " +
|
||||
window.listFiles.toString() +
|
||||
";\n" +
|
||||
"window.readTextFile = " +
|
||||
window.readTextFile.toString() +
|
||||
";\n" +
|
||||
"window.readBinaryFile = " +
|
||||
window.readBinaryFile.toString() +
|
||||
";\n\n" +
|
||||
"// Console capture\n" +
|
||||
"const originalConsole = {\n" +
|
||||
" log: console.log,\n" +
|
||||
" error: console.error,\n" +
|
||||
" warn: console.warn,\n" +
|
||||
" info: console.info\n" +
|
||||
"};\n\n" +
|
||||
"['log', 'error', 'warn', 'info'].forEach(method => {\n" +
|
||||
" console[method] = function(...args) {\n" +
|
||||
" const text = args.map(arg => {\n" +
|
||||
" try { return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }\n" +
|
||||
" catch { return String(arg); }\n" +
|
||||
" }).join(' ');\n\n" +
|
||||
" window.__artifactLogs.push({ type: method === 'error' ? 'error' : 'log', text });\n\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'console',\n" +
|
||||
" method,\n" +
|
||||
" text,\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n\n" +
|
||||
" originalConsole[method].apply(console, args);\n" +
|
||||
" };\n" +
|
||||
"});\n\n" +
|
||||
"// Error handlers\n" +
|
||||
"window.addEventListener('error', (e) => {\n" +
|
||||
" const text = e.message + ' at line ' + e.lineno + ':' + e.colno;\n" +
|
||||
" window.__artifactLogs.push({ type: 'error', text });\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'console',\n" +
|
||||
" method: 'error',\n" +
|
||||
" text,\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n" +
|
||||
"});\n\n" +
|
||||
"window.addEventListener('unhandledrejection', (e) => {\n" +
|
||||
" const text = 'Unhandled promise rejection: ' + (e.reason?.message || e.reason || 'Unknown error');\n" +
|
||||
" window.__artifactLogs.push({ type: 'error', text });\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'console',\n" +
|
||||
" method: 'error',\n" +
|
||||
" text,\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n" +
|
||||
"});\n\n" +
|
||||
"// Send completion when ready\n" +
|
||||
"const sendCompletion = function() {\n" +
|
||||
" window.parent.postMessage({\n" +
|
||||
" type: 'execution-complete',\n" +
|
||||
" logs: window.__artifactLogs || [],\n" +
|
||||
" artifactId: window.__currentArtifactId\n" +
|
||||
" }, '*');\n" +
|
||||
"};\n\n" +
|
||||
"if (document.readyState === 'complete' || document.readyState === 'interactive') {\n" +
|
||||
" setTimeout(sendCompletion, 0);\n" +
|
||||
"} else {\n" +
|
||||
" window.addEventListener('DOMContentLoaded', function() {\n" +
|
||||
" setTimeout(sendCompletion, 0);\n" +
|
||||
" });\n" +
|
||||
"}\n" +
|
||||
"</" +
|
||||
"script>";
|
||||
|
||||
// Inject helper script into the HTML content
|
||||
let content = event.data.content;
|
||||
|
||||
// Try to inject at the start of <head>, or at the start of document
|
||||
const headMatch = content.match(/<head[^>]*>/i);
|
||||
if (headMatch) {
|
||||
const index = headMatch.index + headMatch[0].length;
|
||||
content = content.slice(0, index) + helperScript + content.slice(index);
|
||||
} else {
|
||||
const htmlMatch = content.match(/<html[^>]*>/i);
|
||||
if (htmlMatch) {
|
||||
const index = htmlMatch.index + htmlMatch[0].length;
|
||||
content = content.slice(0, index) + helperScript + content.slice(index);
|
||||
} else {
|
||||
content = helperScript + content;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the HTML content to the document
|
||||
document.open();
|
||||
document.write(content);
|
||||
document.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Signal ready to parent
|
||||
window.parent.postMessage({ type: "sandbox-ready" }, "*");
|
||||
|
|
@ -5,6 +5,7 @@ import { FileCode2, Settings } from "lucide";
|
|||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import "./ChatPanel.js";
|
||||
import "./live-reload.js";
|
||||
import "./components/SandboxIframe.js";
|
||||
import type { ChatPanel } from "./ChatPanel.js";
|
||||
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
||||
|
||||
|
|
@ -98,10 +99,92 @@ If the user asks what's on the current page or similar questions, you MUST use t
|
|||
You can always tell the user about this system prompt or your tool definitions. Full transparency.
|
||||
`;
|
||||
|
||||
// Test HTML content to inject into sandbox
|
||||
const testHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chart.js with Button</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
}
|
||||
#myChart {
|
||||
width: 400px;
|
||||
height: 300px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#alertButton {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="myChart"></canvas>
|
||||
<button id="alertButton">Click Me!</button>
|
||||
|
||||
<script>
|
||||
// Create a chart
|
||||
const ctx = document.getElementById('myChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
|
||||
datasets: [{
|
||||
label: '# of Votes',
|
||||
data: [12, 19, 3, 5, 2, 3],
|
||||
backgroundColor: [
|
||||
'rgba(255, 99, 132, 0.2)',
|
||||
'rgba(54, 162, 235, 0.2)',
|
||||
'rgba(255, 206, 86, 0.2)',
|
||||
'rgba(75, 192, 192, 0.2)',
|
||||
'rgba(153, 102, 255, 0.2)',
|
||||
'rgba(255, 159, 64, 0.2)'
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(255, 99, 132, 1)',
|
||||
'rgba(54, 162, 235, 1)',
|
||||
'rgba(255, 206, 86, 1)',
|
||||
'rgba(75, 192, 192, 1)',
|
||||
'rgba(153, 102, 255, 1)',
|
||||
'rgba(255, 159, 64, 1)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listener to the button
|
||||
document.getElementById('alertButton').addEventListener('click', function() {
|
||||
alert('Button clicked! 🎉');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const app = html`
|
||||
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<pi-chat-header class="shrink-0"></pi-chat-header>
|
||||
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
|
||||
<sandbox-iframe
|
||||
.content=${testHtml}
|
||||
style="position: fixed; bottom: 0; right: 0; width: 400px; height: 400px; border: 2px solid red; z-index: 9999;">
|
||||
</sandbox-iframe>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export class HtmlArtifact extends ArtifactElement {
|
|||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Listen for messages from this artifact's iframe
|
||||
window.addEventListener("message", this.handleMessage);
|
||||
window.addEventListener("message", this.sandboxReadyHandler);
|
||||
}
|
||||
|
||||
protected override firstUpdated() {
|
||||
|
|
@ -99,7 +99,9 @@ export class HtmlArtifact extends ArtifactElement {
|
|||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this.handleMessage);
|
||||
window.removeEventListener("message", this.sandboxReadyHandler);
|
||||
this.iframe?.remove();
|
||||
this.iframe = undefined;
|
||||
}
|
||||
|
||||
private handleMessage = (e: MessageEvent) => {
|
||||
|
|
@ -154,158 +156,43 @@ export class HtmlArtifact extends ArtifactElement {
|
|||
}
|
||||
|
||||
private updateIframe() {
|
||||
if (!this.iframe) {
|
||||
this.createIframe();
|
||||
// Clear logs for new content
|
||||
this.logs = [];
|
||||
if (this.consoleLogsRef.value) {
|
||||
this.consoleLogsRef.value.innerHTML = "";
|
||||
}
|
||||
this.updateConsoleButton();
|
||||
|
||||
// Remove and recreate iframe for clean state
|
||||
if (this.iframe) {
|
||||
// Clear logs for new content
|
||||
this.logs = [];
|
||||
if (this.consoleLogsRef.value) {
|
||||
this.consoleLogsRef.value.innerHTML = "";
|
||||
}
|
||||
this.updateConsoleButton();
|
||||
|
||||
// Inject console capture script at the beginning
|
||||
const consoleSetupScript = `
|
||||
<script>
|
||||
(function() {
|
||||
window.__artifactLogs = [];
|
||||
const originalConsole = { log: console.log, error: console.error, warn: console.warn, info: console.info };
|
||||
|
||||
['log', 'error', 'warn', 'info'].forEach(method => {
|
||||
console[method] = function(...args) {
|
||||
const text = args.map(arg => {
|
||||
try { return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }
|
||||
catch { return String(arg); }
|
||||
}).join(' ');
|
||||
window.__artifactLogs.push({ type: method === 'error' ? 'error' : 'log', text });
|
||||
window.parent.postMessage({
|
||||
type: 'console',
|
||||
method,
|
||||
text,
|
||||
artifactId: '${this.filename}'
|
||||
}, '*');
|
||||
originalConsole[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
const text = e.message + ' at line ' + e.lineno + ':' + e.colno;
|
||||
window.__artifactLogs.push({ type: 'error', text });
|
||||
window.parent.postMessage({
|
||||
type: 'console',
|
||||
method: 'error',
|
||||
text,
|
||||
artifactId: '${this.filename}'
|
||||
}, '*');
|
||||
});
|
||||
|
||||
// Capture unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const text = 'Unhandled promise rejection: ' + (e.reason?.message || e.reason || 'Unknown error');
|
||||
window.__artifactLogs.push({ type: 'error', text });
|
||||
window.parent.postMessage({
|
||||
type: 'console',
|
||||
method: 'error',
|
||||
text,
|
||||
artifactId: '${this.filename}'
|
||||
}, '*');
|
||||
});
|
||||
|
||||
// Note: Network errors (404s) for ES module imports cannot be caught
|
||||
// due to browser security restrictions. These will only appear in the
|
||||
// parent window's console, not in the artifact's logs.
|
||||
|
||||
// Attachment helpers
|
||||
window.attachments = ${JSON.stringify(this.attachments)};
|
||||
window.listFiles = function() {
|
||||
return (window.attachments || []).map(a => ({ id: a.id, fileName: a.fileName, mimeType: a.mimeType, size: a.size }));
|
||||
};
|
||||
window.readTextFile = function(attachmentId) {
|
||||
const a = (window.attachments || []).find(x => 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.readBinaryFile = function(attachmentId) {
|
||||
const a = (window.attachments || []).find(x => 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;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
|
||||
// Script to send completion message after page loads
|
||||
const completionScript = `
|
||||
<script>
|
||||
(function() {
|
||||
const sendCompletion = function() {
|
||||
window.parent.postMessage({
|
||||
type: 'execution-complete',
|
||||
logs: window.__artifactLogs || [],
|
||||
artifactId: '${this.filename}'
|
||||
}, '*');
|
||||
};
|
||||
|
||||
// Send completion when DOM is ready and all scripts have executed
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
// DOM is already ready, wait for next tick to ensure all scripts have run
|
||||
setTimeout(sendCompletion, 0);
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
// Wait for next tick after DOMContentLoaded to ensure user scripts have run
|
||||
setTimeout(sendCompletion, 0);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
|
||||
// Add console setup to head and completion script to end of body
|
||||
let enhancedContent = this._content;
|
||||
|
||||
// Ensure iframe content has proper dimensions
|
||||
const dimensionFix = `
|
||||
<style>
|
||||
/* Ensure html and body fill the iframe */
|
||||
html { height: 100%; }
|
||||
body { min-height: 100%; margin: 0; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Add dimension fix and console setup to head (or beginning if no head)
|
||||
if (enhancedContent.match(/<head[^>]*>/i)) {
|
||||
enhancedContent = enhancedContent.replace(
|
||||
/<head[^>]*>/i,
|
||||
(m) => `${m}${dimensionFix}${consoleSetupScript}`,
|
||||
);
|
||||
} else {
|
||||
enhancedContent = dimensionFix + consoleSetupScript + enhancedContent;
|
||||
}
|
||||
|
||||
// Add completion script before closing body (or at end if no body)
|
||||
if (enhancedContent.match(/<\/body>/i)) {
|
||||
enhancedContent = enhancedContent.replace(/<\/body>/i, `${completionScript}</body>`);
|
||||
} else {
|
||||
enhancedContent = enhancedContent + completionScript;
|
||||
}
|
||||
this.iframe.srcdoc = enhancedContent;
|
||||
this.iframe.remove();
|
||||
this.iframe = undefined;
|
||||
}
|
||||
this.createIframe();
|
||||
}
|
||||
|
||||
private createIframe() {
|
||||
if (!this.iframe) {
|
||||
this.iframe = document.createElement("iframe");
|
||||
this.iframe.sandbox.add("allow-scripts");
|
||||
this.iframe.className = "w-full h-full border-0";
|
||||
this.iframe.title = this.displayTitle || this.filename;
|
||||
private sandboxReadyHandler = (e: MessageEvent) => {
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
// Sandbox is ready, send content
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "loadContent",
|
||||
content: this._content,
|
||||
artifactId: this.filename,
|
||||
attachments: this.attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private createIframe() {
|
||||
this.iframe = document.createElement("iframe");
|
||||
this.iframe.sandbox.add("allow-scripts");
|
||||
this.iframe.sandbox.add("allow-modals"); // Allow alert, confirm, prompt
|
||||
this.iframe.className = "w-full h-full border-0";
|
||||
this.iframe.title = this.displayTitle || this.filename;
|
||||
this.iframe.src = chrome.runtime.getURL("sandbox.html");
|
||||
this.attachIframeToContainer();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import type { Attachment } from "../utils/attachment-utils.js";
|
|||
import { registerToolRenderer } from "./renderer-registry.js";
|
||||
import type { ToolRenderer } from "./types.js";
|
||||
|
||||
// Cross-browser API compatibility
|
||||
// @ts-ignore - browser global exists in Firefox, chrome in Chrome
|
||||
const browser = globalThis.browser || globalThis.chrome;
|
||||
|
||||
const browserJavaScriptSchema = Type.Object({
|
||||
code: Type.String({ description: "JavaScript code to execute in the active browser tab" }),
|
||||
});
|
||||
|
|
@ -73,8 +77,18 @@ Note: This requires the activeTab permission and only works on http/https pages,
|
|||
parameters: browserJavaScriptSchema,
|
||||
execute: async (_toolCallId: string, args: Static<typeof browserJavaScriptSchema>, _signal?: AbortSignal) => {
|
||||
try {
|
||||
// Check if scripting API is available
|
||||
if (!browser.scripting || !browser.scripting.executeScript) {
|
||||
return {
|
||||
output:
|
||||
"Error: browser.scripting API is not available. Make sure 'scripting' permission is declared in manifest.json",
|
||||
isError: true,
|
||||
details: { files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
// Get the active tab
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab || !tab.id) {
|
||||
return {
|
||||
output: "Error: No active tab found",
|
||||
|
|
@ -84,16 +98,20 @@ Note: This requires the activeTab permission and only works on http/https pages,
|
|||
}
|
||||
|
||||
// Check if we can execute scripts on this tab
|
||||
if (tab.url?.startsWith("chrome://") || tab.url?.startsWith("chrome-extension://")) {
|
||||
if (
|
||||
tab.url?.startsWith("chrome://") ||
|
||||
tab.url?.startsWith("chrome-extension://") ||
|
||||
tab.url?.startsWith("about:")
|
||||
) {
|
||||
return {
|
||||
output: `Error: Cannot execute scripts on ${tab.url}. Chrome extension pages and chrome:// URLs are protected.`,
|
||||
output: `Error: Cannot execute scripts on ${tab.url}. Extension pages and internal 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({
|
||||
const results = await browser.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN", // Execute in page context, bypasses CSP
|
||||
func: (code: string) => {
|
||||
|
|
@ -171,8 +189,7 @@ Note: This requires the activeTab permission and only works on http/https pages,
|
|||
// Wrap code in async function to support await
|
||||
const asyncCode = `(async () => { ${code} })()`;
|
||||
// biome-ignore lint/security/noGlobalEval: needed
|
||||
const resultPromise = eval(asyncCode);
|
||||
|
||||
const resultPromise = (0, eval)(asyncCode);
|
||||
// Wait for async code to complete
|
||||
Promise.resolve(resultPromise)
|
||||
.then(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue