diff --git a/CLAUDE.md b/CLAUDE.md index 60f8ef4..733eb26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,12 +227,35 @@ languages = un.get_languages() lang = un.detect_language("script.py") # Returns "python" ``` +#### Artifact Support + +OpenCompletion supports artifacts generated during code execution (compiled binaries, images, videos, etc.). + +**Backend Implementation**: +- Pass `artifacts: true` in execution requests to enable artifact collection +- Artifacts proxy endpoint: `/api/code/artifacts/` +- Handles authenticated download/viewing of artifacts + +**Artifact Types**: +- **Binaries**: Compiled executables (C, C++, Rust, Go, etc.) +- **Images**: PNG, JPG, GIF, SVG generated by code +- **Videos**: MP4, WebM, etc. generated by code +- **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 + #### Frontend Integration - Add play button (▶) next to copy button on code blocks -- Execute code when user clicks play button +- 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 - 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 f1b7a49..05d9277 100644 --- a/app.py +++ b/app.py @@ -1255,6 +1255,7 @@ def proxy_code_execute(): Keeps UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY secure on the server side. Uses official Unsandbox Python SDK for authentication and execution. + Supports artifacts parameter for compiled binaries, images, etc. """ try: data = request.get_json() @@ -1262,34 +1263,45 @@ def proxy_code_execute(): return jsonify({"error": "Request body required"}), 400 # Check if credentials are configured - if not os.environ.get("UNSANDBOX_PUBLIC_KEY") or not os.environ.get("UNSANDBOX_SECRET_KEY"): + 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 # Extract parameters from request language = data.get("language") code = data.get("code") - env = data.get("env") - network_mode = data.get("network_mode", "zerotrust") - ttl = data.get("ttl", 60) if not language or not code: return jsonify({"error": "Language and code are required"}), 400 - # Use SDK's execute_async method - result = un.execute_async( - language=language, - code=code, - env=env, - network_mode=network_mode, - ttl=ttl + # Build request body with all supported parameters + request_body = { + "language": language, + "code": code + } + + # Add optional parameters if provided + if data.get("env"): + request_body["env"] = data.get("env") + if data.get("network_mode"): + request_body["network_mode"] = data.get("network_mode") + if data.get("ttl"): + request_body["ttl"] = data.get("ttl") + if data.get("artifacts"): + request_body["artifacts"] = data.get("artifacts") + + # Use SDK's internal _make_request for full parameter support + result = un._make_request( + "POST", + "/execute", + public_key, + secret_key, + request_body ) - # SDK returns the job_id directly as a string, or a dict with error - if isinstance(result, str): - return jsonify({"job_id": result}), 200 - else: - # Result is already a dict (possibly with error) - return jsonify(result), 200 + # Return job_id from response + return jsonify({"job_id": result.get("job_id")}), 200 except Exception as e: print(f"Error proxying code execution: {e}") @@ -1334,6 +1346,72 @@ 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 55d88a9..ad4cb50 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -1708,7 +1708,7 @@ async function executeCodeBlock(code, blockElement, playButton) { body: JSON.stringify({ language: language, code: code, - return_artifact: true // Request compiled binary for compiled languages + artifacts: true // Request artifacts (binaries, images, videos, etc) }) }); @@ -1908,10 +1908,11 @@ function displayExecutionResults(result, resultsContainer, language, code) { // Format and display results let outputHtml = ''; - // Unsandbox API returns flat structure: {success, stdout, stderr, exit_code} + // Unsandbox API returns flat structure: {success, stdout, stderr, exit_code, artifacts} const actualStdout = result.stdout || ''; const actualStderr = result.stderr || ''; const exitCode = result.exit_code; + const artifacts = result.artifacts || []; // Show language if (language) { @@ -1943,6 +1944,87 @@ function displayExecutionResults(result, resultsContainer, language, code) { resultsContainer.innerHTML = outputHtml; + // Handle artifacts (binaries, images, videos, etc.) + if (artifacts && artifacts.length > 0) { + const artifactsDiv = document.createElement('div'); + artifactsDiv.style.marginTop = '12px'; + artifactsDiv.style.borderTop = '1px solid var(--border-color)'; + artifactsDiv.style.paddingTop = '12px'; + + const artifactsTitle = document.createElement('div'); + artifactsTitle.style.color = 'var(--text-info)'; + artifactsTitle.style.fontWeight = 'bold'; + artifactsTitle.style.marginBottom = '8px'; + artifactsTitle.textContent = `Artifacts (${artifacts.length}):`; + artifactsDiv.appendChild(artifactsTitle); + + artifacts.forEach((artifact, index) => { + const artifactItem = document.createElement('div'); + artifactItem.style.marginBottom = '8px'; + artifactItem.style.padding = '8px'; + artifactItem.style.backgroundColor = 'var(--bg-secondary)'; + artifactItem.style.borderRadius = '4px'; + + // Artifact name/filename + const artifactName = document.createElement('div'); + artifactName.style.fontWeight = 'bold'; + artifactName.style.marginBottom = '4px'; + artifactName.textContent = artifact.name || artifact.filename || `Artifact ${index + 1}`; + artifactItem.appendChild(artifactName); + + // Artifact type/size info + if (artifact.type || artifact.size) { + const artifactInfo = document.createElement('div'); + artifactInfo.style.fontSize = '12px'; + artifactInfo.style.color = 'var(--text-muted)'; + artifactInfo.style.marginBottom = '8px'; + let infoText = ''; + if (artifact.type) infoText += `Type: ${artifact.type}`; + if (artifact.size) infoText += ` | Size: ${formatFileSize(artifact.size)}`; + artifactInfo.textContent = infoText; + artifactItem.appendChild(artifactInfo); + } + + // Buttons container + const buttonsDiv = document.createElement('div'); + buttonsDiv.style.display = 'flex'; + buttonsDiv.style.gap = '8px'; + + // Determine if artifact is viewable (images, videos, text) + const mimeType = artifact.mime_type || artifact.type || ''; + const isImage = mimeType.startsWith('image/'); + const isVideo = mimeType.startsWith('video/'); + const isBinary = mimeType.includes('octet-stream') || mimeType.includes('executable'); + + // Download button (always available) + const downloadBtn = document.createElement('button'); + downloadBtn.textContent = '⬇ Download'; + downloadBtn.style.padding = '4px 8px'; + downloadBtn.style.fontSize = '12px'; + downloadBtn.onclick = () => downloadArtifact(artifact); + buttonsDiv.appendChild(downloadBtn); + + // View button (disabled for binaries) + const viewBtn = document.createElement('button'); + viewBtn.textContent = '👁 View'; + viewBtn.style.padding = '4px 8px'; + viewBtn.style.fontSize = '12px'; + if (isBinary) { + viewBtn.disabled = true; + viewBtn.style.opacity = '0.5'; + viewBtn.style.cursor = 'not-allowed'; + } else { + viewBtn.onclick = () => viewArtifact(artifact, artifactItem, isImage, isVideo); + } + buttonsDiv.appendChild(viewBtn); + + artifactItem.appendChild(buttonsDiv); + artifactsDiv.appendChild(artifactItem); + }); + + resultsContainer.appendChild(artifactsDiv); + } + // Find the download binary button (it's in the button container next to the Run button) // resultsContainer is inside
, button container is the next sibling of 
     const preElement = resultsContainer.parentNode;
