Add web UI and S3 upload endpoints

- Web UI: novice-friendly interface at / and /ui
- Upload endpoint: /upload for file uploads
- S3 upload: /upload/s3 for uploading to S3/MinIO
- CLI: opus ui command to start web UI only
- Full HTML/CSS/JS interface with drag-drop, tabs, etc.
This commit is contained in:
2026-03-13 03:55:10 +00:00
parent 45b5af079c
commit f946bb3714
3 changed files with 812 additions and 2 deletions
+45 -1
View File
@@ -315,6 +315,26 @@ Examples:
help="Enable auto-reload on code changes",
)
# -------------------------------------------------------------------------
# UI COMMAND (Web Interface)
# -------------------------------------------------------------------------
ui_parser = subparsers.add_parser(
"ui",
help="Start web UI only (no API)",
description="Start the novice-friendly web interface",
)
ui_parser.add_argument(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
ui_parser.add_argument(
"--port", "-p",
type=int,
default=8080,
help="Port to bind to (default: 8080)",
)
# -------------------------------------------------------------------------
# INGEST COMMAND (GitHub)
# -------------------------------------------------------------------------
@@ -771,6 +791,29 @@ async def run_serve(args: argparse.Namespace) -> int:
return 0
async def run_ui(args: argparse.Namespace) -> int:
"""Start the web UI only."""
print(f"\n🎨 Starting Opus Web UI...")
print(f" Host: {args.host}")
print(f" Port: {args.port}")
print(f" UI: http://{args.host}:{args.port}/\n")
try:
from opus_orchestrator.server import create_app
import uvicorn
app = create_app(include_ui=True)
config = uvicorn.Config(app, host=args.host, port=args.port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
except ImportError as e:
print(f"Error: {e}")
print("Run `pip install fastapi uvicorn` to enable web UI")
return 1
return 0
def run_ingest(args: argparse.Namespace) -> int:
"""Ingest content from GitHub."""
from opus_orchestrator import OpusOrchestrator
@@ -1035,6 +1078,7 @@ async def main_async(args: argparse.Namespace) -> int:
commands = {
"generate": run_generate,
"serve": run_serve,
"ui": run_ui,
"ingest": run_ingest,
"ingest-s3": run_s3_ingest,
"ingest-local": run_local_ingest,
@@ -1045,7 +1089,7 @@ async def main_async(args: argparse.Namespace) -> int:
}
if args.command in commands:
if args.command in ["generate", "serve"]:
if args.command in ["generate", "serve", "ui"]:
return await commands[args.command](args)
else:
return commands[args.command](args)
+92 -1
View File
@@ -97,7 +97,7 @@ async def lifespan(app: FastAPI):
# CREATE APP
# =============================================================================
def create_app() -> FastAPI:
def create_app(include_ui: bool = True) -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(
title="Opus Orchestrator API",
@@ -110,6 +110,7 @@ def create_app() -> FastAPI:
- **AutoGen Critique**: Multi-agent debate for editorial feedback
- **PydanticAI Validation**: Structured output validation
- **GitHub Ingestion**: Pull content from repositories
- **S3 Upload**: Upload manuscripts to S3-compatible storage
## Quick Start
@@ -126,6 +127,13 @@ curl -X POST "http://localhost:8000/ingest" \\
-H "Content-Type: application/json" \\
-d '{"repo": "owner/my-book-notes"}'
```
3. Upload to S3:
```bash
curl -X POST "http://localhost:8000/upload/s3" \\
-H "Content-Type: application/json" \\
-d '{"content": "# My Manuscript", "bucket": "my-bucket", "key": "output/story.md"}'
```
""",
version="0.2.0",
lifespan=lifespan,
@@ -134,6 +142,11 @@ curl -X POST "http://localhost:8000/ingest" \\
openapi_url="/openapi.json",
)
# Add web UI if requested
if include_ui:
from opus_orchestrator.web_ui import create_web_ui
create_web_ui(app)
return app
@@ -261,6 +274,84 @@ async def ingest(request: IngestRequest):
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# UPLOAD ENDPOINTS
# =============================================================================
class UploadResponse(BaseModel):
"""Response from file upload."""
filename: str
content: str
size: int
status: str
class S3UploadRequest(BaseModel):
"""Request to upload content to S3."""
content: str
bucket: str
key: str
endpoint_url: Optional[str] = None
class S3UploadResponse(BaseModel):
"""Response from S3 upload."""
bucket: str
key: str
url: str
status: str
@app.post("/upload", response_model=UploadResponse, tags=["upload"])
async def upload_file(file: UploadFile = File(...)):
"""Upload a file for processing."""
try:
content = await file.read()
text_content = content.decode("utf-8")
return UploadResponse(
filename=file.filename,
content=text_content,
size=len(content),
status="success",
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/upload/s3", response_model=S3UploadResponse, tags=["upload"])
async def upload_to_s3(request: S3UploadRequest):
"""Upload content to S3-compatible storage."""
try:
from opus_orchestrator import S3Ingestor
# Create S3 ingestor
s3 = S3Ingestor(endpoint_url=request.endpoint_url)
# Upload using boto3 directly
s3.s3_client.put_object(
Bucket=request.bucket,
Key=request.key,
Body=request.content.encode("utf-8"),
ContentType="text/markdown",
)
# Build URL
if request.endpoint_url:
url = f"{request.endpoint_url}/{request.bucket}/{request.key}"
else:
url = f"s3://{request.bucket}/{request.key}"
return S3UploadResponse(
bucket=request.bucket,
key=request.key,
url=url,
status="success",
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# SERVER RUNNER
# =============================================================================
+675
View File
@@ -0,0 +1,675 @@
"""Web UI for Opus Orchestrator.
A simple, novice-friendly web interface for generating manuscripts.
"""
import os
import asyncio
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from dotenv import load_dotenv
load_dotenv("/home/solaria/.openclaw/workspace/opus-orchestrator-ai/.env")
# HTML Template for the UI
WEB_UI_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opus Orchestrator - AI Book Generator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
color: #e4e4e7;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 40px;
}
h1 {
font-size: 2.5rem;
background: linear-gradient(90deg, #a855f7, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 10px;
}
.subtitle {
color: #9ca3af;
font-size: 1.1rem;
}
.card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 30px;
margin-bottom: 24px;
backdrop-filter: blur(10px);
}
.card h2 {
font-size: 1.3rem;
margin-bottom: 20px;
color: #a855f7;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #d1d5db;
}
input[type="text"],
input[type="number"],
textarea,
select {
width: 100%;
padding: 12px 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #e4e4e7;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: #a855f7;
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
}
textarea {
min-height: 120px;
resize: vertical;
}
.row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.col {
flex: 1;
min-width: 200px;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(90deg, #a855f7, #ec4899);
color: white;
width: 100%;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(168, 85, 247, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
}
.tab.active {
background: rgba(168, 85, 247, 0.2);
border-color: #a855f7;
color: #a855f7;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.source-input {
display: none;
}
.source-input.active {
display: block;
}
.output-section {
display: none;
}
.output-section.active {
display: block;
}
#manuscript {
min-height: 300px;
font-family: 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.7;
white-space: pre-wrap;
}
.progress {
display: none;
text-align: center;
padding: 40px;
}
.progress.active {
display: block;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(168, 85, 247, 0.2);
border-top-color: #a855f7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status {
color: #9ca3af;
font-size: 1.1rem;
}
.success-message {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
color: #22c55e;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
color: #ef4444;
}
.file-drop {
border: 2px dashed rgba(168, 85, 247, 0.4);
border-radius: 12px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.file-drop:hover, .file-drop.dragover {
border-color: #a855f7;
background: rgba(168, 85, 247, 0.1);
}
.file-drop input {
display: none;
}
.file-info {
margin-top: 10px;
color: #22c55e;
font-size: 0.9rem;
}
footer {
text-align: center;
margin-top: 40px;
color: #6b7280;
font-size: 0.9rem;
}
footer a {
color: #a855f7;
text-decoration: none;
}
.features {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-top: 20px;
}
.feature {
flex: 1;
min-width: 150px;
padding: 16px;
background: rgba(168, 85, 247, 0.1);
border-radius: 8px;
text-align: center;
}
.feature-icon {
font-size: 1.5rem;
margin-bottom: 8px;
}
.feature-name {
font-weight: 600;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>📚 Opus Orchestrator</h1>
<p class="subtitle">AI-Powered Book Generation</p>
<div class="features">
<div class="feature">
<div class="feature-icon">🎯</div>
<div class="feature-name">7 Story Frameworks</div>
</div>
<div class="feature">
<div class="feature-icon">🤖</div>
<div class="feature-name">AI Critique</div>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Cloud Storage</div>
</div>
</div>
</header>
<div class="card">
<h2>Generate Your Manuscript</h2>
<form id="generateForm">
<!-- Source Type Tabs -->
<div class="tabs">
<button type="button" class="tab active" data-source="concept">💡 Idea</button>
<button type="button" class="tab" data-source="github">🐙 GitHub</button>
<button type="button" class="tab" data-source="s3">🪣 S3</button>
<button type="button" class="tab" data-source="upload">📁 Upload</button>
</div>
<!-- Source Inputs -->
<div class="source-input active" id="concept-input">
<div class="form-group">
<label>Your Story Concept</label>
<textarea name="concept" placeholder="A robot dreams of electric sheep and discovers love in the last library on Earth..."></textarea>
</div>
</div>
<div class="source-input" id="github-input">
<div class="form-group">
<label>GitHub Repository</label>
<input type="text" name="repo" placeholder="owner/repository">
</div>
</div>
<div class="source-input" id="s3-input">
<div class="row">
<div class="col">
<div class="form-group">
<label>S3 Bucket</label>
<input type="text" name="s3_bucket" placeholder="my-bucket">
</div>
</div>
<div class="col">
<div class="form-group">
<label>Path Prefix</label>
<input type="text" name="s3_prefix" placeholder="notes/">
</div>
</div>
</div>
<div class="form-group">
<label>Endpoint (optional, for MinIO/DO Spaces)</label>
<input type="text" name="s3_endpoint" placeholder="https://nyc3.digitaloceanspaces.com">
</div>
</div>
<div class="source-input" id="upload-input">
<div class="form-group">
<label>Upload Files</label>
<div class="file-drop" id="fileDrop">
<input type="file" id="fileInput" name="files" multiple accept=".txt,.md,.markdown,.notes">
<p>📂 Drag & drop files here<br>or click to browse</p>
<div class="file-info" id="fileInfo"></div>
</div>
</div>
</div>
<!-- Generation Options -->
<div class="row">
<div class="col">
<div class="form-group">
<label>Framework</label>
<select name="framework">
<option value="snowflake">Snowflake Method</option>
<option value="hero-journey">Hero's Journey</option>
<option value="three-act">Three-Act Structure</option>
<option value="save-the-cat">Save the Cat</option>
<option value="story-circle">Story Circle</option>
<option value="seven-point">7-Point Plot</option>
<option value="fichtean">Fichtean Curve</option>
</select>
</div>
</div>
<div class="col">
<div class="form-group">
<label>Genre</label>
<select name="genre">
<option value="fiction">Fiction</option>
<option value="science-fiction">Science Fiction</option>
<option value="fantasy">Fantasy</option>
<option value="romance">Romance</option>
<option value="mystery">Mystery</option>
<option value="thriller">Thriller</option>
<option value="literary">Literary Fiction</option>
<option value="nonfiction">Nonfiction</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group">
<label>Target Words</label>
<input type="number" name="words" value="5000" min="100" max="200000">
</div>
</div>
<div class="col">
<div class="form-group">
<label>Chapters</label>
<input type="number" name="chapters" value="3" min="1" max="50">
</div>
</div>
</div>
<!-- Output Options -->
<div class="form-group">
<label>Save Output To</label>
<select name="output_dest">
<option value="">Download (browser)</option>
<option value="s3">S3 / MinIO</option>
<option value="github">GitHub Repository</option>
</select>
</div>
<div id="s3-output-options" style="display:none;">
<div class="row">
<div class="col">
<input type="text" name="output_s3_bucket" placeholder="Output bucket">
</div>
<div class="col">
<input type="text" name="output_s3_path" placeholder="Path (e.g., manuscripts/)">
</div>
</div>
</div>
<div id="github-output-options" style="display:none;">
<input type="text" name="output_repo" placeholder="owner/repository" style="margin-bottom:10px;">
</div>
<button type="submit" class="btn btn-primary" id="generateBtn">
Generate Manuscript
</button>
</form>
</div>
<!-- Progress -->
<div class="card progress" id="progress">
<div class="spinner"></div>
<p class="status" id="statusText">Initializing...</p>
</div>
<!-- Output -->
<div class="card output-section" id="output">
<h2>Your Manuscript</h2>
<div id="successMessage"></div>
<pre id="manuscript"></pre>
<button class="btn btn-primary" id="downloadBtn" style="margin-top:20px;">
📥 Download
</button>
<button class="btn" id="copyBtn" style="margin-top:20px;background:rgba(255,255,255,0.1);color:#e4e4e7;">
📋 Copy
</button>
</div>
<footer>
<p>Powered by <a href="https://github.com/mrhavens/opus-orchestrator-ai">Opus Orchestrator AI</a></p>
<p>Built with LangGraph, CrewAI, AutoGen, and PydanticAI</p>
</footer>
</div>
<script>
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.source-input').forEach(i => i.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.source + '-input').classList.add('active');
});
});
// Output destination switching
document.querySelector('select[name="output_dest"]').addEventListener('change', (e) => {
document.getElementById('s3-output-options').style.display = e.target.value === 's3' ? 'flex' : 'none';
document.getElementById('github-output-options').style.display = e.target.value === 'github' ? 'block' : 'none';
});
// File upload
const fileInput = document.getElementById('fileInput');
const fileDrop = document.getElementById('fileDrop');
const fileInfo = document.getElementById('fileInfo');
fileDrop.addEventListener('click', () => fileInput.click());
fileDrop.addEventListener('dragover', (e) => {
e.preventDefault();
fileDrop.classList.add('dragover');
});
fileDrop.addEventListener('dragleave', () => fileDrop.classList.remove('dragover'));
fileDrop.addEventListener('drop', (e) => {
e.preventDefault();
fileDrop.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => handleFiles(fileInput.files));
function handleFiles(files) {
if (files.length > 0) {
fileInfo.textContent = ` ${files.length} file(s) selected`;
}
}
// Form submission
const form = document.getElementById('generateForm');
const generateBtn = document.getElementById('generateBtn');
const progress = document.getElementById('progress');
const statusText = document.getElementById('statusText');
const outputSection = document.getElementById('output');
const manuscriptEl = document.getElementById('manuscript');
const successMessage = document.getElementById('successMessage');
form.addEventListener('submit', async (e) => {
e.preventDefault();
generateBtn.disabled = true;
progress.classList.add('active');
outputSection.classList.remove('active');
successMessage.innerHTML = '';
const formData = new FormData(form);
// Determine source type
const activeTab = document.querySelector('.tab.active').dataset.source;
const payload = {
framework: formData.get('framework'),
genre: formData.get('genre'),
target_word_count: parseInt(formData.get('words')),
chapters: parseInt(formData.get('chapters')),
};
// Add source based on active tab
if (activeTab === 'concept') {
payload.concept = formData.get('concept');
} else if (activeTab === 'github') {
payload.repo = formData.get('repo');
} else if (activeTab === 's3') {
// For S3, we'd need additional API support
statusText.textContent = 'S3 input not yet implemented in API';
progress.classList.remove('active');
generateBtn.disabled = false;
return;
} else if (activeTab === 'upload') {
// Handle file upload
statusText.textContent = 'Processing files...';
const files = formData.getAll('files');
if (files.length > 0) {
// Read first file as concept for now
const reader = new FileReader();
reader.onload = async (e) => {
payload.concept = e.target.result;
await generateManuscript(payload);
};
reader.readAsText(files[0]);
return;
}
}
await generateManuscript(payload);
});
async function generateManuscript(payload) {
statusText.textContent = 'Generating manuscript...';
try {
const response = await fetch('/api/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error((await response.json()).detail || 'Generation failed');
}
const result = await response.json();
progress.classList.remove('active');
outputSection.classList.add('active');
manuscriptEl.textContent = result.manuscript;
successMessage.innerHTML = `<div class="success-message">
Generated ${result.word_count.toLocaleString()} words in ${result.chapters} chapters
</div>`;
} catch (error) {
progress.classList.remove('active');
successMessage.innerHTML = `<div class="error-message">${error.message}</div>`;
}
generateBtn.disabled = false;
}
// Download
document.getElementById('downloadBtn').addEventListener('click', () => {
const content = manuscriptEl.textContent;
const blob = new Blob([content], {type: 'text/markdown'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'opus-manuscript.md';
a.click();
URL.revokeObjectURL(url);
});
// Copy
document.getElementById('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText(manuscriptEl.textContent);
document.getElementById('copyBtn').textContent = '✓ Copied!';
setTimeout(() => {
document.getElementById('copyBtn').textContent = '📋 Copy';
}, 2000);
});
</script>
</body>
</html>
"""
def create_web_ui(app: FastAPI) -> None:
"""Create and mount the web UI."""
from fastapi.responses import HTMLResponse
@app.get("/", response_class=HTMLResponse)
async def index():
"""Serve the main UI page."""
return WEB_UI_TEMPLATE
@app.get("/ui", response_class=HTMLResponse)
async def ui():
"""Alias for /"""
return WEB_UI_TEMPLATE