Add comprehensive artifact support for code execution

Implement full artifact support for Unsandbox code execution, enabling
download and inline viewing of generated files (binaries, images, videos).

Backend changes (app.py):
- Update /api/code/execute to accept artifacts parameter
- Add /api/code/artifacts/<path> proxy endpoint for authenticated downloads
- Use SDK's _make_request for full parameter support
- Support artifacts in execution request body

Frontend changes (templates/chat.html):
- Pass artifacts=true in all code execution requests
- Display artifacts section with file info (name, type, size)
- Add download button for all artifact types
- Add view button with inline display for images/videos (disabled for binaries)
- Implement formatFileSize, downloadArtifact, viewArtifact helper functions
- Images and videos render directly in chat
- Text files display in formatted <pre> blocks

Documentation (CLAUDE.md):
- Document artifact support section
- List supported artifact types
- Describe frontend and backend features
- Update frontend integration notes

Artifact types supported:
- Compiled binaries (executables from C, C++, Rust, Go, etc.)
- Images (PNG, JPG, GIF, SVG)
- Videos (MP4, WebM)
- Text/data files (JSON, CSV, TXT)
This commit is contained in:
russell@unturf.com 2026-01-20 08:34:35 -05:00
parent 27a0df3d12
commit 5576179ffa
3 changed files with 310 additions and 20 deletions

View file

@ -227,12 +227,35 @@ languages = un.get_languages()
lang = un.detect_language("script.py") # Returns "python" 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/<encoded_url>`
- 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 #### Frontend Integration
- Add play button (▶) next to copy button on code blocks - 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 - Display execution results inline below code block
- Show stdout, stderr, and exit_code separately - Show stdout, stderr, and exit_code separately
- Display artifacts with download/view buttons
- Inline viewing of images and videos
- Use syntax highlighting for output - Use syntax highlighting for output
- Handle timeouts gracefully (60s default) - Handle timeouts gracefully (60s default)
- Support language auto-detection for fenced code blocks - Support language auto-detection for fenced code blocks

112
app.py
View file

@ -1255,6 +1255,7 @@ def proxy_code_execute():
Keeps UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY secure on the server side. Keeps UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY secure on the server side.
Uses official Unsandbox Python SDK for authentication and execution. Uses official Unsandbox Python SDK for authentication and execution.
Supports artifacts parameter for compiled binaries, images, etc.
""" """
try: try:
data = request.get_json() data = request.get_json()
@ -1262,34 +1263,45 @@ def proxy_code_execute():
return jsonify({"error": "Request body required"}), 400 return jsonify({"error": "Request body required"}), 400
# Check if credentials are configured # 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 return jsonify({"error": "Code execution not configured"}), 503
# Extract parameters from request # Extract parameters from request
language = data.get("language") language = data.get("language")
code = data.get("code") 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: if not language or not code:
return jsonify({"error": "Language and code are required"}), 400 return jsonify({"error": "Language and code are required"}), 400
# Use SDK's execute_async method # Build request body with all supported parameters
result = un.execute_async( request_body = {
language=language, "language": language,
code=code, "code": code
env=env, }
network_mode=network_mode,
ttl=ttl # 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 # Return job_id from response
if isinstance(result, str): return jsonify({"job_id": result.get("job_id")}), 200
return jsonify({"job_id": result}), 200
else:
# Result is already a dict (possibly with error)
return jsonify(result), 200
except Exception as e: except Exception as e:
print(f"Error proxying code execution: {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 return jsonify({"error": "Failed to cancel job"}), 500
@app.route("/api/code/artifacts/<path:artifact_url>", 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"]) @app.route("/api/fix-code", methods=["POST"])
def fix_code(): def fix_code():
"""Auto-fix code errors by asking AI to fix issues based on stderr output. """Auto-fix code errors by asking AI to fix issues based on stderr output.

View file

@ -1708,7 +1708,7 @@ async function executeCodeBlock(code, blockElement, playButton) {
body: JSON.stringify({ body: JSON.stringify({
language: language, language: language,
code: code, 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 // Format and display results
let outputHtml = ''; 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 actualStdout = result.stdout || '';
const actualStderr = result.stderr || ''; const actualStderr = result.stderr || '';
const exitCode = result.exit_code; const exitCode = result.exit_code;
const artifacts = result.artifacts || [];
// Show language // Show language
if (language) { if (language) {
@ -1943,6 +1944,87 @@ function displayExecutionResults(result, resultsContainer, language, code) {
resultsContainer.innerHTML = outputHtml; 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) // Find the download binary button (it's in the button container next to the Run button)
// resultsContainer is inside <pre>, button container is the next sibling of <pre> // resultsContainer is inside <pre>, button container is the next sibling of <pre>
const preElement = resultsContainer.parentNode; 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 // Helper function to escape HTML
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');