mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
Fix various sandbox issues.
This commit is contained in:
parent
91c1dc6475
commit
0eaa879d46
10 changed files with 673 additions and 431 deletions
|
|
@ -1,12 +1,15 @@
|
||||||
import { Badge, html } from "@mariozechner/mini-lit";
|
import { Badge, html } from "@mariozechner/mini-lit";
|
||||||
import { LitElement } from "lit";
|
import { LitElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { AgentInterface } from "./components/AgentInterface.js";
|
|
||||||
import "./components/AgentInterface.js";
|
|
||||||
import type { Agent } from "./agent/agent.js";
|
import type { Agent } from "./agent/agent.js";
|
||||||
|
import "./components/AgentInterface.js";
|
||||||
|
import type { AgentInterface } from "./components/AgentInterface.js";
|
||||||
|
import { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
|
||||||
|
import type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
|
||||||
import { ArtifactsPanel, ArtifactsToolRenderer } from "./tools/artifacts/index.js";
|
import { ArtifactsPanel, ArtifactsToolRenderer } from "./tools/artifacts/index.js";
|
||||||
import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
|
import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
|
||||||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
||||||
|
import type { Attachment } from "./utils/attachment-utils.js";
|
||||||
import { i18n } from "./utils/i18n.js";
|
import { i18n } from "./utils/i18n.js";
|
||||||
|
|
||||||
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
|
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
|
||||||
|
|
@ -20,6 +23,21 @@ export class ChatPanel extends LitElement {
|
||||||
@state() private artifactCount = 0;
|
@state() private artifactCount = 0;
|
||||||
@state() private showArtifactsPanel = false;
|
@state() private showArtifactsPanel = false;
|
||||||
@state() private windowWidth = 0;
|
@state() private windowWidth = 0;
|
||||||
|
@property({ attribute: false }) runtimeProvidersFactory = () => {
|
||||||
|
const attachments: Attachment[] = [];
|
||||||
|
for (const message of this.agent!.state.messages) {
|
||||||
|
if (message.role === "user") {
|
||||||
|
message.attachments?.forEach((a) => {
|
||||||
|
attachments.push(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const providers: SandboxRuntimeProvider[] = [];
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
providers.push(new AttachmentsRuntimeProvider(attachments));
|
||||||
|
}
|
||||||
|
return providers;
|
||||||
|
};
|
||||||
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
||||||
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
|
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
|
||||||
@property({ attribute: false }) onBeforeSend?: () => void | Promise<void>;
|
@property({ attribute: false }) onBeforeSend?: () => void | Promise<void>;
|
||||||
|
|
@ -81,30 +99,9 @@ export class ChatPanel extends LitElement {
|
||||||
// Register the standalone tool renderer (not the panel itself)
|
// Register the standalone tool renderer (not the panel itself)
|
||||||
registerToolRenderer("artifacts", new ArtifactsToolRenderer(this.artifactsPanel));
|
registerToolRenderer("artifacts", new ArtifactsToolRenderer(this.artifactsPanel));
|
||||||
|
|
||||||
// Attachments provider
|
// Runtime providers factory
|
||||||
const getAttachments = () => {
|
javascriptReplTool.runtimeProvidersFactory = this.runtimeProvidersFactory;
|
||||||
const attachments: any[] = [];
|
this.artifactsPanel.runtimeProvidersFactory = this.runtimeProvidersFactory;
|
||||||
for (const message of this.agent!.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 = () => {
|
this.artifactsPanel.onArtifactsChange = () => {
|
||||||
const count = this.artifactsPanel?.artifacts?.size ?? 0;
|
const count = this.artifactsPanel?.artifacts?.size ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { LitElement } from "lit";
|
import { LitElement } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
|
||||||
|
import { type MessageConsumer, SANDBOX_MESSAGE_ROUTER } from "./sandbox/SandboxMessageRouter.js";
|
||||||
|
import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
|
||||||
|
|
||||||
export interface SandboxFile {
|
export interface SandboxFile {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|
@ -42,6 +44,9 @@ export class SandboxIframe extends LitElement {
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
|
// Note: We don't unregister the sandbox here for loadContent() mode
|
||||||
|
// because the caller (HtmlArtifact) owns the sandbox lifecycle.
|
||||||
|
// For execute() mode, the sandbox is unregistered in the cleanup function.
|
||||||
this.iframe?.remove();
|
this.iframe?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,65 +54,88 @@ export class SandboxIframe extends LitElement {
|
||||||
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
|
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
|
||||||
* @param sandboxId Unique ID
|
* @param sandboxId Unique ID
|
||||||
* @param htmlContent Full HTML content
|
* @param htmlContent Full HTML content
|
||||||
* @param attachments Attachments available
|
* @param providers Runtime providers to inject
|
||||||
|
* @param consumers Message consumers to register (optional)
|
||||||
*/
|
*/
|
||||||
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
|
public loadContent(
|
||||||
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
|
sandboxId: string,
|
||||||
|
htmlContent: string,
|
||||||
|
providers: SandboxRuntimeProvider[] = [],
|
||||||
|
consumers: MessageConsumer[] = [],
|
||||||
|
): void {
|
||||||
|
// Unregister previous sandbox if exists
|
||||||
|
try {
|
||||||
|
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||||
|
} catch {
|
||||||
|
// Sandbox might not exist, that's ok
|
||||||
|
}
|
||||||
|
|
||||||
|
providers = [new ConsoleRuntimeProvider(), ...providers];
|
||||||
|
|
||||||
|
SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
|
||||||
|
|
||||||
|
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers);
|
||||||
|
|
||||||
|
// Remove previous iframe if exists
|
||||||
|
this.iframe?.remove();
|
||||||
|
|
||||||
if (this.sandboxUrlProvider) {
|
if (this.sandboxUrlProvider) {
|
||||||
// Browser extension mode: use sandbox.html with postMessage
|
// Browser extension mode: use sandbox.html with postMessage
|
||||||
this.loadViaSandboxUrl(sandboxId, completeHtml, attachments);
|
this.loadViaSandboxUrl(sandboxId, completeHtml);
|
||||||
} else {
|
} else {
|
||||||
// Web mode: use srcdoc
|
// Web mode: use srcdoc
|
||||||
this.loadViaSrcdoc(completeHtml);
|
this.loadViaSrcdoc(sandboxId, completeHtml);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadViaSandboxUrl(sandboxId: string, completeHtml: string, attachments: Attachment[]): void {
|
private loadViaSandboxUrl(sandboxId: string, completeHtml: string): void {
|
||||||
// Wait for sandbox-ready and send content
|
// Create iframe pointing to sandbox URL
|
||||||
|
this.iframe = document.createElement("iframe");
|
||||||
|
this.iframe.sandbox.add("allow-scripts");
|
||||||
|
this.iframe.sandbox.add("allow-modals");
|
||||||
|
this.iframe.style.width = "100%";
|
||||||
|
this.iframe.style.height = "100%";
|
||||||
|
this.iframe.style.border = "none";
|
||||||
|
this.iframe.src = this.sandboxUrlProvider!();
|
||||||
|
|
||||||
|
// Update router with iframe reference BEFORE appending to DOM
|
||||||
|
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||||
|
|
||||||
|
// Listen for sandbox-ready message directly
|
||||||
const readyHandler = (e: MessageEvent) => {
|
const readyHandler = (e: MessageEvent) => {
|
||||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||||
window.removeEventListener("message", readyHandler);
|
window.removeEventListener("message", readyHandler);
|
||||||
|
|
||||||
|
// Send content to sandbox
|
||||||
this.iframe?.contentWindow?.postMessage(
|
this.iframe?.contentWindow?.postMessage(
|
||||||
{
|
{
|
||||||
type: "sandbox-load",
|
type: "sandbox-load",
|
||||||
sandboxId,
|
sandboxId,
|
||||||
code: completeHtml,
|
code: completeHtml,
|
||||||
attachments,
|
|
||||||
},
|
},
|
||||||
"*",
|
"*",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("message", readyHandler);
|
window.addEventListener("message", readyHandler);
|
||||||
|
|
||||||
// Always recreate iframe to ensure fresh sandbox and sandbox-ready message
|
|
||||||
this.iframe?.remove();
|
|
||||||
this.iframe = document.createElement("iframe");
|
|
||||||
this.iframe.sandbox.add("allow-scripts");
|
|
||||||
this.iframe.sandbox.add("allow-modals");
|
|
||||||
this.iframe.style.width = "100%";
|
|
||||||
this.iframe.style.height = "100%";
|
|
||||||
this.iframe.style.border = "none";
|
|
||||||
|
|
||||||
this.iframe.src = this.sandboxUrlProvider!();
|
|
||||||
|
|
||||||
this.appendChild(this.iframe);
|
this.appendChild(this.iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadViaSrcdoc(completeHtml: string): void {
|
private loadViaSrcdoc(sandboxId: string, completeHtml: string): void {
|
||||||
// Always recreate iframe to ensure fresh sandbox
|
// Create iframe with srcdoc
|
||||||
this.iframe?.remove();
|
|
||||||
this.iframe = document.createElement("iframe");
|
this.iframe = document.createElement("iframe");
|
||||||
this.iframe.sandbox.add("allow-scripts");
|
this.iframe.sandbox.add("allow-scripts");
|
||||||
this.iframe.sandbox.add("allow-modals");
|
this.iframe.sandbox.add("allow-modals");
|
||||||
this.iframe.style.width = "100%";
|
this.iframe.style.width = "100%";
|
||||||
this.iframe.style.height = "100%";
|
this.iframe.style.height = "100%";
|
||||||
this.iframe.style.border = "none";
|
this.iframe.style.border = "none";
|
||||||
|
|
||||||
// Set content directly via srcdoc (no CSP restrictions in web apps)
|
|
||||||
this.iframe.srcdoc = completeHtml;
|
this.iframe.srcdoc = completeHtml;
|
||||||
|
|
||||||
|
// Update router with iframe reference BEFORE appending to DOM
|
||||||
|
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||||
|
|
||||||
this.appendChild(this.iframe);
|
this.appendChild(this.iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,143 +143,141 @@ export class SandboxIframe extends LitElement {
|
||||||
* Execute code in sandbox
|
* Execute code in sandbox
|
||||||
* @param sandboxId Unique ID for this execution
|
* @param sandboxId Unique ID for this execution
|
||||||
* @param code User code (plain JS for REPL, or full HTML for artifacts)
|
* @param code User code (plain JS for REPL, or full HTML for artifacts)
|
||||||
* @param attachments Attachments available to the code
|
* @param providers Runtime providers to inject
|
||||||
|
* @param consumers Additional message consumers (optional, execute has its own internal consumer)
|
||||||
* @param signal Abort signal
|
* @param signal Abort signal
|
||||||
* @returns Promise resolving to execution result
|
* @returns Promise resolving to execution result
|
||||||
*/
|
*/
|
||||||
public async execute(
|
public async execute(
|
||||||
sandboxId: string,
|
sandboxId: string,
|
||||||
code: string,
|
code: string,
|
||||||
attachments: Attachment[],
|
providers: SandboxRuntimeProvider[] = [],
|
||||||
|
consumers: MessageConsumer[] = [],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<SandboxResult> {
|
): Promise<SandboxResult> {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new Error("Execution aborted");
|
throw new Error("Execution aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the complete HTML document with runtime + user code
|
providers = [new ConsoleRuntimeProvider(), ...providers];
|
||||||
const completeHtml = this.prepareHtmlDocument(sandboxId, code, attachments);
|
SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
|
||||||
|
|
||||||
|
const logs: Array<{ type: string; text: string }> = [];
|
||||||
|
const files: SandboxFile[] = [];
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
// Wait for execution to complete
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const logs: Array<{ type: string; text: string }> = [];
|
// 4. Create execution consumer for lifecycle messages
|
||||||
const files: SandboxFile[] = [];
|
const executionConsumer: MessageConsumer = {
|
||||||
let completed = false;
|
handleMessage(message: any): boolean {
|
||||||
|
if (message.type === "console") {
|
||||||
const messageHandler = (e: MessageEvent) => {
|
logs.push({
|
||||||
// Ignore messages not for this sandbox
|
type: message.method === "error" ? "error" : "log",
|
||||||
if (e.data.sandboxId !== sandboxId) return;
|
text: message.text,
|
||||||
|
});
|
||||||
if (e.data.type === "console") {
|
return true;
|
||||||
logs.push({
|
} else if (message.type === "file-returned") {
|
||||||
type: e.data.method === "error" ? "error" : "log",
|
files.push({
|
||||||
text: e.data.text,
|
fileName: message.fileName,
|
||||||
});
|
content: message.content,
|
||||||
} else if (e.data.type === "file-returned") {
|
mimeType: message.mimeType,
|
||||||
files.push({
|
});
|
||||||
fileName: e.data.fileName,
|
return true;
|
||||||
content: e.data.content,
|
} else if (message.type === "execution-complete") {
|
||||||
mimeType: e.data.mimeType,
|
completed = true;
|
||||||
});
|
cleanup();
|
||||||
} else if (e.data.type === "execution-complete") {
|
resolve({ success: true, console: logs, files });
|
||||||
completed = true;
|
return true;
|
||||||
cleanup();
|
} else if (message.type === "execution-error") {
|
||||||
resolve({
|
completed = true;
|
||||||
success: true,
|
cleanup();
|
||||||
console: logs,
|
resolve({ success: false, console: logs, error: message.error, files });
|
||||||
files: files,
|
return true;
|
||||||
});
|
}
|
||||||
} else if (e.data.type === "execution-error") {
|
return false;
|
||||||
completed = true;
|
},
|
||||||
cleanup();
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
console: logs,
|
|
||||||
error: e.data.error,
|
|
||||||
files,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
SANDBOX_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||||
|
signal?.removeEventListener("abort", abortHandler);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.iframe?.remove();
|
||||||
|
this.iframe = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Abort handler
|
||||||
const abortHandler = () => {
|
const abortHandler = () => {
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
|
completed = true;
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(new Error("Execution aborted"));
|
reject(new Error("Execution aborted"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let readyHandler: ((e: MessageEvent) => void) | undefined;
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", abortHandler);
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
// Timeout handler (30 seconds)
|
||||||
window.removeEventListener("message", messageHandler);
|
|
||||||
signal?.removeEventListener("abort", abortHandler);
|
|
||||||
if (readyHandler) {
|
|
||||||
window.removeEventListener("message", readyHandler);
|
|
||||||
}
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up listeners BEFORE creating iframe
|
|
||||||
window.addEventListener("message", messageHandler);
|
|
||||||
signal?.addEventListener("abort", abortHandler);
|
|
||||||
|
|
||||||
// Timeout after 30 seconds
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
|
completed = true;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve({
|
resolve({
|
||||||
success: false,
|
success: false,
|
||||||
error: { message: "Execution timeout (30s)", stack: "" },
|
|
||||||
console: logs,
|
console: logs,
|
||||||
|
error: { message: "Execution timeout (30s)", stack: "" },
|
||||||
files,
|
files,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
// 4. Prepare HTML and create iframe
|
||||||
|
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers);
|
||||||
|
|
||||||
if (this.sandboxUrlProvider) {
|
if (this.sandboxUrlProvider) {
|
||||||
// Browser extension mode: wait for sandbox-ready and send content
|
// Browser extension mode: wait for sandbox-ready
|
||||||
readyHandler = (e: MessageEvent) => {
|
this.iframe = document.createElement("iframe");
|
||||||
|
this.iframe.sandbox.add("allow-scripts", "allow-modals");
|
||||||
|
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
|
||||||
|
this.iframe.src = this.sandboxUrlProvider();
|
||||||
|
|
||||||
|
// Update router with iframe reference BEFORE appending to DOM
|
||||||
|
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||||
|
|
||||||
|
// Listen for sandbox-ready message directly
|
||||||
|
const readyHandler = (e: MessageEvent) => {
|
||||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||||
window.removeEventListener("message", readyHandler!);
|
window.removeEventListener("message", readyHandler);
|
||||||
// Send the complete HTML
|
|
||||||
|
// Send content to sandbox
|
||||||
this.iframe?.contentWindow?.postMessage(
|
this.iframe?.contentWindow?.postMessage(
|
||||||
{
|
{
|
||||||
type: "sandbox-load",
|
type: "sandbox-load",
|
||||||
sandboxId,
|
sandboxId,
|
||||||
code: completeHtml,
|
code: completeHtml,
|
||||||
attachments,
|
|
||||||
},
|
},
|
||||||
"*",
|
"*",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("message", readyHandler);
|
window.addEventListener("message", readyHandler);
|
||||||
|
|
||||||
// Create 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";
|
|
||||||
|
|
||||||
this.iframe.src = this.sandboxUrlProvider();
|
|
||||||
|
|
||||||
this.appendChild(this.iframe);
|
this.appendChild(this.iframe);
|
||||||
} else {
|
} else {
|
||||||
// Web mode: use srcdoc
|
// Web mode: use srcdoc
|
||||||
this.iframe?.remove();
|
|
||||||
this.iframe = document.createElement("iframe");
|
this.iframe = document.createElement("iframe");
|
||||||
this.iframe.sandbox.add("allow-scripts");
|
this.iframe.sandbox.add("allow-scripts", "allow-modals");
|
||||||
this.iframe.sandbox.add("allow-modals");
|
this.iframe.style.cssText = "width: 100%; height: 100%; border: none; display: none;";
|
||||||
this.iframe.style.width = "100%";
|
|
||||||
this.iframe.style.height = "100%";
|
|
||||||
this.iframe.style.border = "none";
|
|
||||||
|
|
||||||
// Set content via srcdoc BEFORE appending to DOM
|
|
||||||
this.iframe.srcdoc = completeHtml;
|
this.iframe.srcdoc = completeHtml;
|
||||||
|
|
||||||
|
// Update router with iframe reference BEFORE appending to DOM
|
||||||
|
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||||
|
|
||||||
this.appendChild(this.iframe);
|
this.appendChild(this.iframe);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -259,10 +285,11 @@ export class SandboxIframe extends LitElement {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare complete HTML document with runtime + user code
|
* Prepare complete HTML document with runtime + user code
|
||||||
|
* PUBLIC so HtmlArtifact can use it for download button
|
||||||
*/
|
*/
|
||||||
private prepareHtmlDocument(sandboxId: string, userCode: string, attachments: Attachment[]): string {
|
public prepareHtmlDocument(sandboxId: string, userCode: string, providers: SandboxRuntimeProvider[] = []): string {
|
||||||
// Runtime script that will be injected
|
// Runtime script that will be injected
|
||||||
const runtime = this.getRuntimeScript(sandboxId, attachments);
|
const runtime = this.getRuntimeScript(sandboxId, providers);
|
||||||
|
|
||||||
// Check if user provided full HTML
|
// Check if user provided full HTML
|
||||||
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
|
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
|
||||||
|
|
@ -311,215 +338,30 @@ export class SandboxIframe extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the runtime script that captures console, provides helpers, etc.
|
* Generate runtime script from providers
|
||||||
*/
|
*/
|
||||||
private getRuntimeScript(sandboxId: string, attachments: Attachment[]): string {
|
private getRuntimeScript(sandboxId: string, providers: SandboxRuntimeProvider[] = []): string {
|
||||||
// Convert attachments to serializable format
|
// Collect all data from providers
|
||||||
const attachmentsData = attachments.map((a) => ({
|
const allData: Record<string, any> = {};
|
||||||
id: a.id,
|
for (const provider of providers) {
|
||||||
fileName: a.fileName,
|
Object.assign(allData, provider.getData());
|
||||||
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)
|
// Collect all runtime functions - pass sandboxId as string literal
|
||||||
const runtimeFunc = () => {
|
const runtimeFunctions: string[] = [];
|
||||||
// Helper functions
|
for (const provider of providers) {
|
||||||
(window as any).listFiles = () =>
|
runtimeFunctions.push(`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`);
|
||||||
(attachments || []).map((a: any) => ({
|
}
|
||||||
id: a.id,
|
|
||||||
fileName: a.fileName,
|
|
||||||
mimeType: a.mimeType,
|
|
||||||
size: a.size,
|
|
||||||
}));
|
|
||||||
|
|
||||||
(window as any).readTextFile = (attachmentId: string) => {
|
// Build script
|
||||||
const a = (attachments || []).find((x: any) => x.id === attachmentId);
|
const dataInjection = Object.entries(allData)
|
||||||
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
.map(([key, value]) => `window.${key} = ${JSON.stringify(value)};`)
|
||||||
if (a.extractedText) return a.extractedText;
|
.join("\n");
|
||||||
try {
|
|
||||||
return atob(a.content);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to decode text content for: " + attachmentId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).readBinaryFile = (attachmentId: string) => {
|
return `<script>
|
||||||
const a = (attachments || []).find((x: any) => x.id === attachmentId);
|
window.sandboxId = ${JSON.stringify(sandboxId)};
|
||||||
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
${dataInjection}
|
||||||
const bin = atob(a.content);
|
${runtimeFunctions.join("\n")}
|
||||||
const bytes = new Uint8Array(bin.length);
|
</script>`;
|
||||||
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>`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { Attachment } from "../../utils/attachment-utils.js";
|
||||||
|
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachments Runtime Provider
|
||||||
|
*
|
||||||
|
* OPTIONAL provider that provides file access APIs to sandboxed code.
|
||||||
|
* Only needed when attachments are present.
|
||||||
|
*/
|
||||||
|
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
|
constructor(private attachments: Attachment[]) {}
|
||||||
|
|
||||||
|
getData(): Record<string, any> {
|
||||||
|
const attachmentsData = this.attachments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
fileName: a.fileName,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
size: a.size,
|
||||||
|
content: a.content,
|
||||||
|
extractedText: a.extractedText,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { attachments: attachmentsData };
|
||||||
|
}
|
||||||
|
|
||||||
|
getRuntime(): (sandboxId: string) => void {
|
||||||
|
// This function will be stringified, so no external references!
|
||||||
|
return (sandboxId: string) => {
|
||||||
|
// Helper functions for attachments
|
||||||
|
(window as any).listFiles = () =>
|
||||||
|
((window as any).attachments || []).map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
fileName: a.fileName,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
size: a.size,
|
||||||
|
}));
|
||||||
|
|
||||||
|
(window as any).readTextFile = (attachmentId: string) => {
|
||||||
|
const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId);
|
||||||
|
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
||||||
|
if (a.extractedText) return a.extractedText;
|
||||||
|
try {
|
||||||
|
return atob(a.content);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Failed to decode text content for: " + attachmentId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).readBinaryFile = (attachmentId: string) => {
|
||||||
|
const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId);
|
||||||
|
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
||||||
|
const bin = atob(a.content);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
(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,
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
132
packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts
Normal file
132
packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console Runtime Provider
|
||||||
|
*
|
||||||
|
* REQUIRED provider that should always be included first.
|
||||||
|
* Provides console capture, error handling, and execution lifecycle management.
|
||||||
|
*/
|
||||||
|
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
|
getData(): Record<string, any> {
|
||||||
|
// No data needed
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
getRuntime(): (sandboxId: string) => void {
|
||||||
|
return (sandboxId: string) => {
|
||||||
|
// 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 || "?");
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
152
packages/web-ui/src/components/sandbox/SandboxMessageRouter.ts
Normal file
152
packages/web-ui/src/components/sandbox/SandboxMessageRouter.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message consumer interface - components that want to receive messages from sandboxes
|
||||||
|
*/
|
||||||
|
export interface MessageConsumer {
|
||||||
|
/**
|
||||||
|
* Handle a message from a sandbox.
|
||||||
|
* @returns true if message was consumed (stops propagation), false otherwise
|
||||||
|
*/
|
||||||
|
handleMessage(message: any): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sandbox context - tracks active sandboxes and their consumers
|
||||||
|
*/
|
||||||
|
interface SandboxContext {
|
||||||
|
sandboxId: string;
|
||||||
|
iframe: HTMLIFrameElement | null; // null until setSandboxIframe()
|
||||||
|
providers: SandboxRuntimeProvider[];
|
||||||
|
consumers: Set<MessageConsumer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized message router for all sandbox communication.
|
||||||
|
*
|
||||||
|
* This singleton replaces all individual window.addEventListener("message") calls
|
||||||
|
* with a single global listener that routes messages to the appropriate handlers.
|
||||||
|
*
|
||||||
|
* Benefits:
|
||||||
|
* - Single global listener instead of multiple independent listeners
|
||||||
|
* - Automatic cleanup when sandboxes are destroyed
|
||||||
|
* - Support for bidirectional communication (providers) and broadcasting (consumers)
|
||||||
|
* - Clear lifecycle management
|
||||||
|
*/
|
||||||
|
export class SandboxMessageRouter {
|
||||||
|
private sandboxes = new Map<string, SandboxContext>();
|
||||||
|
private messageListener: ((e: MessageEvent) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new sandbox with its runtime providers.
|
||||||
|
* Call this BEFORE creating the iframe.
|
||||||
|
*/
|
||||||
|
registerSandbox(sandboxId: string, providers: SandboxRuntimeProvider[], consumers: MessageConsumer[]): void {
|
||||||
|
this.sandboxes.set(sandboxId, {
|
||||||
|
sandboxId,
|
||||||
|
iframe: null, // Will be set via setSandboxIframe()
|
||||||
|
providers,
|
||||||
|
consumers: new Set(consumers),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup global listener if not already done
|
||||||
|
this.setupListener();
|
||||||
|
console.log("Registered sandbox:", sandboxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the iframe reference for a sandbox.
|
||||||
|
* Call this AFTER creating the iframe.
|
||||||
|
* This is needed so providers can send responses back to the sandbox.
|
||||||
|
*/
|
||||||
|
setSandboxIframe(sandboxId: string, iframe: HTMLIFrameElement): void {
|
||||||
|
const context = this.sandboxes.get(sandboxId);
|
||||||
|
if (context) {
|
||||||
|
context.iframe = iframe;
|
||||||
|
}
|
||||||
|
console.log("Set iframe for sandbox:", sandboxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a sandbox and remove all its consumers.
|
||||||
|
* Call this when the sandbox is destroyed.
|
||||||
|
*/
|
||||||
|
unregisterSandbox(sandboxId: string): void {
|
||||||
|
this.sandboxes.delete(sandboxId);
|
||||||
|
|
||||||
|
// If no more sandboxes, remove global listener
|
||||||
|
if (this.sandboxes.size === 0 && this.messageListener) {
|
||||||
|
window.removeEventListener("message", this.messageListener);
|
||||||
|
this.messageListener = null;
|
||||||
|
}
|
||||||
|
console.log("Unregistered sandbox:", sandboxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a message consumer for a sandbox.
|
||||||
|
* Consumers receive broadcast messages (console, execution-complete, etc.)
|
||||||
|
*/
|
||||||
|
addConsumer(sandboxId: string, consumer: MessageConsumer): void {
|
||||||
|
const context = this.sandboxes.get(sandboxId);
|
||||||
|
if (context) {
|
||||||
|
context.consumers.add(consumer);
|
||||||
|
}
|
||||||
|
console.log("Added consumer for sandbox:", sandboxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a message consumer from a sandbox.
|
||||||
|
*/
|
||||||
|
removeConsumer(sandboxId: string, consumer: MessageConsumer): void {
|
||||||
|
const context = this.sandboxes.get(sandboxId);
|
||||||
|
if (context) {
|
||||||
|
context.consumers.delete(consumer);
|
||||||
|
}
|
||||||
|
console.log("Removed consumer for sandbox:", sandboxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup the global message listener (called automatically)
|
||||||
|
*/
|
||||||
|
private setupListener(): void {
|
||||||
|
if (this.messageListener) return;
|
||||||
|
|
||||||
|
this.messageListener = (e: MessageEvent) => {
|
||||||
|
const { sandboxId } = e.data;
|
||||||
|
if (!sandboxId) return;
|
||||||
|
|
||||||
|
console.log("Router received message for sandbox:", sandboxId, e.data);
|
||||||
|
|
||||||
|
const context = this.sandboxes.get(sandboxId);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
// Create respond() function for bidirectional communication
|
||||||
|
const respond = (response: any) => {
|
||||||
|
if (!response.sandboxId) response.sandboxId = sandboxId;
|
||||||
|
context.iframe?.contentWindow?.postMessage(response, "*");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Try provider handlers first (for bidirectional comm like memory)
|
||||||
|
for (const provider of context.providers) {
|
||||||
|
if (provider.handleMessage) {
|
||||||
|
const handled = provider.handleMessage(e.data, respond);
|
||||||
|
if (handled) return; // Stop if handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Broadcast to consumers (for one-way messages like console)
|
||||||
|
for (const consumer of context.consumers) {
|
||||||
|
const consumed = consumer.handleMessage(e.data);
|
||||||
|
if (consumed) break; // Stop if consumed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", this.messageListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global singleton instance.
|
||||||
|
* Import this from wherever you need to interact with the message router.
|
||||||
|
*/
|
||||||
|
export const SANDBOX_MESSAGE_ROUTER = new SandboxMessageRouter();
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Interface for providing runtime capabilities to sandboxed iframes.
|
||||||
|
* Each provider injects data and runtime functions into the sandbox context.
|
||||||
|
*/
|
||||||
|
export interface SandboxRuntimeProvider {
|
||||||
|
/**
|
||||||
|
* Returns data to inject into window scope.
|
||||||
|
* Keys become window properties (e.g., { attachments: [...] } -> window.attachments)
|
||||||
|
*/
|
||||||
|
getData(): Record<string, any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a runtime function that will be stringified and executed in the sandbox.
|
||||||
|
* The function receives sandboxId and has access to data from getData() via window.
|
||||||
|
*
|
||||||
|
* IMPORTANT: This function will be converted to string via .toString() and injected
|
||||||
|
* into the sandbox, so it cannot reference external variables or imports.
|
||||||
|
*/
|
||||||
|
getRuntime(): (sandboxId: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional message handler for bidirectional communication.
|
||||||
|
* Return true if the message was handled, false to let other handlers try.
|
||||||
|
*
|
||||||
|
* @param message - The message from the sandbox
|
||||||
|
* @param respond - Function to send a response back to the sandbox
|
||||||
|
* @returns true if message was handled, false otherwise
|
||||||
|
*/
|
||||||
|
handleMessage?(message: any, respond: (response: any) => void): boolean;
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||||
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
|
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
|
||||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
import { type MessageConsumer, SANDBOX_MESSAGE_ROUTER } from "../../components/sandbox/SandboxMessageRouter.js";
|
||||||
|
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
|
||||||
import { i18n } from "../../utils/i18n.js";
|
import { i18n } from "../../utils/i18n.js";
|
||||||
import "../../components/SandboxedIframe.js";
|
import "../../components/SandboxedIframe.js";
|
||||||
import { ArtifactElement } from "./ArtifactElement.js";
|
import { ArtifactElement } from "./ArtifactElement.js";
|
||||||
|
|
@ -16,7 +17,7 @@ import "./Console.js";
|
||||||
export class HtmlArtifact extends ArtifactElement {
|
export class HtmlArtifact extends ArtifactElement {
|
||||||
@property() override filename = "";
|
@property() override filename = "";
|
||||||
@property({ attribute: false }) override displayTitle = "";
|
@property({ attribute: false }) override displayTitle = "";
|
||||||
@property({ attribute: false }) attachments: Attachment[] = [];
|
@property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] = [];
|
||||||
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
||||||
|
|
||||||
private _content = "";
|
private _content = "";
|
||||||
|
|
@ -26,9 +27,6 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
|
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
|
||||||
private consoleRef: Ref<Console> = createRef();
|
private consoleRef: Ref<Console> = createRef();
|
||||||
|
|
||||||
// Store message handler so we can remove it
|
|
||||||
private messageHandler?: (e: MessageEvent) => void;
|
|
||||||
|
|
||||||
@state() private viewMode: "preview" | "code" = "preview";
|
@state() private viewMode: "preview" | "code" = "preview";
|
||||||
|
|
||||||
private setViewMode(mode: "preview" | "code") {
|
private setViewMode(mode: "preview" | "code") {
|
||||||
|
|
@ -47,11 +45,17 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
copyButton.title = i18n("Copy HTML");
|
copyButton.title = i18n("Copy HTML");
|
||||||
copyButton.showText = false;
|
copyButton.showText = false;
|
||||||
|
|
||||||
|
// Generate standalone HTML with all runtime code injected for download
|
||||||
|
const sandbox = this.sandboxIframeRef.value;
|
||||||
|
const sandboxId = `artifact-${this.filename}`;
|
||||||
|
const downloadContent =
|
||||||
|
sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || []) || this._content;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
${toggle}
|
${toggle}
|
||||||
${copyButton}
|
${copyButton}
|
||||||
${DownloadButton({ content: this._content, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
|
${DownloadButton({ content: downloadContent, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -79,33 +83,29 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
|
sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove previous message handler if it exists
|
|
||||||
if (this.messageHandler) {
|
|
||||||
window.removeEventListener("message", this.messageHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sandboxId = `artifact-${this.filename}`;
|
const sandboxId = `artifact-${this.filename}`;
|
||||||
|
|
||||||
// Set up message listener to collect logs
|
// Create consumer for console messages
|
||||||
this.messageHandler = (e: MessageEvent) => {
|
const consumer: MessageConsumer = {
|
||||||
if (e.data.sandboxId !== sandboxId) return;
|
handleMessage: (message: any): boolean => {
|
||||||
|
if (message.type === "console") {
|
||||||
if (e.data.type === "console") {
|
// Create new array reference for Lit reactivity
|
||||||
// Create new array reference for Lit reactivity
|
this.logs = [
|
||||||
this.logs = [
|
...this.logs,
|
||||||
...this.logs,
|
{
|
||||||
{
|
type: message.method === "error" ? "error" : "log",
|
||||||
type: e.data.method === "error" ? "error" : "log",
|
text: message.text,
|
||||||
text: e.data.text,
|
},
|
||||||
},
|
];
|
||||||
];
|
this.requestUpdate(); // Re-render to show console
|
||||||
this.requestUpdate(); // Re-render to show console
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
window.addEventListener("message", this.messageHandler);
|
|
||||||
|
|
||||||
// Load content (iframe persists, doesn't get removed)
|
// Load content - this handles sandbox registration, consumer registration, and iframe creation
|
||||||
sandbox.loadContent(sandboxId, html, this.attachments);
|
sandbox.loadContent(sandboxId, html, this.runtimeProviders, [consumer]);
|
||||||
}
|
}
|
||||||
|
|
||||||
override get content(): string {
|
override get content(): string {
|
||||||
|
|
@ -114,11 +114,9 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
// Clean up message handler when element is removed from DOM
|
// Unregister sandbox when element is removed from DOM
|
||||||
if (this.messageHandler) {
|
const sandboxId = `artifact-${this.filename}`;
|
||||||
window.removeEventListener("message", this.messageHandler);
|
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||||
this.messageHandler = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override firstUpdated() {
|
override firstUpdated() {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,11 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
|
||||||
|
|
||||||
// For create/update/rewrite errors, show code block + console/error
|
// For create/update/rewrite errors, show code block + console/error
|
||||||
if (command === "create" || command === "update" || command === "rewrite") {
|
if (command === "create" || command === "update" || command === "rewrite") {
|
||||||
const content = command === "update" ? params?.new_str || params?.old_str || "" : params?.content || "";
|
const content = params?.content || "";
|
||||||
|
const { old_str, new_str } = params || {};
|
||||||
|
const isDiff = command === "update";
|
||||||
|
const diffContent =
|
||||||
|
old_str !== undefined && new_str !== undefined ? Diff({ oldText: old_str, newText: new_str }) : "";
|
||||||
|
|
||||||
const isHtml = filename?.endsWith(".html");
|
const isHtml = filename?.endsWith(".html");
|
||||||
|
|
||||||
|
|
@ -101,7 +105,7 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
|
||||||
<div>
|
<div>
|
||||||
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
|
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
|
||||||
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
|
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
|
||||||
${content ? html`<code-block .code=${content} language=${getLanguageFromFilename(filename)}></code-block>` : ""}
|
${isDiff ? diffContent : content ? html`<code-block .code=${content} language=${getLanguageFromFilename(filename)}></code-block>` : ""}
|
||||||
${
|
${
|
||||||
isHtml
|
isHtml
|
||||||
? html`<console-block .content=${result.output || i18n("An error occurred")} variant="error"></console-block>`
|
? html`<console-block .content=${result.output || i18n("An error occurred")} variant="error"></console-block>`
|
||||||
|
|
@ -156,7 +160,7 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
|
||||||
}
|
}
|
||||||
|
|
||||||
// CREATE/UPDATE/REWRITE: always show code block, + console block for .html files
|
// CREATE/UPDATE/REWRITE: always show code block, + console block for .html files
|
||||||
if (command === "create" || command === "update" || command === "rewrite") {
|
if (command === "create" || command === "rewrite") {
|
||||||
const codeContent = content || "";
|
const codeContent = content || "";
|
||||||
const isHtml = filename?.endsWith(".html");
|
const isHtml = filename?.endsWith(".html");
|
||||||
const logs = result.output || "";
|
const logs = result.output || "";
|
||||||
|
|
@ -172,6 +176,20 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === "update") {
|
||||||
|
const isHtml = filename?.endsWith(".html");
|
||||||
|
const logs = result.output || "";
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
|
||||||
|
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
|
||||||
|
${Diff({ oldText: params.old_str || "", newText: params.new_str || "" })}
|
||||||
|
${isHtml && logs ? html`<console-block .content=${logs}></console-block>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// For DELETE, just show header
|
// For DELETE, just show header
|
||||||
return html`
|
return html`
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { html, LitElement, type TemplateResult } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
||||||
import { X } from "lucide";
|
import { X } from "lucide";
|
||||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
|
||||||
import { i18n } from "../../utils/i18n.js";
|
import { i18n } from "../../utils/i18n.js";
|
||||||
import type { ArtifactElement } from "./ArtifactElement.js";
|
import type { ArtifactElement } from "./ArtifactElement.js";
|
||||||
import { HtmlArtifact } from "./HtmlArtifact.js";
|
import { HtmlArtifact } from "./HtmlArtifact.js";
|
||||||
|
|
@ -45,8 +45,8 @@ export class ArtifactsPanel extends LitElement {
|
||||||
private artifactElements = new Map<string, ArtifactElement>();
|
private artifactElements = new Map<string, ArtifactElement>();
|
||||||
private contentRef: Ref<HTMLDivElement> = createRef();
|
private contentRef: Ref<HTMLDivElement> = createRef();
|
||||||
|
|
||||||
// External provider for attachments (decouples panel from AgentInterface)
|
// External factory for runtime providers (decouples panel from AgentInterface)
|
||||||
@property({ attribute: false }) attachmentsProvider?: () => Attachment[];
|
@property({ attribute: false }) runtimeProvidersFactory?: () => SandboxRuntimeProvider[];
|
||||||
// Sandbox URL provider for browser extensions (optional)
|
// Sandbox URL provider for browser extensions (optional)
|
||||||
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
||||||
// Callbacks
|
// Callbacks
|
||||||
|
|
@ -108,7 +108,8 @@ export class ArtifactsPanel extends LitElement {
|
||||||
const type = this.getFileType(filename);
|
const type = this.getFileType(filename);
|
||||||
if (type === "html") {
|
if (type === "html") {
|
||||||
element = new HtmlArtifact();
|
element = new HtmlArtifact();
|
||||||
(element as HtmlArtifact).attachments = this.attachmentsProvider?.() || [];
|
const runtimeProviders = this.runtimeProvidersFactory?.() || [];
|
||||||
|
(element as HtmlArtifact).runtimeProviders = runtimeProviders;
|
||||||
if (this.sandboxUrlProvider) {
|
if (this.sandboxUrlProvider) {
|
||||||
(element as HtmlArtifact).sandboxUrlProvider = this.sandboxUrlProvider;
|
(element as HtmlArtifact).sandboxUrlProvider = this.sandboxUrlProvider;
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +145,8 @@ export class ArtifactsPanel extends LitElement {
|
||||||
element.content = content;
|
element.content = content;
|
||||||
element.displayTitle = title;
|
element.displayTitle = title;
|
||||||
if (element instanceof HtmlArtifact) {
|
if (element instanceof HtmlArtifact) {
|
||||||
element.attachments = this.attachmentsProvider?.() || [];
|
const runtimeProviders = this.runtimeProvidersFactory?.() || [];
|
||||||
|
element.runtimeProviders = runtimeProviders;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,46 +416,11 @@ CRITICAL REMINDER FOR ALL ARTIFACTS:
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false;
|
// Fallback timeout - just get logs after execution should complete
|
||||||
|
|
||||||
// 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(() => {
|
setTimeout(() => {
|
||||||
if (!resolved) {
|
// Get whatever logs we have
|
||||||
resolved = true;
|
const logs = element.getLogs();
|
||||||
window.removeEventListener("message", messageHandler);
|
resolve(logs);
|
||||||
|
|
||||||
// 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);
|
}, 1500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -568,7 +535,7 @@ CRITICAL REMINDER FOR ALL ARTIFACTS:
|
||||||
this.showArtifact(params.filename);
|
this.showArtifact(params.filename);
|
||||||
|
|
||||||
// For HTML files, wait for execution
|
// For HTML files, wait for execution
|
||||||
let result = `Rewrote file ${params.filename}`;
|
let result = "";
|
||||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
||||||
const logs = await this.waitForHtmlExecution(params.filename);
|
const logs = await this.waitForHtmlExecution(params.filename);
|
||||||
result += logs;
|
result += logs;
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,15 @@ import { type Static, Type } from "@sinclair/typebox";
|
||||||
import { createRef, ref } from "lit/directives/ref.js";
|
import { createRef, ref } from "lit/directives/ref.js";
|
||||||
import { Code } from "lucide";
|
import { Code } from "lucide";
|
||||||
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
|
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
|
||||||
|
import type { SandboxRuntimeProvider } from "../components/sandbox/SandboxRuntimeProvider.js";
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
import type { Attachment } from "../utils/attachment-utils.js";
|
||||||
|
|
||||||
import { registerToolRenderer, renderCollapsibleHeader, renderHeader } from "./renderer-registry.js";
|
import { registerToolRenderer, renderCollapsibleHeader, renderHeader } from "./renderer-registry.js";
|
||||||
import type { ToolRenderer } from "./types.js";
|
import type { ToolRenderer } from "./types.js";
|
||||||
|
|
||||||
// Execute JavaScript code with attachments using SandboxedIframe
|
// Execute JavaScript code with attachments using SandboxedIframe
|
||||||
export async function executeJavaScript(
|
export async function executeJavaScript(
|
||||||
code: string,
|
code: string,
|
||||||
attachments: Attachment[] = [],
|
runtimeProviders: SandboxRuntimeProvider[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
sandboxUrlProvider?: () => string,
|
sandboxUrlProvider?: () => string,
|
||||||
): Promise<{ output: string; files?: SandboxFile[] }> {
|
): Promise<{ output: string; files?: SandboxFile[] }> {
|
||||||
|
|
@ -34,8 +34,11 @@ export async function executeJavaScript(
|
||||||
document.body.appendChild(sandbox);
|
document.body.appendChild(sandbox);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sandboxId = `repl-${Date.now()}`;
|
const sandboxId = `repl-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
const result: SandboxResult = await sandbox.execute(sandboxId, code, attachments, signal);
|
|
||||||
|
// Pass providers to execute (router handles all message routing)
|
||||||
|
// No additional consumers needed - execute() has its own internal consumer
|
||||||
|
const result: SandboxResult = await sandbox.execute(sandboxId, code, runtimeProviders, [], signal);
|
||||||
|
|
||||||
// Remove the sandbox iframe after execution
|
// Remove the sandbox iframe after execution
|
||||||
sandbox.remove();
|
sandbox.remove();
|
||||||
|
|
@ -114,13 +117,13 @@ interface JavaScriptReplResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
|
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
|
||||||
attachmentsProvider?: () => Attachment[];
|
runtimeProvidersFactory?: () => SandboxRuntimeProvider[];
|
||||||
sandboxUrlProvider?: () => string;
|
sandboxUrlProvider?: () => string;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
label: "JavaScript REPL",
|
label: "JavaScript REPL",
|
||||||
name: "javascript_repl",
|
name: "javascript_repl",
|
||||||
attachmentsProvider: () => [], // default to empty array
|
runtimeProvidersFactory: () => [], // default to empty array
|
||||||
sandboxUrlProvider: undefined, // optional, for browser extensions
|
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.
|
||||||
|
|
||||||
|
|
@ -196,8 +199,12 @@ Global variables:
|
||||||
- All standard browser globals (window, document, fetch, etc.)`,
|
- All standard browser globals (window, document, fetch, etc.)`,
|
||||||
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 result = await executeJavaScript(
|
||||||
const result = await executeJavaScript(args.code, attachments, signal, this.sandboxUrlProvider);
|
args.code,
|
||||||
|
this.runtimeProvidersFactory?.() ?? [],
|
||||||
|
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