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:
parent
27a0df3d12
commit
5576179ffa
3 changed files with 310 additions and 20 deletions
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -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
112
app.py
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue