HydraDeck / custom_web.py
OpeneR
Remove Gradio runtime and use FastAPI-only backend
c27b609
from __future__ import annotations
import json
import threading
import time
import uuid
from pathlib import Path
from typing import Any
import os
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
from app import _api_quick_check, _run_agentic_pipeline
from hydradeck.clients.grok_client import GrokClient
class RunRequest(BaseModel):
topic: str
model: str = "grok-3-mini"
base_url: str = "https://api.example.com"
api_key: str = ""
request_budget: float = 30.0
use_mock: bool = False
language: str = "en"
model_scope: str = ""
model_structure: str = ""
model_planner: str = ""
model_section: str = ""
model_paper: str = ""
model_slides: str = ""
JOBS: dict[str, dict[str, Any]] = {}
LOCK = threading.Lock()
STATE_PATH = Path("/tmp/hydradeck_state.json")
HISTORY_LIMIT = 40
app = FastAPI(title="HydraDeck")
def _load_state() -> None:
if not STATE_PATH.exists():
return
try:
data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
except Exception:
return
jobs = data.get("jobs")
if isinstance(jobs, dict):
with LOCK:
JOBS.update({str(k): v for k, v in jobs.items() if isinstance(v, dict)})
def _save_state() -> None:
with LOCK:
payload = {"jobs": JOBS}
STATE_PATH.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def _prune_history() -> None:
with LOCK:
items = sorted(
JOBS.items(),
key=lambda kv: float(kv[1].get("updated_at", 0.0)),
reverse=True,
)
keep = dict(items[:HISTORY_LIMIT])
JOBS.clear()
JOBS.update(keep)
_load_state()
def _new_job(req: RunRequest) -> dict[str, Any]:
now = time.time()
return {
"id": str(uuid.uuid4()),
"status": "queued",
"created_at": now,
"updated_at": now,
"progress": 0,
"status_text": "Queued",
"progress_log": "",
"scope": "",
"sections": "",
"paper": "",
"slides": "",
"pdf_paths": "",
"paper_pdf": "",
"slides_pdf": "",
"error": "",
"events": [],
"params": req.model_dump(),
}
def _update_job(job_id: str, updates: dict[str, Any]) -> None:
with LOCK:
job = JOBS.get(job_id)
if not job:
return
job.update(updates)
job["updated_at"] = time.time()
_prune_history()
_save_state()
def _append_event(job_id: str, event: dict[str, Any]) -> None:
with LOCK:
job = JOBS.get(job_id)
if not job:
return
events = job.get("events")
if isinstance(events, list):
events.append(event)
_save_state()
def _run_job(job_id: str, req: RunRequest) -> None:
_update_job(job_id, {"status": "running", "status_text": "Running"})
def on_stage(payload: dict[str, Any]) -> None:
_update_job(
job_id,
{
"status": "running",
"status_text": str(payload.get("status", "Running")),
"progress": int(str(payload.get("progress", "0"))),
"progress_log": str(payload.get("progress_log", "")),
"scope": str(payload.get("scope", "")),
"sections": str(payload.get("sections", "")),
"paper": str(payload.get("paper", "")),
"slides": str(payload.get("slides", "")),
"pdf_paths": str(payload.get("pdf_paths", "")),
"paper_pdf": str(payload.get("paper_pdf", "")),
"slides_pdf": str(payload.get("slides_pdf", "")),
},
)
_append_event(
job_id,
{
"ts": time.time(),
"stage": str(payload.get("stage", "")),
"detail": str(payload.get("detail", "")),
"progress": int(str(payload.get("progress", "0"))),
},
)
try:
(
status,
progress_log,
scope,
sections,
paper,
slides,
pdf_paths,
paper_pdf,
slides_pdf,
) = _run_agentic_pipeline(
topic=req.topic,
model=req.model,
base_url=req.base_url,
api_key=req.api_key,
request_budget=req.request_budget,
use_mock=req.use_mock,
progress=None,
stage_callback=on_stage,
language=req.language,
stage_models={
"scope": req.model_scope,
"structure": req.model_structure,
"planner": req.model_planner,
"section": req.model_section,
"paper": req.model_paper,
"slides": req.model_slides,
},
)
_update_job(
job_id,
{
"status": "done",
"status_text": status,
"progress": 100,
"progress_log": progress_log,
"scope": scope,
"sections": sections,
"paper": paper,
"slides": slides,
"pdf_paths": pdf_paths,
"paper_pdf": paper_pdf,
"slides_pdf": slides_pdf,
},
)
except Exception as exc:
_update_job(
job_id,
{
"status": "error",
"status_text": "Failed",
"error": str(exc),
},
)
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return """
<!doctype html>
<html>
<head>
<meta charset=\"utf-8\" />
<title>HydraDeck</title>
<style>
:root{--bg:#f5ecd8;--paper:#fff9ec;--ink:#2a1f12;--muted:#7a5f3e;--accent:#8b3a3a;--ok:#2f6f3e}
body{font-family:"IBM Plex Mono","Courier New",monospace;max-width:1220px;margin:18px auto;padding:0 12px;background:var(--bg);color:var(--ink)}
.panel{border:2px solid var(--ink);background:var(--paper);box-shadow:2px 2px 0 #0002;padding:10px;margin:10px 0}
.row{display:flex;gap:10px;margin:8px 0;flex-wrap:wrap}
input,select,textarea{padding:8px;width:100%;border:1px solid #4b3924;background:#fffdf7;color:var(--ink)}
button{padding:9px 13px;border:2px solid var(--ink);background:#ead2b0;color:var(--ink);cursor:pointer}
button:hover{background:#f0ddc3}
.bar{height:16px;background:#d8c3a5;border:1px solid #4b3924;overflow:hidden}
.fill{height:100%;width:0%;background:linear-gradient(90deg,#8b3a3a,#d46a6a);transition:width .25s}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
pre{background:#1b130c;color:#f7e8d0;padding:10px;white-space:pre-wrap;max-height:260px;overflow:auto;border:1px solid #3a2a1b}
.title{font-size:28px;font-weight:700;letter-spacing:1px}
.sub{color:var(--muted)}
.tiny{font-size:12px;color:var(--muted)}
details{border:1px dashed #7a5f3e;padding:8px;background:#fff9ef}
summary{cursor:pointer;font-weight:700}
</style>
</head>
<body>
<div class=\"panel\"><div class=\"title\">HydraDeck</div></div>
<div class=\"panel\">
<div class=\"row\" style=\"gap:6px\">
<button onclick=\"showTab('tab-run')\">Run</button>
<button onclick=\"showTab('tab-artifacts')\">Artifacts</button>
<button onclick=\"showTab('tab-console')\">Console</button>
</div>
</div>
<div id=\"tab-run\" class=\"panel tab\">
<div class=\"row\"><input id=\"topic\" value=\"RynnBrain technical research report\" /></div>
<div class=\"row\">
<select id=\"model\"></select>
<input id=\"base_url\" value=\"https://api.example.com\" />
</div>
<div class=\"row\">
<label>language
<select id=\"language\">
<option value=\"en\" selected>English</option>
<option value=\"zh\">中文</option>
</select>
</label>
<input id=\"api_key\" placeholder=\"api key\" />
<input id=\"request_budget\" value=\"30\" />
<label><input id=\"use_mock\" type=\"checkbox\" /> use mock</label>
</div>
<div class=\"row\">
<button onclick=\"quickCheck()\">Quick API Check</button>
<button onclick=\"startRun()\">Run HydraDeck</button>
<button onclick=\"resumeLastRun()\">Resume Last Run</button>
</div>
<details>
<summary>Advanced model routing</summary>
<div class=\"tiny\">Per-agent model overrides (optional)</div>
<div class=\"row\"><select id=\"model_scope\"></select><select id=\"model_structure\"></select></div>
<div class=\"row\"><select id=\"model_planner\"></select><select id=\"model_section\"></select></div>
<div class=\"row\"><select id=\"model_paper\"></select><select id=\"model_slides\"></select></div>
</details>
</div>
<div id=\"status\">Idle</div>
<div class=\"bar\"><div id=\"fill\" class=\"fill\"></div></div>
<div id=\"pct\">0%</div>
<div id=\"tab-artifacts\" class=\"panel tab\" style=\"display:none\">
<div class=\"row\">
<a id=\"paperLink\" target=\"_blank\"></a>
<a id=\"slidesLink\" target=\"_blank\"></a>
</div>
<div class=\"grid\">
<div><h4>Scope</h4><pre id=\"scope\"></pre></div>
<div><h4>Sections</h4><pre id=\"sections\"></pre></div>
<div><h4>paper.tex</h4><pre id=\"paper\"></pre></div>
<div><h4>slides.tex</h4><pre id=\"slides\"></pre></div>
</div>
</div>
<div id=\"tab-console\" class=\"panel tab\" style=\"display:none\">
<div class=\"grid\">
<div><h4>Progress</h4><pre id=\"progress\"></pre></div>
<div><h4>Events</h4><pre id=\"events\"></pre></div>
</div>
</div>
<script>
let jobId = null;
let timer = null;
let inflight = false;
let refreshFailCount = 0;
function showTab(id){
for(const el of document.querySelectorAll('.tab')) el.style.display='none';
document.getElementById(id).style.display='block';
}
function addModelOptions(selectId, models){
const s=document.getElementById(selectId);
s.innerHTML='';
const blank=document.createElement('option');
blank.value='';
blank.textContent = selectId==='model' ? '(default model)' : '(inherit default)';
s.appendChild(blank);
for(const m of models){
const o=document.createElement('option');
o.value=m; o.textContent=m; s.appendChild(o);
}
}
async function loadModels(){
try{
const ctl = new AbortController();
const t = setTimeout(()=>ctl.abort(), 15000);
const r=await fetch('/api/models?base_url='+encodeURIComponent(document.getElementById('base_url').value)+'&api_key='+encodeURIComponent(document.getElementById('api_key').value), {signal: ctl.signal});
clearTimeout(t);
const j=await r.json();
const models=Array.isArray(j.models)?j.models:[];
for(const id of ['model','model_scope','model_structure','model_planner','model_section','model_paper','model_slides']) addModelOptions(id, models);
if(models.includes('grok-3-mini')) document.getElementById('model').value='grok-3-mini';
}catch(e){
document.getElementById('status').innerText='model list failed: '+e;
}
}
function payload(){
return {
topic: document.getElementById('topic').value,
model: document.getElementById('model').value,
base_url: document.getElementById('base_url').value,
api_key: document.getElementById('api_key').value,
request_budget: Number(document.getElementById('request_budget').value || 30),
use_mock: document.getElementById('use_mock').checked,
language: document.getElementById('language').value,
model_scope: document.getElementById('model_scope').value,
model_structure: document.getElementById('model_structure').value,
model_planner: document.getElementById('model_planner').value,
model_section: document.getElementById('model_section').value,
model_paper: document.getElementById('model_paper').value,
model_slides: document.getElementById('model_slides').value,
};
}
async function quickCheck(){
const ctl = new AbortController();
const t = setTimeout(()=>ctl.abort(), 20000);
const r = await fetch('/api/quick-check',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload()),signal: ctl.signal});
clearTimeout(t);
const j = await r.json();
document.getElementById('status').innerText = j.result || j.error;
showTab('tab-console');
}
async function startRun(){
if(inflight) return;
inflight = true;
const ctl = new AbortController();
const t = setTimeout(()=>ctl.abort(), 20000);
const r = await fetch('/api/jobs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload()),signal: ctl.signal});
clearTimeout(t);
const j = await r.json();
jobId = j.id;
localStorage.setItem('hydradeck_last_job_id', jobId);
if (timer) clearInterval(timer);
timer = setInterval(refresh, 1000);
refresh();
showTab('tab-console');
}
async function refresh(){
if(!inflight) return;
if(!jobId) return;
try {
const ctl = new AbortController();
const t = setTimeout(()=>ctl.abort(), 12000);
const r = await fetch('/api/jobs/'+jobId, {signal: ctl.signal});
clearTimeout(t);
if(!r.ok) {
refreshFailCount += 1;
if (refreshFailCount >= 5) {
inflight = false;
if (timer) { clearInterval(timer); timer = null; }
document.getElementById('status').innerText = 'Polling paused (network/server issue). Use Resume Last Run.';
}
return;
}
const j = await r.json();
refreshFailCount = 0;
document.getElementById('status').innerText = j.status_text || j.status;
const p = Math.max(0, Math.min(100, Number(j.progress || 0)));
document.getElementById('fill').style.width = p + '%';
document.getElementById('pct').innerText = p + '%';
document.getElementById('progress').innerText = j.progress_log || '';
document.getElementById('scope').innerText = j.scope || '';
document.getElementById('sections').innerText = j.sections || '';
document.getElementById('paper').innerText = j.paper || '';
document.getElementById('slides').innerText = j.slides || '';
document.getElementById('events').innerText = JSON.stringify(j.events || [], null, 2);
const p1 = document.getElementById('paperLink');
const p2 = document.getElementById('slidesLink');
if (j.paper_pdf){ p1.href = '/api/jobs/'+jobId+'/artifact/paper'; p1.innerText='Download paper.pdf'; }
if (j.slides_pdf){ p2.href = '/api/jobs/'+jobId+'/artifact/slides'; p2.innerText='Download slides.pdf'; }
if (j.status === 'done' || j.status === 'error') {
clearInterval(timer);
timer = null;
inflight = false;
localStorage.removeItem('hydradeck_last_job_id');
}
} catch (e) {
refreshFailCount += 1;
if (refreshFailCount >= 5) {
inflight = false;
if (timer) { clearInterval(timer); timer = null; }
document.getElementById('status').innerText = 'Polling paused due to repeated timeout. Use Resume Last Run.';
}
}
}
function resumeLastRun(){
const saved = localStorage.getItem('hydradeck_last_job_id');
if(!saved){
document.getElementById('status').innerText = 'No resumable job.';
return;
}
jobId = saved;
inflight = true;
refreshFailCount = 0;
if (timer) clearInterval(timer);
timer = setInterval(refresh, 1000);
refresh();
showTab('tab-console');
}
document.getElementById('base_url').addEventListener('change', loadModels);
document.getElementById('api_key').addEventListener('change', loadModels);
loadModels();
showTab('tab-run');
if(localStorage.getItem('hydradeck_last_job_id')){
document.getElementById('status').innerText = 'Last run available. Click Resume Last Run to continue.';
}
</script>
</body>
</html>
"""
@app.post("/api/quick-check")
def api_quick_check(req: RunRequest) -> dict[str, str]:
result = _api_quick_check(req.base_url, req.api_key, req.model, req.request_budget)
return {"result": result}
@app.post("/api/jobs")
def create_job(req: RunRequest) -> dict[str, str]:
if not req.topic.strip():
raise HTTPException(status_code=400, detail="topic is required")
job = _new_job(req)
with LOCK:
JOBS[job["id"]] = job
_prune_history()
_save_state()
t = threading.Thread(target=_run_job, args=(job["id"], req), daemon=True)
t.start()
return {"id": job["id"]}
@app.get("/api/history")
def get_history() -> dict[str, Any]:
with LOCK:
items = sorted(
JOBS.values(),
key=lambda j: float(j.get("updated_at", 0.0)),
reverse=True,
)
rows = [
{
"id": j.get("id"),
"status": j.get("status"),
"progress": j.get("progress"),
"topic": (j.get("params") or {}).get("topic", ""),
"updated_at": j.get("updated_at"),
}
for j in items[:HISTORY_LIMIT]
]
return {"items": rows}
@app.get("/api/models")
def get_models(base_url: str, api_key: str = "") -> dict[str, Any]:
try:
cli = GrokClient(base_url=base_url, api_key=api_key, model="grok-3-mini", timeout_s=20.0, max_retries=1)
models = cli.list_models(timeout_s=20.0)
return {"models": models}
except Exception as exc:
return {"models": [], "error": str(exc)}
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str) -> dict[str, Any]:
with LOCK:
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
return dict(job)
@app.get("/api/jobs/{job_id}/artifact/{kind}")
def get_artifact(job_id: str, kind: str):
with LOCK:
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
if kind == "paper":
path = str(job.get("paper_pdf", ""))
filename = "paper.pdf"
elif kind == "slides":
path = str(job.get("slides_pdf", ""))
filename = "slides.pdf"
else:
raise HTTPException(status_code=400, detail="kind must be paper|slides")
p = Path(path)
if not path or not p.exists():
raise HTTPException(status_code=404, detail="artifact not ready")
return FileResponse(str(p), media_type="application/pdf", filename=filename)
if __name__ == "__main__":
import uvicorn
_load_state()
port = int(os.getenv("PORT", "7861"))
uvicorn.run(app, host="0.0.0.0", port=port)