diff --git a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts index a09a7f44..148c4062 100644 --- a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts +++ b/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts @@ -25,7 +25,7 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { getRuntime(): (sandboxId: string) => void { return (_sandboxId: string) => { - // Console capture + // Console capture with immediate send + completion batch pattern const originalConsole = { log: console.log, error: console.error, @@ -33,6 +33,9 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { info: console.info, }; + // Track pending logs (not yet confirmed sent) + const pendingLogs: Array<{ method: string; text: string; args: any[] }> = []; + ["log", "error", "warn", "info"].forEach((method) => { (console as any)[method] = (...args: any[]) => { const text = args @@ -45,7 +48,12 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { }) .join(" "); - // Send to extension if available (online mode) + const logEntry = { method, text, args }; + + // Add to pending logs + pendingLogs.push(logEntry); + + // Try to send immediately (fire-and-forget) if ((window as any).sendRuntimeMessage) { (window as any) .sendRuntimeMessage({ @@ -54,8 +62,15 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { text, args, // Send raw args for provider collection }) + .then(() => { + // Remove from pending on successful send + const index = pendingLogs.indexOf(logEntry); + if (index !== -1) { + pendingLogs.splice(index, 1); + } + }) .catch(() => { - // Ignore errors in fire-and-forget console messages + // Keep in pending array if send fails }); } @@ -64,6 +79,25 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { }; }); + // Register completion callback to send any remaining logs + if ((window as any).onCompleted) { + (window as any).onCompleted(async (_success: boolean) => { + // Send any logs that haven't been sent yet + if (pendingLogs.length > 0 && (window as any).sendRuntimeMessage) { + await Promise.all( + pendingLogs.map((logEntry) => + (window as any).sendRuntimeMessage({ + type: "console", + method: logEntry.method, + text: logEntry.text, + args: logEntry.args, + }), + ), + ); + } + }); + } + // Track errors for HTML artifacts let lastError: { message: string; stack: string } | null = null; diff --git a/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts b/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts index c38cc8a5..b2e754e5 100644 --- a/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts +++ b/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts @@ -27,6 +27,7 @@ export class RuntimeMessageBridge { private static generateSandboxBridge(sandboxId: string): string { // Returns stringified function that uses window.parent.postMessage return ` +window.__completionCallbacks = []; window.sendRuntimeMessage = async (message) => { const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9); @@ -57,18 +58,25 @@ window.sendRuntimeMessage = async (message) => { }, 30000); }); }; +window.onCompleted = (callback) => { + window.__completionCallbacks.push(callback); +}; `.trim(); } private static generateUserScriptBridge(sandboxId: string): string { // Returns stringified function that uses chrome.runtime.sendMessage return ` +window.__completionCallbacks = []; window.sendRuntimeMessage = async (message) => { return await chrome.runtime.sendMessage({ ...message, sandboxId: ${JSON.stringify(sandboxId)} }); }; +window.onCompleted = (callback) => { + window.__completionCallbacks.push(callback); +}; `.trim(); } } diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 77e3cd00..cb8db7e0 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -54,7 +54,10 @@ export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js"; export { SessionListDialog } from "./dialogs/SessionListDialog.js"; export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js"; // Prompts -export { ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION } from "./prompts/tool-prompts.js"; +export { + ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION, + DOWNLOADABLE_FILE_RUNTIME_DESCRIPTION, +} from "./prompts/tool-prompts.js"; // Storage export { AppStorage, getAppStorage, setAppStorage } from "./storage/app-storage.js"; export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js"; diff --git a/packages/web-ui/src/prompts/tool-prompts.ts b/packages/web-ui/src/prompts/tool-prompts.ts index e5e93b0d..761a5129 100644 --- a/packages/web-ui/src/prompts/tool-prompts.ts +++ b/packages/web-ui/src/prompts/tool-prompts.ts @@ -218,6 +218,27 @@ Example: await createArtifact('image.png', base64); `; +// ============================================================================ +// Downloadable File Runtime Provider +// ============================================================================ + +export const DOWNLOADABLE_FILE_RUNTIME_DESCRIPTION = ` +Downloadable Files (one-time downloads for the user - YOU cannot read these back): +- await returnDownloadableFile(filename, content, mimeType?) - Create downloadable file (async!) + * Use for: Processed/transformed data, generated images, analysis results + * Important: This creates a download for the user. You will NOT be able to access this file's content later. + * 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"). + If omitted, throws an Error with stack trace pointing to the offending line. + * Strings without a MIME default to text/plain. + * Objects are auto-JSON stringified and default to application/json unless a MIME is provided. + * Canvas images: Use toBlob() with await Promise wrapper + * Examples: + - await returnDownloadableFile('cleaned-data.csv', csvString, 'text/csv') + - await returnDownloadableFile('analysis.json', {results: [...]}, 'application/json') + - await returnDownloadableFile('chart.png', blob, 'image/png')`; + // ============================================================================ // Attachments Runtime Provider // ============================================================================ @@ -235,22 +256,6 @@ User Attachments (files the user added to the conversation): * Example: const xlsxBytes = readBinaryAttachment(files[0].id); * Example: const XLSX = await import('https://esm.run/xlsx'); const workbook = XLSX.read(xlsxBytes); -Downloadable Files (one-time downloads for the user - YOU cannot read these back): -- await returnDownloadableFile(filename, content, mimeType?) - Create downloadable file (async!) - * Use for: Processed/transformed data, generated images, analysis results - * Important: This creates a download for the user. You will NOT be able to access this file's content later. - * 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"). - If omitted, throws an Error with stack trace pointing to the offending line. - * Strings without a MIME default to text/plain. - * Objects are auto-JSON stringified and default to application/json unless a MIME is provided. - * Canvas images: Use toBlob() with await Promise wrapper - * Examples: - - await returnDownloadableFile('cleaned-data.csv', csvString, 'text/csv') - - await returnDownloadableFile('analysis.json', {results: [...]}, 'application/json') - - 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'));