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 <pre> 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
  }]
}
This commit is contained in:
russell@unturf.com 2026-01-20 08:38:37 -05:00
parent 5576179ffa
commit 7a54952191
3 changed files with 81 additions and 97 deletions

View file

@ -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/<encoded_url>`
- 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 `<pre>` 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

66
app.py
View file

@ -1346,72 +1346,6 @@ def proxy_job_cancel(job_id):
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"])
def fix_code():
"""Auto-fix code errors by asking AI to fix issues based on stderr output.

View file

@ -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)';
}
}