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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user