diff --git a/packages/web-ui/src/components/SandboxedIframe.ts b/packages/web-ui/src/components/SandboxedIframe.ts index a3379b8f..8d9655df 100644 --- a/packages/web-ui/src/components/SandboxedIframe.ts +++ b/packages/web-ui/src/components/SandboxedIframe.ts @@ -1,7 +1,8 @@ import { LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js"; -import { type MessageConsumer, SANDBOX_MESSAGE_ROUTER } from "./sandbox/SandboxMessageRouter.js"; +import { RuntimeMessageBridge } from "./sandbox/RuntimeMessageBridge.js"; +import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "./sandbox/RuntimeMessageRouter.js"; import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js"; export interface SandboxFile { @@ -65,16 +66,17 @@ export class SandboxIframe extends LitElement { ): void { // Unregister previous sandbox if exists try { - SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId); + RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId); } catch { // Sandbox might not exist, that's ok } providers = [new ConsoleRuntimeProvider(), ...providers]; - SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers); + RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers); - const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers); + // loadContent is always used for HTML artifacts + const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers, true); // Remove previous iframe if exists this.iframe?.remove(); @@ -99,7 +101,7 @@ export class SandboxIframe extends LitElement { this.iframe.src = this.sandboxUrlProvider!(); // Update router with iframe reference BEFORE appending to DOM - SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); + RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); // Listen for sandbox-ready message directly const readyHandler = (e: MessageEvent) => { @@ -134,7 +136,7 @@ export class SandboxIframe extends LitElement { this.iframe.srcdoc = completeHtml; // Update router with iframe reference BEFORE appending to DOM - SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); + RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); this.appendChild(this.iframe); } @@ -154,13 +156,14 @@ export class SandboxIframe extends LitElement { providers: SandboxRuntimeProvider[] = [], consumers: MessageConsumer[] = [], signal?: AbortSignal, + isHtmlArtifact: boolean = false, ): Promise { if (signal?.aborted) { throw new Error("Execution aborted"); } providers = [new ConsoleRuntimeProvider(), ...providers]; - SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers); + RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers); const logs: Array<{ type: string; text: string }> = []; const files: SandboxFile[] = []; @@ -198,10 +201,10 @@ export class SandboxIframe extends LitElement { }, }; - SANDBOX_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer); + RUNTIME_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer); const cleanup = () => { - SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId); + RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId); signal?.removeEventListener("abort", abortHandler); clearTimeout(timeoutId); this.iframe?.remove(); @@ -236,7 +239,7 @@ export class SandboxIframe extends LitElement { }, 30000); // 4. Prepare HTML and create iframe - const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers); + const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers, isHtmlArtifact); if (this.sandboxUrlProvider) { // Browser extension mode: wait for sandbox-ready @@ -246,7 +249,7 @@ export class SandboxIframe extends LitElement { this.iframe.src = this.sandboxUrlProvider(); // Update router with iframe reference BEFORE appending to DOM - SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); + RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); // Listen for sandbox-ready message directly const readyHandler = (e: MessageEvent) => { @@ -276,7 +279,7 @@ export class SandboxIframe extends LitElement { this.iframe.srcdoc = completeHtml; // Update router with iframe reference BEFORE appending to DOM - SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); + RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe); this.appendChild(this.iframe); } @@ -287,14 +290,18 @@ export class SandboxIframe extends LitElement { * Prepare complete HTML document with runtime + user code * PUBLIC so HtmlArtifact can use it for download button */ - public prepareHtmlDocument(sandboxId: string, userCode: string, providers: SandboxRuntimeProvider[] = []): string { + public prepareHtmlDocument( + sandboxId: string, + userCode: string, + providers: SandboxRuntimeProvider[] = [], + isHtmlArtifact: boolean = false, + ): string { // Runtime script that will be injected const runtime = this.getRuntimeScript(sandboxId, providers); - // Check if user provided full HTML - const hasHtmlTag = /]*>/i.test(userCode); - - if (hasHtmlTag) { + // Only check for HTML tags if explicitly marked as HTML artifact + // For javascript_repl, userCode is JavaScript that may contain HTML in string literals + if (isHtmlArtifact) { // HTML Artifact - inject runtime into existing HTML const headMatch = userCode.match(/]*>/i); if (headMatch) { @@ -347,20 +354,31 @@ export class SandboxIframe extends LitElement { Object.assign(allData, provider.getData()); } + // Generate bridge code + const bridgeCode = RuntimeMessageBridge.generateBridgeCode({ + context: "sandbox-iframe", + sandboxId, + }); + // Collect all runtime functions - pass sandboxId as string literal const runtimeFunctions: string[] = []; for (const provider of providers) { runtimeFunctions.push(`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`); } - // Build script + // Build script with HTML escaping + // Escape to prevent premature tag closure in HTML parser const dataInjection = Object.entries(allData) - .map(([key, value]) => `window.${key} = ${JSON.stringify(value)};`) + .map(([key, value]) => { + const jsonStr = JSON.stringify(value).replace(/<\/script/gi, "<\\/script"); + return `window.${key} = ${jsonStr};`; + }) .join("\n"); return ``; } diff --git a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts index 413f537e..a2947537 100644 --- a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts +++ b/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts @@ -6,6 +6,7 @@ import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; * * Provides programmatic access to session artifacts from sandboxed code. * Allows code to create, read, update, and delete artifacts dynamically. + * Supports both online (extension) and offline (downloaded HTML) modes. */ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider { constructor( @@ -17,54 +18,59 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider { ) {} getData(): Record { - // No initial data injection needed - artifacts are accessed via async functions - return {}; + // Inject artifact snapshot for offline mode + const snapshot: Record = {}; + const artifacts = this.getArtifactsFn(); + artifacts.forEach((artifact, filename) => { + snapshot[filename] = artifact.content; + }); + return { artifacts: snapshot }; } getRuntime(): (sandboxId: string) => void { // This function will be stringified, so no external references! - return (sandboxId: string) => { - // Helper to send message and wait for response - const sendArtifactMessage = (action: string, data: any): Promise => { - console.log("Sending artifact message:", action, data); - return new Promise((resolve, reject) => { - const messageId = `artifact_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - - const handler = (event: MessageEvent) => { - if (event.data.type === "artifact-response" && event.data.messageId === messageId) { - window.removeEventListener("message", handler); - if (event.data.success) { - resolve(event.data.result); - } else { - reject(new Error(event.data.error || "Artifact operation failed")); - } - } - }; - - window.addEventListener("message", handler); - - window.parent.postMessage( - { - type: "artifact-operation", - sandboxId, - messageId, - action, - data, - }, - "*", - ); - }); - }; - + return (_sandboxId: string) => { // Auto-parse/stringify for .json files const isJsonFile = (filename: string) => filename.endsWith(".json"); (window as any).hasArtifact = async (filename: string): Promise => { - return await sendArtifactMessage("has", { filename }); + // Online: ask extension + if ((window as any).sendRuntimeMessage) { + const response = await (window as any).sendRuntimeMessage({ + type: "artifact-operation", + action: "has", + filename, + }); + if (!response.success) throw new Error(response.error); + return response.result; + } + // Offline: check snapshot + else { + return !!(window as any).artifacts?.[filename]; + } }; (window as any).getArtifact = async (filename: string): Promise => { - const content = await sendArtifactMessage("get", { filename }); + let content: string; + + // Online: ask extension + if ((window as any).sendRuntimeMessage) { + const response = await (window as any).sendRuntimeMessage({ + type: "artifact-operation", + action: "get", + filename, + }); + if (!response.success) throw new Error(response.error); + content = response.result; + } + // Offline: read snapshot + else { + if (!(window as any).artifacts?.[filename]) { + throw new Error(`Artifact not found (offline mode): ${filename}`); + } + content = (window as any).artifacts[filename]; + } + // Auto-parse .json files if (isJsonFile(filename)) { try { @@ -77,45 +83,62 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider { }; (window as any).createArtifact = async (filename: string, content: any, mimeType?: string): Promise => { - let finalContent = content; - let finalMimeType = mimeType; + if (!(window as any).sendRuntimeMessage) { + throw new Error("Cannot create artifacts in offline mode (read-only)"); + } + let finalContent = content; // Auto-stringify .json files if (isJsonFile(filename) && typeof content !== "string") { finalContent = JSON.stringify(content, null, 2); - finalMimeType = mimeType || "application/json"; - } else if (typeof content === "string") { - finalContent = content; - finalMimeType = mimeType || "text/plain"; - } else { + } else if (typeof content !== "string") { finalContent = JSON.stringify(content, null, 2); - finalMimeType = mimeType || "application/json"; } - await sendArtifactMessage("create", { filename, content: finalContent, mimeType: finalMimeType }); + const response = await (window as any).sendRuntimeMessage({ + type: "artifact-operation", + action: "create", + filename, + content: finalContent, + mimeType, + }); + if (!response.success) throw new Error(response.error); }; (window as any).updateArtifact = async (filename: string, content: any, mimeType?: string): Promise => { - let finalContent = content; - let finalMimeType = mimeType; + if (!(window as any).sendRuntimeMessage) { + throw new Error("Cannot update artifacts in offline mode (read-only)"); + } + let finalContent = content; // Auto-stringify .json files if (isJsonFile(filename) && typeof content !== "string") { finalContent = JSON.stringify(content, null, 2); - finalMimeType = mimeType || "application/json"; - } else if (typeof content === "string") { - finalContent = content; - finalMimeType = mimeType || "text/plain"; - } else { + } else if (typeof content !== "string") { finalContent = JSON.stringify(content, null, 2); - finalMimeType = mimeType || "application/json"; } - await sendArtifactMessage("update", { filename, content: finalContent, mimeType: finalMimeType }); + const response = await (window as any).sendRuntimeMessage({ + type: "artifact-operation", + action: "update", + filename, + content: finalContent, + mimeType, + }); + if (!response.success) throw new Error(response.error); }; (window as any).deleteArtifact = async (filename: string): Promise => { - await sendArtifactMessage("delete", { filename }); + if (!(window as any).sendRuntimeMessage) { + throw new Error("Cannot delete artifacts in offline mode (read-only)"); + } + + const response = await (window as any).sendRuntimeMessage({ + type: "artifact-operation", + action: "delete", + filename, + }); + if (!response.success) throw new Error(response.error); }; }; } @@ -125,103 +148,86 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider { return false; } - const { action, data, messageId } = message; - - const sendResponse = (success: boolean, result?: any, error?: string) => { - respond({ - type: "artifact-response", - messageId, - success, - result, - error, - }); - }; + const { action, filename, content, mimeType } = message; try { switch (action) { case "has": { const artifacts = this.getArtifactsFn(); - const exists = artifacts.has(data.filename); - sendResponse(true, exists); + const exists = artifacts.has(filename); + respond({ success: true, result: exists }); break; } case "get": { const artifacts = this.getArtifactsFn(); - const artifact = artifacts.get(data.filename); + const artifact = artifacts.get(filename); if (!artifact) { - sendResponse(false, undefined, `Artifact not found: ${data.filename}`); + respond({ success: false, error: `Artifact not found: ${filename}` }); } else { - sendResponse(true, artifact.content); + respond({ success: true, result: artifact.content }); } break; } case "create": { try { - // Note: mimeType parameter is ignored - artifact type is inferred from filename extension - // Third parameter is title, defaults to filename - await this.createArtifactFn(data.filename, data.content, data.filename); - // Append artifact message for session persistence + await this.createArtifactFn(filename, content, filename); this.appendMessageFn?.({ role: "artifact", action: "create", - filename: data.filename, - content: data.content, - title: data.filename, + filename, + content, + title: filename, timestamp: new Date().toISOString(), }); - sendResponse(true); + respond({ success: true }); } catch (err: any) { - sendResponse(false, undefined, err.message); + respond({ success: false, error: err.message }); } break; } case "update": { try { - // Note: mimeType parameter is ignored - artifact type is inferred from filename extension - // Third parameter is title, defaults to filename - await this.updateArtifactFn(data.filename, data.content, data.filename); - // Append artifact message for session persistence + await this.updateArtifactFn(filename, content, filename); this.appendMessageFn?.({ role: "artifact", action: "update", - filename: data.filename, - content: data.content, + filename, + content, timestamp: new Date().toISOString(), }); - sendResponse(true); + respond({ success: true }); } catch (err: any) { - sendResponse(false, undefined, err.message); + respond({ success: false, error: err.message }); } break; } case "delete": { try { - await this.deleteArtifactFn(data.filename); - // Append artifact message for session persistence + await this.deleteArtifactFn(filename); this.appendMessageFn?.({ role: "artifact", action: "delete", - filename: data.filename, + filename, timestamp: new Date().toISOString(), }); - sendResponse(true); + respond({ success: true }); } catch (err: any) { - sendResponse(false, undefined, err.message); + respond({ success: false, error: err.message }); } break; } default: - sendResponse(false, undefined, `Unknown artifact action: ${action}`); + respond({ success: false, error: `Unknown artifact action: ${action}` }); } return true; } catch (error: any) { - sendResponse(false, undefined, error.message); + respond({ success: false, error: error.message }); return true; } } diff --git a/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts index ee5f9662..671a6d32 100644 --- a/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts +++ b/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts @@ -7,6 +7,7 @@ import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; * * OPTIONAL provider that provides file access APIs to sandboxed code. * Only needed when attachments are present. + * Attachments are read-only snapshot data - no messaging needed. */ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider { constructor(private attachments: Attachment[]) {} @@ -26,9 +27,10 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider { getRuntime(): (sandboxId: string) => void { // This function will be stringified, so no external references! - return (sandboxId: string) => { - // Helper functions for attachments - (window as any).listFiles = () => + // These functions read directly from window.attachments + // Works both online AND offline (no messaging needed!) + return (_sandboxId: string) => { + (window as any).listAttachments = () => ((window as any).attachments || []).map((a: any) => ({ id: a.id, fileName: a.fileName, @@ -36,7 +38,7 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider { size: a.size, })); - (window as any).readTextFile = (attachmentId: string) => { + (window as any).readTextAttachment = (attachmentId: string) => { const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId); if (!a) throw new Error("Attachment not found: " + attachmentId); if (a.extractedText) return a.extractedText; @@ -47,7 +49,7 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider { } }; - (window as any).readBinaryFile = (attachmentId: string) => { + (window as any).readBinaryAttachment = (attachmentId: string) => { const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId); if (!a) throw new Error("Attachment not found: " + attachmentId); const bin = atob(a.content); @@ -55,46 +57,6 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider { 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, - }, - "*", - ); - }; }; } diff --git a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts index d11e00c8..a09a7f44 100644 --- a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts +++ b/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts @@ -1,19 +1,30 @@ import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; +export interface ConsoleLog { + type: "log" | "warn" | "error" | "info"; + text: string; + args?: unknown[]; +} + /** * Console Runtime Provider * * REQUIRED provider that should always be included first. * Provides console capture, error handling, and execution lifecycle management. + * Collects console output for retrieval by caller. */ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { + private logs: ConsoleLog[] = []; + private completionError: { message: string; stack: string } | null = null; + private completed = false; + getData(): Record { // No data needed return {}; } getRuntime(): (sandboxId: string) => void { - return (sandboxId: string) => { + return (_sandboxId: string) => { // Console capture const originalConsole = { log: console.log, @@ -34,16 +45,21 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { }) .join(" "); - window.parent.postMessage( - { - type: "console", - sandboxId, - method, - text, - }, - "*", - ); + // Send to extension if available (online mode) + if ((window as any).sendRuntimeMessage) { + (window as any) + .sendRuntimeMessage({ + type: "console", + method, + text, + args, // Send raw args for provider collection + }) + .catch(() => { + // Ignore errors in fire-and-forget console messages + }); + } + // Always log locally too (originalConsole as any)[method].apply(console, args); }; }); @@ -61,15 +77,15 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { stack: e.error?.stack || text, }; - window.parent.postMessage( - { - type: "console", - sandboxId, - method: "error", - text, - }, - "*", - ); + if ((window as any).sendRuntimeMessage) { + (window as any) + .sendRuntimeMessage({ + type: "console", + method: "error", + text, + }) + .catch(() => {}); + } }); window.addEventListener("unhandledrejection", (e) => { @@ -80,15 +96,15 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { stack: e.reason?.stack || text, }; - window.parent.postMessage( - { - type: "console", - sandboxId, - method: "error", - text, - }, - "*", - ); + if ((window as any).sendRuntimeMessage) { + (window as any) + .sendRuntimeMessage({ + type: "console", + method: "error", + text, + }) + .catch(() => {}); + } }); // Expose complete() method for user code to call @@ -99,23 +115,21 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { const finalError = error || lastError; - if (finalError) { - window.parent.postMessage( - { - type: "execution-error", - sandboxId, - error: finalError, - }, - "*", - ); - } else { - window.parent.postMessage( - { - type: "execution-complete", - sandboxId, - }, - "*", - ); + if ((window as any).sendRuntimeMessage) { + if (finalError) { + (window as any) + .sendRuntimeMessage({ + type: "execution-error", + error: finalError, + }) + .catch(() => {}); + } else { + (window as any) + .sendRuntimeMessage({ + type: "execution-complete", + }) + .catch(() => {}); + } } }; @@ -129,4 +143,66 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { } }; } + + async handleMessage(message: any, _respond: (response: any) => void): Promise { + if (message.type === "console") { + // Collect console output + this.logs.push({ + type: + message.method === "error" + ? "error" + : message.method === "warn" + ? "warn" + : message.method === "info" + ? "info" + : "log", + text: message.text, + args: message.args, + }); + return true; + } + + if (message.type === "execution-complete") { + this.completed = true; + return true; + } + + if (message.type === "execution-error") { + this.completed = true; + this.completionError = message.error; + return true; + } + + return false; + } + + /** + * Get collected console logs + */ + getLogs(): ConsoleLog[] { + return this.logs; + } + + /** + * Get completion status + */ + isCompleted(): boolean { + return this.completed; + } + + /** + * Get completion error if any + */ + getCompletionError(): { message: string; stack: string } | null { + return this.completionError; + } + + /** + * Reset state for reuse + */ + reset(): void { + this.logs = []; + this.completionError = null; + this.completed = false; + } } diff --git a/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts new file mode 100644 index 00000000..2a105efc --- /dev/null +++ b/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts @@ -0,0 +1,113 @@ +import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; + +export interface DownloadableFile { + fileName: string; + content: string | Uint8Array; + mimeType: string; +} + +/** + * File Download Runtime Provider + * + * Provides returnDownloadableFile() for creating user downloads. + * Files returned this way are NOT accessible to the LLM later (one-time download). + * Works both online (sends to extension) and offline (triggers browser download directly). + * Collects files for retrieval by caller. + */ +export class FileDownloadRuntimeProvider implements SandboxRuntimeProvider { + private files: DownloadableFile[] = []; + + getData(): Record { + // No data needed + return {}; + } + + getRuntime(): (sandboxId: string) => void { + return (_sandboxId: string) => { + (window as any).returnDownloadableFile = async (fileName: string, content: any, mimeType?: string) => { + let finalContent: any, finalMimeType: string; + + if (content instanceof Blob) { + const arrayBuffer = await content.arrayBuffer(); + finalContent = new Uint8Array(arrayBuffer); + finalMimeType = mimeType || content.type || "application/octet-stream"; + if (!mimeType && !content.type) { + throw new Error( + "returnDownloadableFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').", + ); + } + } else if (content instanceof Uint8Array) { + finalContent = content; + if (!mimeType) { + throw new Error( + "returnDownloadableFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').", + ); + } + finalMimeType = mimeType; + } else if (typeof content === "string") { + finalContent = content; + finalMimeType = mimeType || "text/plain"; + } else { + finalContent = JSON.stringify(content, null, 2); + finalMimeType = mimeType || "application/json"; + } + + // Send to extension if available (online mode) + if ((window as any).sendRuntimeMessage) { + const response = await (window as any).sendRuntimeMessage({ + type: "file-returned", + fileName, + content: finalContent, + mimeType: finalMimeType, + }); + if (response.error) throw new Error(response.error); + } else { + // Offline mode: trigger browser download directly + const blob = new Blob([finalContent instanceof Uint8Array ? finalContent : finalContent], { + type: finalMimeType, + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + } + }; + }; + } + + async handleMessage(message: any, respond: (response: any) => void): Promise { + if (message.type !== "file-returned") { + return false; + } + + // Collect file for caller + this.files.push({ + fileName: message.fileName, + content: message.content, + mimeType: message.mimeType, + }); + + respond({ success: true }); + return true; + } + + /** + * Get collected files + */ + getFiles(): DownloadableFile[] { + return this.files; + } + + /** + * Reset state for reuse + */ + reset(): void { + this.files = []; + } + + getDescription(): string { + return "returnDownloadableFile(filename, content, mimeType?) - Create downloadable file for user (one-time download, not accessible later)"; + } +} diff --git a/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts b/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts new file mode 100644 index 00000000..c38cc8a5 --- /dev/null +++ b/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts @@ -0,0 +1,74 @@ +/** + * Generates sendRuntimeMessage() function for injection into execution contexts. + * Provides unified messaging API that works in both sandbox iframe and user script contexts. + */ + +export type MessageType = "request-response" | "fire-and-forget"; + +export interface RuntimeMessageBridgeOptions { + context: "sandbox-iframe" | "user-script"; + sandboxId: string; +} + +// biome-ignore lint/complexity/noStaticOnlyClass: fine +export class RuntimeMessageBridge { + /** + * Generate sendRuntimeMessage() function as injectable string. + * Returns the function source code to be injected into target context. + */ + static generateBridgeCode(options: RuntimeMessageBridgeOptions): string { + if (options.context === "sandbox-iframe") { + return RuntimeMessageBridge.generateSandboxBridge(options.sandboxId); + } else { + return RuntimeMessageBridge.generateUserScriptBridge(options.sandboxId); + } + } + + private static generateSandboxBridge(sandboxId: string): string { + // Returns stringified function that uses window.parent.postMessage + return ` +window.sendRuntimeMessage = async (message) => { + const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9); + + return new Promise((resolve, reject) => { + const handler = (e) => { + if (e.data.type === 'runtime-response' && e.data.messageId === messageId) { + window.removeEventListener('message', handler); + if (e.data.success) { + resolve(e.data); + } else { + reject(new Error(e.data.error || 'Operation failed')); + } + } + }; + + window.addEventListener('message', handler); + + window.parent.postMessage({ + ...message, + sandboxId: ${JSON.stringify(sandboxId)}, + messageId: messageId + }, '*'); + + // Timeout after 30s + setTimeout(() => { + window.removeEventListener('message', handler); + reject(new Error('Runtime message timeout')); + }, 30000); + }); +}; +`.trim(); + } + + private static generateUserScriptBridge(sandboxId: string): string { + // Returns stringified function that uses chrome.runtime.sendMessage + return ` +window.sendRuntimeMessage = async (message) => { + return await chrome.runtime.sendMessage({ + ...message, + sandboxId: ${JSON.stringify(sandboxId)} + }); +}; +`.trim(); + } +} diff --git a/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts b/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts new file mode 100644 index 00000000..13674689 --- /dev/null +++ b/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts @@ -0,0 +1,221 @@ +import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; + +// Type declaration for chrome extension API (when available) +declare const chrome: any; + +/** + * Message consumer interface - components that want to receive messages from sandboxes + */ +export interface MessageConsumer { + /** + * Handle a message from a sandbox. + * @returns true if message was consumed (stops propagation), false otherwise + */ + handleMessage(message: any): Promise; +} + +/** + * Sandbox context - tracks active sandboxes and their consumers + */ +interface SandboxContext { + sandboxId: string; + iframe: HTMLIFrameElement | null; // null until setSandboxIframe() or null for user scripts + providers: SandboxRuntimeProvider[]; + consumers: Set; +} + +/** + * Centralized message router for all runtime communication. + * + * This singleton replaces all individual window.addEventListener("message") calls + * with a single global listener that routes messages to the appropriate handlers. + * Also handles user script messages from chrome.runtime.onUserScriptMessage. + * + * Benefits: + * - Single global listener instead of multiple independent listeners + * - Automatic cleanup when sandboxes are destroyed + * - Support for bidirectional communication (providers) and broadcasting (consumers) + * - Works with both sandbox iframes and user scripts + * - Clear lifecycle management + */ +export class RuntimeMessageRouter { + private sandboxes = new Map(); + private messageListener: ((e: MessageEvent) => void) | null = null; + private userScriptMessageListener: + | ((message: any, sender: any, sendResponse: (response: any) => void) => boolean) + | null = null; + + /** + * Register a new sandbox with its runtime providers. + * Call this BEFORE creating the iframe (for sandbox contexts) or executing user script. + */ + registerSandbox(sandboxId: string, providers: SandboxRuntimeProvider[], consumers: MessageConsumer[]): void { + this.sandboxes.set(sandboxId, { + sandboxId, + iframe: null, // Will be set via setSandboxIframe() for sandbox contexts + providers, + consumers: new Set(consumers), + }); + + // Setup global listener if not already done + this.setupListener(); + 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 listeners + if (this.sandboxes.size === 0) { + // Remove iframe listener + if (this.messageListener) { + window.removeEventListener("message", this.messageListener); + this.messageListener = null; + } + + // Remove user script listener + if (this.userScriptMessageListener && typeof chrome !== "undefined" && chrome.runtime?.onUserScriptMessage) { + chrome.runtime.onUserScriptMessage.removeListener(this.userScriptMessageListener); + this.userScriptMessageListener = null; + } + } + 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 listeners (called automatically) + */ + private setupListener(): void { + // Setup sandbox iframe listener + if (!this.messageListener) { + this.messageListener = async (e: MessageEvent) => { + const { sandboxId, messageId } = e.data; + if (!sandboxId) return; + + 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) => { + context.iframe?.contentWindow?.postMessage( + { + type: "runtime-response", + messageId, + sandboxId, + ...response, + }, + "*", + ); + }; + + // 1. Try provider handlers first (for bidirectional comm) + for (const provider of context.providers) { + if (provider.handleMessage) { + const handled = await 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 = await consumer.handleMessage(e.data); + if (consumed) break; // Stop if consumed + } + }; + + window.addEventListener("message", this.messageListener); + } + + // Setup user script message listener + if (!this.userScriptMessageListener) { + // Guard: check if we're in extension context + if (typeof chrome === "undefined" || !chrome.runtime?.onUserScriptMessage) { + console.log("[RuntimeMessageRouter] User script API not available (not in extension context)"); + return; + } + + this.userScriptMessageListener = (message: any, _sender: any, sendResponse: (response: any) => void) => { + const { sandboxId } = message; + if (!sandboxId) return false; + + const context = this.sandboxes.get(sandboxId); + if (!context) return false; + + const respond = (response: any) => { + sendResponse({ + ...response, + sandboxId, + }); + }; + + // Route to providers (async) + (async () => { + for (const provider of context.providers) { + if (provider.handleMessage) { + const handled = await provider.handleMessage(message, respond); + if (handled) return; + } + } + + // Broadcast to consumers + for (const consumer of context.consumers) { + const consumed = await consumer.handleMessage(message); + if (consumed) break; + } + })(); + + return true; // Indicates async response + }; + + chrome.runtime.onUserScriptMessage.addListener(this.userScriptMessageListener); + } + } +} + +/** + * Global singleton instance. + * Import this from wherever you need to interact with the message router. + */ +export const RUNTIME_MESSAGE_ROUTER = new RuntimeMessageRouter(); diff --git a/packages/web-ui/src/components/sandbox/SandboxMessageRouter.ts b/packages/web-ui/src/components/sandbox/SandboxMessageRouter.ts deleted file mode 100644 index d4896a72..00000000 --- a/packages/web-ui/src/components/sandbox/SandboxMessageRouter.ts +++ /dev/null @@ -1,152 +0,0 @@ -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): Promise; -} - -/** - * Sandbox context - tracks active sandboxes and their consumers - */ -interface SandboxContext { - sandboxId: string; - iframe: HTMLIFrameElement | null; // null until setSandboxIframe() - providers: SandboxRuntimeProvider[]; - consumers: Set; -} - -/** - * 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(); - 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 = async (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 = await 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 = await 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(); diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 26311589..77e3cd00 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -35,6 +35,17 @@ export { type SandboxUrlProvider, } from "./components/SandboxedIframe.js"; export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js"; +// Sandbox Runtime Providers +export { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js"; +export { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js"; +export { type ConsoleLog, ConsoleRuntimeProvider } from "./components/sandbox/ConsoleRuntimeProvider.js"; +export { + type DownloadableFile, + FileDownloadRuntimeProvider, +} from "./components/sandbox/FileDownloadRuntimeProvider.js"; +export { RuntimeMessageBridge } from "./components/sandbox/RuntimeMessageBridge.js"; +export { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js"; +export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js"; export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js"; export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js"; // Dialogs diff --git a/packages/web-ui/src/prompts/tool-prompts.ts b/packages/web-ui/src/prompts/tool-prompts.ts index 305475c4..e5e93b0d 100644 --- a/packages/web-ui/src/prompts/tool-prompts.ts +++ b/packages/web-ui/src/prompts/tool-prompts.ts @@ -52,7 +52,7 @@ export const JAVASCRIPT_REPL_CHART_EXAMPLE = ` options: { responsive: false, animation: false } }); const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); - await returnFile('chart.png', blob, 'image/png');`; + await returnDownloadableFile('chart.png', blob, 'image/png');`; export const JAVASCRIPT_REPL_FOOTER = ` @@ -107,20 +107,20 @@ Commands: export const ARTIFACTS_RUNTIME_EXAMPLE = `- Example HTML artifact that processes a CSV attachment: