From 7a54952191ae8d0065a48fd07cfabbd32a79c21a Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Tue, 20 Jan 2026 08:38:37 -0500 Subject: [PATCH] Fix artifact handling - use base64 from response payload Artifacts are returned as base64-encoded data directly in the job response payload, not as URLs. Previous implementation incorrectly tried to fetch artifacts from URLs via a proxy endpoint. Changes: - Remove /api/code/artifacts proxy endpoint (not needed) - Update downloadArtifact() to decode base64 and trigger download - Update viewArtifact() to decode base64 and display inline: * Images: rendered as data URLs (data:image/png;base64,...) * Videos: decoded to blob URLs with controls * Text: decoded and displayed in
 blocks
- Update CLAUDE.md with correct artifact format and implementation details
- Add artifact response format example showing base64 data structure

Artifact format in response:
{
  "artifacts": [{
    "name": "output.png",
    "type": "image/png",
    "data": "base64string...",
    "size": 12345
  }]
}
---
 CLAUDE.md           | 41 ++++++++++++++++++++------
 app.py              | 66 -----------------------------------------
 templates/chat.html | 71 +++++++++++++++++++++++++++++++--------------
 3 files changed, 81 insertions(+), 97 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 733eb26..a2928db 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -231,10 +231,29 @@ lang = un.detect_language("script.py")  # Returns "python"
 
 OpenCompletion supports artifacts generated during code execution (compiled binaries, images, videos, etc.).
 
-**Backend Implementation**:
+**How Artifacts Work**:
 - Pass `artifacts: true` in execution requests to enable artifact collection
-- Artifacts proxy endpoint: `/api/code/artifacts/`
-- Handles authenticated download/viewing of artifacts
+- Artifacts are returned as **base64-encoded data** directly in the job response payload
+- No separate download endpoint needed - artifacts are embedded in the response
+
+**Artifact Response Format**:
+```json
+{
+  "job_id": "job-xxx",
+  "status": "completed",
+  "stdout": "...",
+  "stderr": "...",
+  "exit_code": 0,
+  "artifacts": [
+    {
+      "name": "output.png",
+      "type": "image/png",
+      "data": "base64string...",
+      "size": 12345
+    }
+  ]
+}
+```
 
 **Artifact Types**:
 - **Binaries**: Compiled executables (C, C++, Rust, Go, etc.)
@@ -243,10 +262,13 @@ OpenCompletion supports artifacts generated during code execution (compiled bina
 - **Text/Data**: JSON, CSV, TXT output files
 
 **Frontend Features**:
-- Download button for all artifact types
-- View button for images/videos (disabled for binaries)
-- Inline display of images/videos in chat
-- File size and type information
+- Download button decodes base64 and triggers browser download
+- View button decodes base64 and displays inline:
+  - **Images**: Rendered as data URLs
+  - **Videos**: Rendered as blob URLs with controls
+  - **Text**: Decoded and displayed in formatted `
` blocks
+- View button disabled for binary executables
+- File size and type information displayed
 
 #### Frontend Integration
 
@@ -254,8 +276,9 @@ OpenCompletion supports artifacts generated during code execution (compiled bina
 - Execute code when user clicks play button with `artifacts: true` parameter
 - Display execution results inline below code block
 - Show stdout, stderr, and exit_code separately
-- Display artifacts with download/view buttons
-- Inline viewing of images and videos
+- Display artifacts section with download/view buttons
+- Decode base64 artifacts for inline viewing and downloads
+- Images displayed as data URLs, videos as blob URLs
 - Use syntax highlighting for output
 - Handle timeouts gracefully (60s default)
 - Support language auto-detection for fenced code blocks
diff --git a/app.py b/app.py
index 05d9277..b95e61a 100644
--- a/app.py
+++ b/app.py
@@ -1346,72 +1346,6 @@ def proxy_job_cancel(job_id):
         return jsonify({"error": "Failed to cancel job"}), 500
 
 
-@app.route("/api/code/artifacts/", methods=["GET"])
-def proxy_artifact_download(artifact_url):
-    """Proxy artifact download requests from Unsandbox API.
-
-    Artifacts are files generated during code execution (compiled binaries,
-    images, videos, etc.). This endpoint proxies the download with authentication.
-    """
-    import httpx
-
-    try:
-        # Check if credentials are configured
-        public_key = os.environ.get("UNSANDBOX_PUBLIC_KEY")
-        secret_key = os.environ.get("UNSANDBOX_SECRET_KEY")
-        if not public_key or not secret_key:
-            return jsonify({"error": "Code execution not configured"}), 503
-
-        # Decode the artifact URL (it's passed as part of the path)
-        import urllib.parse
-        full_artifact_url = urllib.parse.unquote(artifact_url)
-
-        # If it's a relative path, make it absolute
-        if not full_artifact_url.startswith("http"):
-            full_artifact_url = f"https://api.unsandbox.com{full_artifact_url}"
-
-        # Make authenticated request to download artifact
-        with httpx.Client(timeout=60.0) as client:
-            # Extract path for signing
-            from urllib.parse import urlparse
-            parsed = urlparse(full_artifact_url)
-            path = parsed.path
-
-            # Sign the request
-            import time
-            import hmac
-            import hashlib
-            timestamp = int(time.time())
-            message = f"{timestamp}:GET:{path}:"
-            signature = hmac.new(
-                secret_key.encode(),
-                message.encode(),
-                hashlib.sha256
-            ).hexdigest()
-
-            headers = {
-                "Authorization": f"Bearer {public_key}",
-                "X-Timestamp": str(timestamp),
-                "X-Signature": signature
-            }
-
-            response = client.get(full_artifact_url, headers=headers)
-            response.raise_for_status()
-
-            # Return the artifact with appropriate content type
-            return Response(
-                response.content,
-                mimetype=response.headers.get("Content-Type", "application/octet-stream"),
-                headers={
-                    "Content-Disposition": response.headers.get("Content-Disposition", "attachment")
-                }
-            )
-
-    except Exception as e:
-        print(f"Error downloading artifact: {e}")
-        return jsonify({"error": "Failed to download artifact"}), 500
-
-
 @app.route("/api/fix-code", methods=["POST"])
 def fix_code():
     """Auto-fix code errors by asking AI to fix issues based on stderr output.
diff --git a/templates/chat.html b/templates/chat.html
index ad4cb50..99924cc 100644
--- a/templates/chat.html
+++ b/templates/chat.html
@@ -2119,18 +2119,33 @@ function formatFileSize(bytes) {
 // Helper function to download artifact
 async function downloadArtifact(artifact) {
     try {
-        const artifactUrl = artifact.url || artifact.download_url;
-        if (!artifactUrl) {
-            alert('Artifact URL not available');
+        // Artifacts come as base64 in the response
+        const base64Data = artifact.data || artifact.content;
+        if (!base64Data) {
+            alert('Artifact data not available');
             return;
         }
 
-        // Use our backend proxy to download with authentication
-        const encodedUrl = encodeURIComponent(artifactUrl);
-        const proxyUrl = `/api/code/artifacts/${encodedUrl}`;
+        // Decode base64 to binary
+        const binaryString = atob(base64Data);
+        const bytes = new Uint8Array(binaryString.length);
+        for (let i = 0; i < binaryString.length; i++) {
+            bytes[i] = binaryString.charCodeAt(i);
+        }
 
-        // Open in new tab to trigger download
-        window.open(proxyUrl, '_blank');
+        // Determine mime type
+        const mimeType = artifact.mime_type || artifact.type || 'application/octet-stream';
+
+        // Create blob and download
+        const blob = new Blob([bytes], { type: mimeType });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = artifact.name || artifact.filename || 'download';
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        URL.revokeObjectURL(url);
     } catch (error) {
         console.error('Error downloading artifact:', error);
         alert('Failed to download artifact: ' + error.message);
@@ -2140,9 +2155,10 @@ async function downloadArtifact(artifact) {
 // Helper function to view artifact inline
 async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
     try {
-        const artifactUrl = artifact.url || artifact.download_url;
-        if (!artifactUrl) {
-            alert('Artifact URL not available');
+        // Artifacts come as base64 in the response
+        const base64Data = artifact.data || artifact.content;
+        if (!base64Data) {
+            alert('Artifact data not available');
             return;
         }
 
@@ -2163,13 +2179,14 @@ async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
         viewerDiv.style.maxWidth = '100%';
         viewerDiv.style.overflow = 'auto';
 
-        // Use our backend proxy for authenticated access
-        const encodedUrl = encodeURIComponent(artifactUrl);
-        const proxyUrl = `/api/code/artifacts/${encodedUrl}`;
+        // Determine mime type
+        const mimeType = artifact.mime_type || artifact.type || 'application/octet-stream';
 
         if (isImage) {
+            // Create data URL from base64
+            const dataUrl = `data:${mimeType};base64,${base64Data}`;
             const img = document.createElement('img');
-            img.src = proxyUrl;
+            img.src = dataUrl;
             img.style.maxWidth = '100%';
             img.style.height = 'auto';
             img.style.display = 'block';
@@ -2179,8 +2196,17 @@ async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
             };
             viewerDiv.appendChild(img);
         } else if (isVideo) {
+            // Create blob URL from base64
+            const binaryString = atob(base64Data);
+            const bytes = new Uint8Array(binaryString.length);
+            for (let i = 0; i < binaryString.length; i++) {
+                bytes[i] = binaryString.charCodeAt(i);
+            }
+            const blob = new Blob([bytes], { type: mimeType });
+            const blobUrl = URL.createObjectURL(blob);
+
             const video = document.createElement('video');
-            video.src = proxyUrl;
+            video.src = blobUrl;
             video.controls = true;
             video.style.maxWidth = '100%';
             video.style.height = 'auto';
@@ -2188,21 +2214,22 @@ async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
             video.onerror = () => {
                 viewerDiv.textContent = 'Failed to load video';
                 viewerDiv.style.color = 'var(--text-error)';
+                URL.revokeObjectURL(blobUrl);
             };
             viewerDiv.appendChild(video);
         } else {
-            // For other types, try to fetch and display as text
-            const response = await fetch(proxyUrl);
-            if (response.ok) {
-                const text = await response.text();
+            // For text types, decode and display
+            try {
+                const binaryString = atob(base64Data);
+                const text = decodeURIComponent(escape(binaryString));
                 const pre = document.createElement('pre');
                 pre.style.margin = '0';
                 pre.style.whiteSpace = 'pre-wrap';
                 pre.style.wordWrap = 'break-word';
                 pre.textContent = text;
                 viewerDiv.appendChild(pre);
-            } else {
-                viewerDiv.textContent = 'Failed to load content';
+            } catch (decodeError) {
+                viewerDiv.textContent = 'Failed to decode content';
                 viewerDiv.style.color = 'var(--text-error)';
             }
         }