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.). 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 - Pass `artifacts: true` in execution requests to enable artifact collection
- Artifacts proxy endpoint: `/api/code/artifacts/<encoded_url>` - Artifacts are returned as **base64-encoded data** directly in the job response payload
- Handles authenticated download/viewing of artifacts - 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**: **Artifact Types**:
- **Binaries**: Compiled executables (C, C++, Rust, Go, etc.) - **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 - **Text/Data**: JSON, CSV, TXT output files
**Frontend Features**: **Frontend Features**:
- Download button for all artifact types - Download button decodes base64 and triggers browser download
- View button for images/videos (disabled for binaries) - View button decodes base64 and displays inline:
- Inline display of images/videos in chat - **Images**: Rendered as data URLs
- File size and type information - **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 #### 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 - 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 - Display artifacts section with download/view buttons
- Inline viewing of images and videos - Decode base64 artifacts for inline viewing and downloads
- Images displayed as data URLs, videos as blob URLs
- 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

66
app.py
View file

@ -1346,72 +1346,6 @@ 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

@ -2119,18 +2119,33 @@ function formatFileSize(bytes) {
// Helper function to download artifact // Helper function to download artifact
async function downloadArtifact(artifact) { async function downloadArtifact(artifact) {
try { try {
const artifactUrl = artifact.url || artifact.download_url; // Artifacts come as base64 in the response
if (!artifactUrl) { const base64Data = artifact.data || artifact.content;
alert('Artifact URL not available'); if (!base64Data) {
alert('Artifact data not available');
return; return;
} }
// Use our backend proxy to download with authentication // Decode base64 to binary
const encodedUrl = encodeURIComponent(artifactUrl); const binaryString = atob(base64Data);
const proxyUrl = `/api/code/artifacts/${encodedUrl}`; 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 // Determine mime type
window.open(proxyUrl, '_blank'); 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) { } catch (error) {
console.error('Error downloading artifact:', error); console.error('Error downloading artifact:', error);
alert('Failed to download artifact: ' + error.message); alert('Failed to download artifact: ' + error.message);
@ -2140,9 +2155,10 @@ async function downloadArtifact(artifact) {
// Helper function to view artifact inline // Helper function to view artifact inline
async function viewArtifact(artifact, artifactItem, isImage, isVideo) { async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
try { try {
const artifactUrl = artifact.url || artifact.download_url; // Artifacts come as base64 in the response
if (!artifactUrl) { const base64Data = artifact.data || artifact.content;
alert('Artifact URL not available'); if (!base64Data) {
alert('Artifact data not available');
return; return;
} }
@ -2163,13 +2179,14 @@ async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
viewerDiv.style.maxWidth = '100%'; viewerDiv.style.maxWidth = '100%';
viewerDiv.style.overflow = 'auto'; viewerDiv.style.overflow = 'auto';
// Use our backend proxy for authenticated access // Determine mime type
const encodedUrl = encodeURIComponent(artifactUrl); const mimeType = artifact.mime_type || artifact.type || 'application/octet-stream';
const proxyUrl = `/api/code/artifacts/${encodedUrl}`;
if (isImage) { if (isImage) {
// Create data URL from base64
const dataUrl = `data:${mimeType};base64,${base64Data}`;
const img = document.createElement('img'); const img = document.createElement('img');
img.src = proxyUrl; img.src = dataUrl;
img.style.maxWidth = '100%'; img.style.maxWidth = '100%';
img.style.height = 'auto'; img.style.height = 'auto';
img.style.display = 'block'; img.style.display = 'block';
@ -2179,8 +2196,17 @@ async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
}; };
viewerDiv.appendChild(img); viewerDiv.appendChild(img);
} else if (isVideo) { } 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'); const video = document.createElement('video');
video.src = proxyUrl; video.src = blobUrl;
video.controls = true; video.controls = true;
video.style.maxWidth = '100%'; video.style.maxWidth = '100%';
video.style.height = 'auto'; video.style.height = 'auto';
@ -2188,21 +2214,22 @@ async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
video.onerror = () => { video.onerror = () => {
viewerDiv.textContent = 'Failed to load video'; viewerDiv.textContent = 'Failed to load video';
viewerDiv.style.color = 'var(--text-error)'; viewerDiv.style.color = 'var(--text-error)';
URL.revokeObjectURL(blobUrl);
}; };
viewerDiv.appendChild(video); viewerDiv.appendChild(video);
} else { } else {
// For other types, try to fetch and display as text // For text types, decode and display
const response = await fetch(proxyUrl); try {
if (response.ok) { const binaryString = atob(base64Data);
const text = await response.text(); const text = decodeURIComponent(escape(binaryString));
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.style.margin = '0'; pre.style.margin = '0';
pre.style.whiteSpace = 'pre-wrap'; pre.style.whiteSpace = 'pre-wrap';
pre.style.wordWrap = 'break-word'; pre.style.wordWrap = 'break-word';
pre.textContent = text; pre.textContent = text;
viewerDiv.appendChild(pre); viewerDiv.appendChild(pre);
} else { } catch (decodeError) {
viewerDiv.textContent = 'Failed to load content'; viewerDiv.textContent = 'Failed to decode content';
viewerDiv.style.color = 'var(--text-error)'; viewerDiv.style.color = 'var(--text-error)';
} }
} }