@@ -2025,6 +2107,113 @@ function displayExecutionResults(result, resultsContainer, language, code) {
     }
 }
 
+// Helper function to format file size
+function formatFileSize(bytes) {
+    if (bytes === 0) return '0 Bytes';
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
+}
+
+// Helper function to download artifact
+async function downloadArtifact(artifact) {
+    try {
+        const artifactUrl = artifact.url || artifact.download_url;
+        if (!artifactUrl) {
+            alert('Artifact URL not available');
+            return;
+        }
+
+        // Use our backend proxy to download with authentication
+        const encodedUrl = encodeURIComponent(artifactUrl);
+        const proxyUrl = `/api/code/artifacts/${encodedUrl}`;
+
+        // Open in new tab to trigger download
+        window.open(proxyUrl, '_blank');
+    } catch (error) {
+        console.error('Error downloading artifact:', error);
+        alert('Failed to download artifact: ' + error.message);
+    }
+}
+
+// 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');
+            return;
+        }
+
+        // Check if already viewing
+        const existingViewer = artifactItem.querySelector('.artifact-viewer');
+        if (existingViewer) {
+            existingViewer.remove();
+            return;
+        }
+
+        // Create viewer container
+        const viewerDiv = document.createElement('div');
+        viewerDiv.classList.add('artifact-viewer');
+        viewerDiv.style.marginTop = '8px';
+        viewerDiv.style.padding = '8px';
+        viewerDiv.style.backgroundColor = 'var(--bg-primary)';
+        viewerDiv.style.borderRadius = '4px';
+        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}`;
+
+        if (isImage) {
+            const img = document.createElement('img');
+            img.src = proxyUrl;
+            img.style.maxWidth = '100%';
+            img.style.height = 'auto';
+            img.style.display = 'block';
+            img.onerror = () => {
+                viewerDiv.textContent = 'Failed to load image';
+                viewerDiv.style.color = 'var(--text-error)';
+            };
+            viewerDiv.appendChild(img);
+        } else if (isVideo) {
+            const video = document.createElement('video');
+            video.src = proxyUrl;
+            video.controls = true;
+            video.style.maxWidth = '100%';
+            video.style.height = 'auto';
+            video.style.display = 'block';
+            video.onerror = () => {
+                viewerDiv.textContent = 'Failed to load video';
+                viewerDiv.style.color = 'var(--text-error)';
+            };
+            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();
+                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';
+                viewerDiv.style.color = 'var(--text-error)';
+            }
+        }
+
+        artifactItem.appendChild(viewerDiv);
+    } catch (error) {
+        console.error('Error viewing artifact:', error);
+        alert('Failed to view artifact: ' + error.message);
+    }
+}
+
 // Helper function to escape HTML
 function escapeHtml(text) {
     const div = document.createElement('div');