mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 23:04:41 +00:00
Add runtime bridge architecture and fix HTML escaping
Major refactoring to unify runtime providers across sandbox and user script contexts: 1. Runtime Bridge & Router - Add RuntimeMessageBridge for unified messaging abstraction - Rename SandboxMessageRouter → RuntimeMessageRouter - Router now handles both iframe and user script messages - Guard for non-extension environments 2. Provider Refactoring - ArtifactsRuntimeProvider: Add offline mode with snapshot fallback - AttachmentsRuntimeProvider: Remove returnDownloadableFile (moved to dedicated provider) - ConsoleRuntimeProvider: Add message collection, remove lifecycle logic - FileDownloadRuntimeProvider: New provider for file downloads 3. HTML Escaping Fix - Escape </script> in JSON.stringify output to prevent premature tag closure - Applies when injecting provider data into <script> tags - JavaScript engine automatically unescapes, no runtime changes needed 4. Function Renaming - listFiles → listAttachments - readTextFile → readTextAttachment - readBinaryFile → readBinaryAttachment - returnFile → returnDownloadableFile 5. Updated Exports - Export new RuntimeMessageBridge and RuntimeMessageRouter - Export FileDownloadRuntimeProvider - Update all cross-references This sets the foundation for reusing providers in browser-javascript tool.
This commit is contained in:
parent
d7d79bd533
commit
c2793d8017
11 changed files with 722 additions and 385 deletions
|
|
@ -1,7 +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 { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.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";
|
import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
|
||||||
|
|
||||||
export interface SandboxFile {
|
export interface SandboxFile {
|
||||||
|
|
@ -65,16 +66,17 @@ export class SandboxIframe extends LitElement {
|
||||||
): void {
|
): void {
|
||||||
// Unregister previous sandbox if exists
|
// Unregister previous sandbox if exists
|
||||||
try {
|
try {
|
||||||
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||||
} catch {
|
} catch {
|
||||||
// Sandbox might not exist, that's ok
|
// Sandbox might not exist, that's ok
|
||||||
}
|
}
|
||||||
|
|
||||||
providers = [new ConsoleRuntimeProvider(), ...providers];
|
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
|
// Remove previous iframe if exists
|
||||||
this.iframe?.remove();
|
this.iframe?.remove();
|
||||||
|
|
@ -99,7 +101,7 @@ export class SandboxIframe extends LitElement {
|
||||||
this.iframe.src = this.sandboxUrlProvider!();
|
this.iframe.src = this.sandboxUrlProvider!();
|
||||||
|
|
||||||
// Update router with iframe reference BEFORE appending to DOM
|
// 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
|
// Listen for sandbox-ready message directly
|
||||||
const readyHandler = (e: MessageEvent) => {
|
const readyHandler = (e: MessageEvent) => {
|
||||||
|
|
@ -134,7 +136,7 @@ export class SandboxIframe extends LitElement {
|
||||||
this.iframe.srcdoc = completeHtml;
|
this.iframe.srcdoc = completeHtml;
|
||||||
|
|
||||||
// Update router with iframe reference BEFORE appending to DOM
|
// 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);
|
this.appendChild(this.iframe);
|
||||||
}
|
}
|
||||||
|
|
@ -154,13 +156,14 @@ export class SandboxIframe extends LitElement {
|
||||||
providers: SandboxRuntimeProvider[] = [],
|
providers: SandboxRuntimeProvider[] = [],
|
||||||
consumers: MessageConsumer[] = [],
|
consumers: MessageConsumer[] = [],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
isHtmlArtifact: boolean = false,
|
||||||
): Promise<SandboxResult> {
|
): Promise<SandboxResult> {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new Error("Execution aborted");
|
throw new Error("Execution aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
providers = [new ConsoleRuntimeProvider(), ...providers];
|
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 logs: Array<{ type: string; text: string }> = [];
|
||||||
const files: SandboxFile[] = [];
|
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 = () => {
|
const cleanup = () => {
|
||||||
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||||
signal?.removeEventListener("abort", abortHandler);
|
signal?.removeEventListener("abort", abortHandler);
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
this.iframe?.remove();
|
this.iframe?.remove();
|
||||||
|
|
@ -236,7 +239,7 @@ export class SandboxIframe extends LitElement {
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
// 4. Prepare HTML and create iframe
|
// 4. Prepare HTML and create iframe
|
||||||
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers);
|
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers, isHtmlArtifact);
|
||||||
|
|
||||||
if (this.sandboxUrlProvider) {
|
if (this.sandboxUrlProvider) {
|
||||||
// Browser extension mode: wait for sandbox-ready
|
// Browser extension mode: wait for sandbox-ready
|
||||||
|
|
@ -246,7 +249,7 @@ export class SandboxIframe extends LitElement {
|
||||||
this.iframe.src = this.sandboxUrlProvider();
|
this.iframe.src = this.sandboxUrlProvider();
|
||||||
|
|
||||||
// Update router with iframe reference BEFORE appending to DOM
|
// 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
|
// Listen for sandbox-ready message directly
|
||||||
const readyHandler = (e: MessageEvent) => {
|
const readyHandler = (e: MessageEvent) => {
|
||||||
|
|
@ -276,7 +279,7 @@ export class SandboxIframe extends LitElement {
|
||||||
this.iframe.srcdoc = completeHtml;
|
this.iframe.srcdoc = completeHtml;
|
||||||
|
|
||||||
// Update router with iframe reference BEFORE appending to DOM
|
// 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);
|
this.appendChild(this.iframe);
|
||||||
}
|
}
|
||||||
|
|
@ -287,14 +290,18 @@ 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
|
* 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
|
// Runtime script that will be injected
|
||||||
const runtime = this.getRuntimeScript(sandboxId, providers);
|
const runtime = this.getRuntimeScript(sandboxId, providers);
|
||||||
|
|
||||||
// Check if user provided full HTML
|
// Only check for HTML tags if explicitly marked as HTML artifact
|
||||||
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
|
// For javascript_repl, userCode is JavaScript that may contain HTML in string literals
|
||||||
|
if (isHtmlArtifact) {
|
||||||
if (hasHtmlTag) {
|
|
||||||
// HTML Artifact - inject runtime into existing HTML
|
// HTML Artifact - inject runtime into existing HTML
|
||||||
const headMatch = userCode.match(/<head[^>]*>/i);
|
const headMatch = userCode.match(/<head[^>]*>/i);
|
||||||
if (headMatch) {
|
if (headMatch) {
|
||||||
|
|
@ -347,20 +354,31 @@ export class SandboxIframe extends LitElement {
|
||||||
Object.assign(allData, provider.getData());
|
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
|
// Collect all runtime functions - pass sandboxId as string literal
|
||||||
const runtimeFunctions: string[] = [];
|
const runtimeFunctions: string[] = [];
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
runtimeFunctions.push(`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`);
|
runtimeFunctions.push(`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build script
|
// Build script with HTML escaping
|
||||||
|
// Escape </script> to prevent premature tag closure in HTML parser
|
||||||
const dataInjection = Object.entries(allData)
|
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");
|
.join("\n");
|
||||||
|
|
||||||
return `<script>
|
return `<script>
|
||||||
window.sandboxId = ${JSON.stringify(sandboxId)};
|
window.sandboxId = ${JSON.stringify(sandboxId)};
|
||||||
${dataInjection}
|
${dataInjection}
|
||||||
|
${bridgeCode}
|
||||||
${runtimeFunctions.join("\n")}
|
${runtimeFunctions.join("\n")}
|
||||||
</script>`;
|
</script>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||||
*
|
*
|
||||||
* Provides programmatic access to session artifacts from sandboxed code.
|
* Provides programmatic access to session artifacts from sandboxed code.
|
||||||
* Allows code to create, read, update, and delete artifacts dynamically.
|
* Allows code to create, read, update, and delete artifacts dynamically.
|
||||||
|
* Supports both online (extension) and offline (downloaded HTML) modes.
|
||||||
*/
|
*/
|
||||||
export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -17,54 +18,59 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getData(): Record<string, any> {
|
getData(): Record<string, any> {
|
||||||
// No initial data injection needed - artifacts are accessed via async functions
|
// Inject artifact snapshot for offline mode
|
||||||
return {};
|
const snapshot: Record<string, string> = {};
|
||||||
|
const artifacts = this.getArtifactsFn();
|
||||||
|
artifacts.forEach((artifact, filename) => {
|
||||||
|
snapshot[filename] = artifact.content;
|
||||||
|
});
|
||||||
|
return { artifacts: snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
getRuntime(): (sandboxId: string) => void {
|
getRuntime(): (sandboxId: string) => void {
|
||||||
// This function will be stringified, so no external references!
|
// This function will be stringified, so no external references!
|
||||||
return (sandboxId: string) => {
|
return (_sandboxId: string) => {
|
||||||
// Helper to send message and wait for response
|
|
||||||
const sendArtifactMessage = (action: string, data: any): Promise<any> => {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-parse/stringify for .json files
|
// Auto-parse/stringify for .json files
|
||||||
const isJsonFile = (filename: string) => filename.endsWith(".json");
|
const isJsonFile = (filename: string) => filename.endsWith(".json");
|
||||||
|
|
||||||
(window as any).hasArtifact = async (filename: string): Promise<boolean> => {
|
(window as any).hasArtifact = async (filename: string): Promise<boolean> => {
|
||||||
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<any> => {
|
(window as any).getArtifact = async (filename: string): Promise<any> => {
|
||||||
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
|
// Auto-parse .json files
|
||||||
if (isJsonFile(filename)) {
|
if (isJsonFile(filename)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -77,45 +83,62 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
};
|
};
|
||||||
|
|
||||||
(window as any).createArtifact = async (filename: string, content: any, mimeType?: string): Promise<void> => {
|
(window as any).createArtifact = async (filename: string, content: any, mimeType?: string): Promise<void> => {
|
||||||
let finalContent = content;
|
if (!(window as any).sendRuntimeMessage) {
|
||||||
let finalMimeType = mimeType;
|
throw new Error("Cannot create artifacts in offline mode (read-only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalContent = content;
|
||||||
// Auto-stringify .json files
|
// Auto-stringify .json files
|
||||||
if (isJsonFile(filename) && typeof content !== "string") {
|
if (isJsonFile(filename) && typeof content !== "string") {
|
||||||
finalContent = JSON.stringify(content, null, 2);
|
finalContent = JSON.stringify(content, null, 2);
|
||||||
finalMimeType = mimeType || "application/json";
|
} else if (typeof content !== "string") {
|
||||||
} else if (typeof content === "string") {
|
|
||||||
finalContent = content;
|
|
||||||
finalMimeType = mimeType || "text/plain";
|
|
||||||
} else {
|
|
||||||
finalContent = JSON.stringify(content, null, 2);
|
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<void> => {
|
(window as any).updateArtifact = async (filename: string, content: any, mimeType?: string): Promise<void> => {
|
||||||
let finalContent = content;
|
if (!(window as any).sendRuntimeMessage) {
|
||||||
let finalMimeType = mimeType;
|
throw new Error("Cannot update artifacts in offline mode (read-only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalContent = content;
|
||||||
// Auto-stringify .json files
|
// Auto-stringify .json files
|
||||||
if (isJsonFile(filename) && typeof content !== "string") {
|
if (isJsonFile(filename) && typeof content !== "string") {
|
||||||
finalContent = JSON.stringify(content, null, 2);
|
finalContent = JSON.stringify(content, null, 2);
|
||||||
finalMimeType = mimeType || "application/json";
|
} else if (typeof content !== "string") {
|
||||||
} else if (typeof content === "string") {
|
|
||||||
finalContent = content;
|
|
||||||
finalMimeType = mimeType || "text/plain";
|
|
||||||
} else {
|
|
||||||
finalContent = JSON.stringify(content, null, 2);
|
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<void> => {
|
(window as any).deleteArtifact = async (filename: string): Promise<void> => {
|
||||||
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { action, data, messageId } = message;
|
const { action, filename, content, mimeType } = message;
|
||||||
|
|
||||||
const sendResponse = (success: boolean, result?: any, error?: string) => {
|
|
||||||
respond({
|
|
||||||
type: "artifact-response",
|
|
||||||
messageId,
|
|
||||||
success,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "has": {
|
case "has": {
|
||||||
const artifacts = this.getArtifactsFn();
|
const artifacts = this.getArtifactsFn();
|
||||||
const exists = artifacts.has(data.filename);
|
const exists = artifacts.has(filename);
|
||||||
sendResponse(true, exists);
|
respond({ success: true, result: exists });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "get": {
|
case "get": {
|
||||||
const artifacts = this.getArtifactsFn();
|
const artifacts = this.getArtifactsFn();
|
||||||
const artifact = artifacts.get(data.filename);
|
const artifact = artifacts.get(filename);
|
||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
sendResponse(false, undefined, `Artifact not found: ${data.filename}`);
|
respond({ success: false, error: `Artifact not found: ${filename}` });
|
||||||
} else {
|
} else {
|
||||||
sendResponse(true, artifact.content);
|
respond({ success: true, result: artifact.content });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "create": {
|
case "create": {
|
||||||
try {
|
try {
|
||||||
// Note: mimeType parameter is ignored - artifact type is inferred from filename extension
|
await this.createArtifactFn(filename, content, filename);
|
||||||
// Third parameter is title, defaults to filename
|
|
||||||
await this.createArtifactFn(data.filename, data.content, data.filename);
|
|
||||||
// Append artifact message for session persistence
|
|
||||||
this.appendMessageFn?.({
|
this.appendMessageFn?.({
|
||||||
role: "artifact",
|
role: "artifact",
|
||||||
action: "create",
|
action: "create",
|
||||||
filename: data.filename,
|
filename,
|
||||||
content: data.content,
|
content,
|
||||||
title: data.filename,
|
title: filename,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
sendResponse(true);
|
respond({ success: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
sendResponse(false, undefined, err.message);
|
respond({ success: false, error: err.message });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "update": {
|
case "update": {
|
||||||
try {
|
try {
|
||||||
// Note: mimeType parameter is ignored - artifact type is inferred from filename extension
|
await this.updateArtifactFn(filename, content, filename);
|
||||||
// Third parameter is title, defaults to filename
|
|
||||||
await this.updateArtifactFn(data.filename, data.content, data.filename);
|
|
||||||
// Append artifact message for session persistence
|
|
||||||
this.appendMessageFn?.({
|
this.appendMessageFn?.({
|
||||||
role: "artifact",
|
role: "artifact",
|
||||||
action: "update",
|
action: "update",
|
||||||
filename: data.filename,
|
filename,
|
||||||
content: data.content,
|
content,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
sendResponse(true);
|
respond({ success: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
sendResponse(false, undefined, err.message);
|
respond({ success: false, error: err.message });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "delete": {
|
case "delete": {
|
||||||
try {
|
try {
|
||||||
await this.deleteArtifactFn(data.filename);
|
await this.deleteArtifactFn(filename);
|
||||||
// Append artifact message for session persistence
|
|
||||||
this.appendMessageFn?.({
|
this.appendMessageFn?.({
|
||||||
role: "artifact",
|
role: "artifact",
|
||||||
action: "delete",
|
action: "delete",
|
||||||
filename: data.filename,
|
filename,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
sendResponse(true);
|
respond({ success: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
sendResponse(false, undefined, err.message);
|
respond({ success: false, error: err.message });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
sendResponse(false, undefined, `Unknown artifact action: ${action}`);
|
respond({ success: false, error: `Unknown artifact action: ${action}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
sendResponse(false, undefined, error.message);
|
respond({ success: false, error: error.message });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||||
*
|
*
|
||||||
* OPTIONAL provider that provides file access APIs to sandboxed code.
|
* OPTIONAL provider that provides file access APIs to sandboxed code.
|
||||||
* Only needed when attachments are present.
|
* Only needed when attachments are present.
|
||||||
|
* Attachments are read-only snapshot data - no messaging needed.
|
||||||
*/
|
*/
|
||||||
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
constructor(private attachments: Attachment[]) {}
|
constructor(private attachments: Attachment[]) {}
|
||||||
|
|
@ -26,9 +27,10 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
|
|
||||||
getRuntime(): (sandboxId: string) => void {
|
getRuntime(): (sandboxId: string) => void {
|
||||||
// This function will be stringified, so no external references!
|
// This function will be stringified, so no external references!
|
||||||
return (sandboxId: string) => {
|
// These functions read directly from window.attachments
|
||||||
// Helper functions for attachments
|
// Works both online AND offline (no messaging needed!)
|
||||||
(window as any).listFiles = () =>
|
return (_sandboxId: string) => {
|
||||||
|
(window as any).listAttachments = () =>
|
||||||
((window as any).attachments || []).map((a: any) => ({
|
((window as any).attachments || []).map((a: any) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
fileName: a.fileName,
|
fileName: a.fileName,
|
||||||
|
|
@ -36,7 +38,7 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
size: a.size,
|
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);
|
const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId);
|
||||||
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
||||||
if (a.extractedText) return a.extractedText;
|
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);
|
const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId);
|
||||||
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
if (!a) throw new Error("Attachment not found: " + attachmentId);
|
||||||
const bin = atob(a.content);
|
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);
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
return bytes;
|
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,
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,30 @@
|
||||||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||||
|
|
||||||
|
export interface ConsoleLog {
|
||||||
|
type: "log" | "warn" | "error" | "info";
|
||||||
|
text: string;
|
||||||
|
args?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Console Runtime Provider
|
* Console Runtime Provider
|
||||||
*
|
*
|
||||||
* REQUIRED provider that should always be included first.
|
* REQUIRED provider that should always be included first.
|
||||||
* Provides console capture, error handling, and execution lifecycle management.
|
* Provides console capture, error handling, and execution lifecycle management.
|
||||||
|
* Collects console output for retrieval by caller.
|
||||||
*/
|
*/
|
||||||
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
|
private logs: ConsoleLog[] = [];
|
||||||
|
private completionError: { message: string; stack: string } | null = null;
|
||||||
|
private completed = false;
|
||||||
|
|
||||||
getData(): Record<string, any> {
|
getData(): Record<string, any> {
|
||||||
// No data needed
|
// No data needed
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
getRuntime(): (sandboxId: string) => void {
|
getRuntime(): (sandboxId: string) => void {
|
||||||
return (sandboxId: string) => {
|
return (_sandboxId: string) => {
|
||||||
// Console capture
|
// Console capture
|
||||||
const originalConsole = {
|
const originalConsole = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
|
|
@ -34,16 +45,21 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
})
|
})
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
window.parent.postMessage(
|
// Send to extension if available (online mode)
|
||||||
{
|
if ((window as any).sendRuntimeMessage) {
|
||||||
type: "console",
|
(window as any)
|
||||||
sandboxId,
|
.sendRuntimeMessage({
|
||||||
method,
|
type: "console",
|
||||||
text,
|
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);
|
(originalConsole as any)[method].apply(console, args);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -61,15 +77,15 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
stack: e.error?.stack || text,
|
stack: e.error?.stack || text,
|
||||||
};
|
};
|
||||||
|
|
||||||
window.parent.postMessage(
|
if ((window as any).sendRuntimeMessage) {
|
||||||
{
|
(window as any)
|
||||||
type: "console",
|
.sendRuntimeMessage({
|
||||||
sandboxId,
|
type: "console",
|
||||||
method: "error",
|
method: "error",
|
||||||
text,
|
text,
|
||||||
},
|
})
|
||||||
"*",
|
.catch(() => {});
|
||||||
);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("unhandledrejection", (e) => {
|
window.addEventListener("unhandledrejection", (e) => {
|
||||||
|
|
@ -80,15 +96,15 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
stack: e.reason?.stack || text,
|
stack: e.reason?.stack || text,
|
||||||
};
|
};
|
||||||
|
|
||||||
window.parent.postMessage(
|
if ((window as any).sendRuntimeMessage) {
|
||||||
{
|
(window as any)
|
||||||
type: "console",
|
.sendRuntimeMessage({
|
||||||
sandboxId,
|
type: "console",
|
||||||
method: "error",
|
method: "error",
|
||||||
text,
|
text,
|
||||||
},
|
})
|
||||||
"*",
|
.catch(() => {});
|
||||||
);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expose complete() method for user code to call
|
// Expose complete() method for user code to call
|
||||||
|
|
@ -99,23 +115,21 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
|
|
||||||
const finalError = error || lastError;
|
const finalError = error || lastError;
|
||||||
|
|
||||||
if (finalError) {
|
if ((window as any).sendRuntimeMessage) {
|
||||||
window.parent.postMessage(
|
if (finalError) {
|
||||||
{
|
(window as any)
|
||||||
type: "execution-error",
|
.sendRuntimeMessage({
|
||||||
sandboxId,
|
type: "execution-error",
|
||||||
error: finalError,
|
error: finalError,
|
||||||
},
|
})
|
||||||
"*",
|
.catch(() => {});
|
||||||
);
|
} else {
|
||||||
} else {
|
(window as any)
|
||||||
window.parent.postMessage(
|
.sendRuntimeMessage({
|
||||||
{
|
type: "execution-complete",
|
||||||
type: "execution-complete",
|
})
|
||||||
sandboxId,
|
.catch(() => {});
|
||||||
},
|
}
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -129,4 +143,66 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleMessage(message: any, _respond: (response: any) => void): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string, any> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
221
packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts
Normal file
221
packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts
Normal file
|
|
@ -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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<MessageConsumer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, SandboxContext>();
|
||||||
|
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();
|
||||||
|
|
@ -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<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 = 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();
|
|
||||||
|
|
@ -35,6 +35,17 @@ export {
|
||||||
type SandboxUrlProvider,
|
type SandboxUrlProvider,
|
||||||
} from "./components/SandboxedIframe.js";
|
} from "./components/SandboxedIframe.js";
|
||||||
export { StreamingMessageContainer } from "./components/StreamingMessageContainer.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 { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
|
||||||
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
|
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
|
||||||
// Dialogs
|
// Dialogs
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export const JAVASCRIPT_REPL_CHART_EXAMPLE = `
|
||||||
options: { responsive: false, animation: false }
|
options: { responsive: false, animation: false }
|
||||||
});
|
});
|
||||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
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 = `
|
export const JAVASCRIPT_REPL_FOOTER = `
|
||||||
|
|
||||||
|
|
@ -107,20 +107,20 @@ Commands:
|
||||||
export const ARTIFACTS_RUNTIME_EXAMPLE = `- Example HTML artifact that processes a CSV attachment:
|
export const ARTIFACTS_RUNTIME_EXAMPLE = `- Example HTML artifact that processes a CSV attachment:
|
||||||
<script>
|
<script>
|
||||||
// List available files
|
// List available files
|
||||||
const files = listFiles();
|
const files = listAttachments();
|
||||||
console.log('Available files:', files);
|
console.log('Available files:', files);
|
||||||
|
|
||||||
// Find CSV file
|
// Find CSV file
|
||||||
const csvFile = files.find(f => f.mimeType === 'text/csv');
|
const csvFile = files.find(f => f.mimeType === 'text/csv');
|
||||||
if (csvFile) {
|
if (csvFile) {
|
||||||
const csvContent = readTextFile(csvFile.id);
|
const csvContent = readTextAttachment(csvFile.id);
|
||||||
// Process CSV data...
|
// Process CSV data...
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display image
|
// Display image
|
||||||
const imageFile = files.find(f => f.mimeType.startsWith('image/'));
|
const imageFile = files.find(f => f.mimeType.startsWith('image/'));
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
const bytes = readBinaryFile(imageFile.id);
|
const bytes = readBinaryAttachment(imageFile.id);
|
||||||
const blob = new Blob([bytes], {type: imageFile.mimeType});
|
const blob = new Blob([bytes], {type: imageFile.mimeType});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
document.body.innerHTML = '<img src="' + url + '">';
|
document.body.innerHTML = '<img src="' + url + '">';
|
||||||
|
|
@ -223,29 +223,37 @@ Example:
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const ATTACHMENTS_RUNTIME_DESCRIPTION = `
|
export const ATTACHMENTS_RUNTIME_DESCRIPTION = `
|
||||||
Global variables:
|
User Attachments (files the user added to the conversation):
|
||||||
- attachments[] - Array of attachment objects from user messages
|
- listAttachments() - List all attachments, returns array of {id, fileName, mimeType, size}
|
||||||
* Properties:
|
* Example: const files = listAttachments(); // [{id: '...', fileName: 'data.xlsx', mimeType: '...', size: 12345}]
|
||||||
- id: string (unique identifier)
|
- readTextAttachment(attachmentId) - Read attachment as text, returns string
|
||||||
- fileName: string (e.g., "data.xlsx")
|
* Use for: CSV, JSON, TXT, XML, and other text-based files
|
||||||
- mimeType: string (e.g., "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
* Example: const csvContent = readTextAttachment(files[0].id);
|
||||||
- size: number (bytes)
|
* Example: const json = JSON.parse(readTextAttachment(jsonFile.id));
|
||||||
* Helper functions:
|
- readBinaryAttachment(attachmentId) - Read attachment as binary data, returns Uint8Array
|
||||||
- listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
|
* Use for: Excel (.xlsx), images, PDFs, and other binary files
|
||||||
- readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
|
* Example: const xlsxBytes = readBinaryAttachment(files[0].id);
|
||||||
- readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
|
* Example: const XLSX = await import('https://esm.run/xlsx'); const workbook = XLSX.read(xlsxBytes);
|
||||||
* Examples:
|
|
||||||
- const files = listFiles();
|
Downloadable Files (one-time downloads for the user - YOU cannot read these back):
|
||||||
- const csvContent = readTextFile(files[0].id); // Read CSV as text
|
- await returnDownloadableFile(filename, content, mimeType?) - Create downloadable file (async!)
|
||||||
- const xlsxBytes = readBinaryFile(files[0].id); // Read Excel as binary
|
* Use for: Processed/transformed data, generated images, analysis results
|
||||||
- await returnFile(filename, content, mimeType?) - Create downloadable files (async function!)
|
* Important: This creates a download for the user. You will NOT be able to access this file's content later.
|
||||||
* Always use await with returnFile
|
* If you need to access the data later, use createArtifact() instead (if available).
|
||||||
|
* Always use await with returnDownloadableFile
|
||||||
* REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
|
* REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
|
||||||
If omitted, the REPL throws an Error with stack trace pointing to the offending line.
|
If omitted, throws an Error with stack trace pointing to the offending line.
|
||||||
* Strings without a MIME default to text/plain.
|
* Strings without a MIME default to text/plain.
|
||||||
* Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
|
* Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
|
||||||
* Canvas images: Use toBlob() with await Promise wrapper
|
* Canvas images: Use toBlob() with await Promise wrapper
|
||||||
* Examples:
|
* Examples:
|
||||||
- await returnFile('data.txt', 'Hello World', 'text/plain')
|
- await returnDownloadableFile('cleaned-data.csv', csvString, 'text/csv')
|
||||||
- await returnFile('data.json', {key: 'value'}, 'application/json')
|
- await returnDownloadableFile('analysis.json', {results: [...]}, 'application/json')
|
||||||
- await returnFile('data.csv', 'name,age\\nJohn,30', 'text/csv')`;
|
- await returnDownloadableFile('chart.png', blob, 'image/png')
|
||||||
|
|
||||||
|
Common pattern - Process attachment and create download:
|
||||||
|
const files = listAttachments();
|
||||||
|
const csvFile = files.find(f => f.fileName.endsWith('.csv'));
|
||||||
|
const csvData = readTextAttachment(csvFile.id);
|
||||||
|
// Process csvData...
|
||||||
|
await returnDownloadableFile('processed-' + csvFile.fileName, processedData, 'text/csv');`;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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 MessageConsumer, SANDBOX_MESSAGE_ROUTER } from "../../components/sandbox/SandboxMessageRouter.js";
|
import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "../../components/sandbox/RuntimeMessageRouter.js";
|
||||||
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.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";
|
||||||
|
|
@ -48,7 +48,7 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
const sandbox = this.sandboxIframeRef.value;
|
const sandbox = this.sandboxIframeRef.value;
|
||||||
const sandboxId = `artifact-${this.filename}`;
|
const sandboxId = `artifact-${this.filename}`;
|
||||||
const downloadContent =
|
const downloadContent =
|
||||||
sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || []) || this._content;
|
sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || [], true) || this._content;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -115,7 +115,7 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
// Unregister sandbox when element is removed from DOM
|
// Unregister sandbox when element is removed from DOM
|
||||||
const sandboxId = `artifact-${this.filename}`;
|
const sandboxId = `artifact-${this.filename}`;
|
||||||
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||||
}
|
}
|
||||||
|
|
||||||
override firstUpdated() {
|
override firstUpdated() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue