diff --git a/llm_team_ui.py b/llm_team_ui.py
index def9c67..efdd8b4 100644
--- a/llm_team_ui.py
+++ b/llm_team_ui.py
@@ -645,6 +645,22 @@ HTML = r"""
.status-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: rgba(0,0,0,0.3); border: 2px solid var(--border); border-radius: 2px; font-size: 11px; color: var(--text2); font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
.spinner { width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
+ .progress-panel { background: rgba(0,0,0,0.3); border: 2px solid var(--border); border-radius: 2px; padding: 14px; margin-bottom: 10px; }
+ .progress-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
+ .progress-header .prog-mode { font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--accent); font-weight: 700; }
+ .progress-header .prog-time { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); letter-spacing: 0.5px; }
+ .progress-track { height: 6px; background: rgba(0,0,0,0.4); border: 1px solid var(--border); border-radius: 1px; overflow: hidden; margin-bottom: 8px; }
+ .progress-fill { height: 100%; background: var(--accent); transition: width 0.4s ease; box-shadow: 0 0 10px rgba(226,181,90,0.3); position: relative; }
+ .progress-fill::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 20px; background: linear-gradient(90deg, transparent, var(--accent2)); animation: progress-shimmer 1.5s ease-in-out infinite; }
+ @keyframes progress-shimmer { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
+ .progress-steps { display: flex; gap: 4px; margin-bottom: 8px; }
+ .progress-step { flex: 1; height: 3px; background: rgba(0,0,0,0.4); border-radius: 1px; transition: background 0.3s; }
+ .progress-step.done { background: var(--accent); }
+ .progress-step.active { background: var(--accent); animation: step-pulse 1s ease-in-out infinite; }
+ @keyframes step-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
+ .progress-detail { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); display: flex; justify-content: space-between; }
+ .progress-detail .prog-substep { max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .progress-detail .prog-stats { color: var(--text2); opacity: 0.6; }
.sample-prompts { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.sample-chip { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 2px; padding: 6px 12px; font-size: 11px; color: var(--text2); cursor: pointer; transition: all 0.15s; line-height: 1.4; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sample-chip:hover { border-color: var(--accent); color: var(--accent); background: var(--glow); }
@@ -1257,15 +1273,83 @@ function buildConfig() {
return c;
}
+let _runStartTime = 0;
+let _runTimer = null;
+let _runEventCount = 0;
+let _runResponseCount = 0;
+
+function formatElapsed(ms) {
+ const s = Math.floor(ms / 1000);
+ if (s < 60) return s + 's';
+ return Math.floor(s/60) + 'm ' + (s%60) + 's';
+}
+
+function updateProgressTime() {
+ const el = document.getElementById('prog-time');
+ if (el && _runStartTime) el.textContent = formatElapsed(Date.now() - _runStartTime);
+ const ev = document.getElementById('prog-events');
+ if (ev) ev.textContent = _runEventCount + ' events / ' + _runResponseCount + ' responses';
+}
+
async function runTeam() {
const config = buildConfig();
if (!config) return;
const btn = document.getElementById('run-btn');
btn.disabled = true; btn.textContent = 'Running...';
const output = document.getElementById('output');
- output.innerHTML = '
';
+ _runStartTime = Date.now();
+ _runEventCount = 0;
+ _runResponseCount = 0;
+ const progEl = document.createElement('div');
+ progEl.className = 'progress-panel';
+ progEl.id = 'run-progress';
+ progEl.textContent = '';
+ const header = document.createElement('div');
+ header.className = 'progress-header';
+ const modeLabel = document.createElement('span');
+ modeLabel.className = 'prog-mode';
+ modeLabel.textContent = currentMode;
+ const timeLabel = document.createElement('span');
+ timeLabel.className = 'prog-time';
+ timeLabel.id = 'prog-time';
+ timeLabel.textContent = '0s';
+ header.appendChild(modeLabel);
+ header.appendChild(timeLabel);
+ progEl.appendChild(header);
+ const track = document.createElement('div');
+ track.className = 'progress-track';
+ const fill = document.createElement('div');
+ fill.className = 'progress-fill';
+ fill.id = 'prog-fill';
+ fill.style.width = '2%';
+ track.appendChild(fill);
+ progEl.appendChild(track);
+ const stepsDiv = document.createElement('div');
+ stepsDiv.className = 'progress-steps';
+ stepsDiv.id = 'prog-steps';
+ progEl.appendChild(stepsDiv);
+ const detail = document.createElement('div');
+ detail.className = 'progress-detail';
+ const substep = document.createElement('span');
+ substep.className = 'prog-substep';
+ substep.id = 'prog-substep';
+ substep.textContent = 'Initializing...';
+ const stats = document.createElement('span');
+ stats.className = 'prog-stats';
+ stats.id = 'prog-events';
+ stats.textContent = '0 events';
+ detail.appendChild(substep);
+ detail.appendChild(stats);
+ progEl.appendChild(detail);
+ output.textContent = '';
+ output.appendChild(progEl);
+ _runTimer = setInterval(updateProgressTime, 1000);
try {
const resp = await fetch('/api/run', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) });
+ if (!resp.ok) {
+ const errData = await resp.json().catch(function() { return {error: 'HTTP ' + resp.status}; });
+ throw new Error(errData.error || 'HTTP ' + resp.status);
+ }
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@@ -1276,26 +1360,80 @@ async function runTeam() {
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
- if (line.startsWith('data: ')) { try { handleEvent(JSON.parse(line.slice(6))); } catch(e) {} }
+ if (line.startsWith('data: ')) { try { _runEventCount++; handleEvent(JSON.parse(line.slice(6))); } catch(e) {} }
}
}
} catch(e) {
- output.innerHTML = `Error: ${e.message}
`;
+ const errDiv = document.createElement('div');
+ errDiv.className = 'status-bar';
+ errDiv.style.color = 'var(--red)';
+ errDiv.style.borderColor = 'var(--red)';
+ errDiv.textContent = 'Error: ' + e.message + ' (after ' + formatElapsed(Date.now() - _runStartTime) + ')';
+ output.appendChild(errDiv);
+ }
+ clearInterval(_runTimer);
+ const prog = document.getElementById('run-progress');
+ if (prog) {
+ const fillEl = document.getElementById('prog-fill');
+ if (fillEl) { fillEl.style.width = '100%'; fillEl.style.boxShadow = '0 0 16px rgba(74,222,128,0.4)'; fillEl.style.background = 'var(--green)'; }
+ const sub = document.getElementById('prog-substep');
+ if (sub) sub.textContent = 'Complete — ' + formatElapsed(Date.now() - _runStartTime);
+ const allSteps = prog.querySelectorAll('.progress-step');
+ allSteps.forEach(function(s) { s.className = 'progress-step done'; });
+ setTimeout(function() { if (prog.parentNode) { prog.style.opacity = '0.4'; prog.style.transition = 'opacity 2s'; } }, 3000);
}
btn.disabled = false; btn.textContent = 'Run Team';
}
function handleEvent(evt) {
const output = document.getElementById('output');
- if (evt.type === 'clear') { output.innerHTML = ''; return; }
+ if (evt.type === 'clear') {
+ const prog = document.getElementById('run-progress');
+ output.textContent = '';
+ if (prog) output.appendChild(prog);
+ return;
+ }
+ if (evt.type === 'progress') {
+ const fill = document.getElementById('prog-fill');
+ const sub = document.getElementById('prog-substep');
+ const stepsDiv = document.getElementById('prog-steps');
+ if (fill && evt.percent != null) fill.style.width = Math.max(2, Math.min(98, evt.percent)) + '%';
+ if (sub && evt.substep) sub.textContent = evt.substep;
+ if (stepsDiv && evt.total_steps) {
+ while (stepsDiv.children.length < evt.total_steps) {
+ const s = document.createElement('div');
+ s.className = 'progress-step';
+ stepsDiv.appendChild(s);
+ }
+ for (let i = 0; i < stepsDiv.children.length; i++) {
+ if (i < evt.step - 1) stepsDiv.children[i].className = 'progress-step done';
+ else if (i === evt.step - 1) stepsDiv.children[i].className = 'progress-step active';
+ else stepsDiv.children[i].className = 'progress-step';
+ }
+ }
+ return;
+ }
if (evt.type === 'status') {
+ const sub = document.getElementById('prog-substep');
+ if (sub) { sub.textContent = evt.message; return; }
let bar = output.querySelector('.status-bar');
if (bar) bar.querySelector('span').textContent = evt.message;
- else output.innerHTML += ``;
+ else {
+ const newBar = document.createElement('div');
+ newBar.className = 'status-bar';
+ const sp = document.createElement('div');
+ sp.className = 'spinner';
+ const span = document.createElement('span');
+ span.textContent = evt.message;
+ newBar.appendChild(sp);
+ newBar.appendChild(span);
+ output.appendChild(newBar);
+ }
return;
}
if (evt.type === 'done') { const bar = output.querySelector('.status-bar'); if (bar) bar.remove(); return; }
if (evt.type === 'response') {
+ _runResponseCount++;
const bar = output.querySelector('.status-bar'); if (bar) bar.remove();
const mi = availableModels.findIndex(m => m.name === evt.model);
const color = COLORS[(mi >= 0 ? mi : 0) % COLORS.length];
@@ -2870,6 +3008,178 @@ def admin_ollama_models():
return jsonify({"models": [], "error": str(e)})
+# ─── ADMIN MONITOR ─────────────────────────────────────────────
+
+@app.route("/admin/monitor")
+@admin_required
+def monitor_page():
+ return MONITOR_HTML
+
+@app.route("/api/admin/monitor")
+@admin_required
+def monitor_data():
+ active = []
+ for rid, r in _active_runs.items():
+ active.append({
+ "run_id": rid, "mode": r["mode"], "user": r["user"],
+ "prompt": r["prompt"], "elapsed": round(time.time() - r["started"], 1),
+ "step": r["step"], "total_steps": r["total_steps"],
+ "substep": r["substep"], "events": r["events"],
+ "errors": len(r["errors"]), "responses_size": r["responses_size"],
+ "error_details": r["errors"][-3:] # last 3 errors
+ })
+ recent = list(reversed(_run_log[-20:]))
+ return jsonify({"active": active, "recent": recent, "timestamp": time.time()})
+
+
+MONITOR_HTML = r"""
+
+
+LLM Team — Monitor
+
+
+
+
+
+
+
+
+
+
+
Recent Runs (last 20)
+
+
+
+
+"""
+
+
# ─── HISTORY ROUTES ────────────────────────────────────────────
@app.route("/api/runs")
@@ -3297,6 +3607,19 @@ def lab_stream(eid):
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+# ─── ACTIVE RUN TRACKING ──────────────────────────────────────
+import uuid as _uuid
+
+_active_runs = {} # run_id -> {mode, user, prompt, started, step, total_steps, substep, events, errors}
+_run_log = [] # recent completed runs with timing/error info (last 100)
+
+def _log_run(run_info):
+ """Archive a completed run to the log."""
+ _run_log.append(run_info)
+ if len(_run_log) > 100:
+ _run_log.pop(0)
+
+
# ─── TEAM ROUTES ──────────────────────────────────────────────
@app.route("/api/run", methods=["POST"])
@@ -3328,21 +3651,60 @@ def run_team():
"research": run_research, "eval": run_eval, "extract": run_extract,
}
+ run_id = str(_uuid.uuid4())[:8]
+ username = session.get("username", "unknown")
+ _active_runs[run_id] = {
+ "mode": mode, "user": username, "prompt": prompt[:100],
+ "started": time.time(), "step": 0, "total_steps": 0,
+ "substep": "", "events": 0, "errors": [],
+ "responses_size": 0
+ }
+
def generate():
collected = []
- runner = RUNNERS.get(mode)
- if runner:
- for event_str in runner(config):
- yield event_str
- try:
- data = json.loads(event_str.replace("data: ", "", 1).strip())
- if data.get("type") == "response":
- collected.append({"model": data.get("model", ""), "text": data.get("text", ""), "role": data.get("role", "")})
- except Exception:
- pass
- else:
- yield sse({"type": "response", "model": "system", "text": f"Unknown mode: {mode}", "role": "error"})
- yield sse({"type": "done"})
+ run = _active_runs[run_id]
+ last_heartbeat = time.time()
+ try:
+ runner = RUNNERS.get(mode)
+ if runner:
+ for event_str in runner(config):
+ yield event_str
+ run["events"] += 1
+ try:
+ data = json.loads(event_str.replace("data: ", "", 1).strip())
+ evt_type = data.get("type")
+ if evt_type == "response":
+ text = data.get("text", "")
+ run["responses_size"] += len(text)
+ collected.append({"model": data.get("model", ""), "text": text, "role": data.get("role", "")})
+ if data.get("role") == "error":
+ run["errors"].append({"model": data.get("model"), "error": text[:200], "time": time.time()})
+ elif evt_type == "progress":
+ run["step"] = data.get("step", run["step"])
+ run["total_steps"] = data.get("total_steps", run["total_steps"])
+ run["substep"] = data.get("substep", "")
+ elif evt_type == "status":
+ run["substep"] = data.get("message", "")
+ except Exception:
+ pass
+ # SSE keepalive — prevent nginx/browser timeout during gaps
+ now = time.time()
+ if now - last_heartbeat > 15:
+ yield ": keepalive\n\n"
+ last_heartbeat = now
+ else:
+ yield sse({"type": "response", "model": "system", "text": f"Unknown mode: {mode}", "role": "error"})
+ yield sse({"type": "done"})
+ except Exception as e:
+ run["errors"].append({"model": "system", "error": str(e)[:500], "time": time.time()})
+ yield sse({"type": "response", "model": "system", "text": f"Pipeline error: {e}", "role": "error"})
+ yield sse({"type": "done"})
+ finally:
+ run["finished"] = time.time()
+ run["duration"] = round(run["finished"] - run["started"], 1)
+ run["response_count"] = len(collected)
+ _log_run(dict(run, run_id=run_id))
+ _active_runs.pop(run_id, None)
if collected:
save_run(mode, config.get("prompt", ""), config, collected)
@@ -3355,12 +3717,17 @@ def run_team():
def run_brainstorm(config):
models, prompt = config.get("models", []), config["prompt"]
synthesizer = config.get("synthesizer", models[0] if models else "qwen2.5")
+ total = 2 if len(models) > 1 else 1
yield sse({"type": "clear"})
+ yield sse({"type": "progress", "step": 1, "total_steps": total, "substep": f"Querying {len(models)} models in parallel...", "percent": 10})
yield sse({"type": "status", "message": f"Querying {len(models)} models..."})
responses = parallel_query(models, prompt)
- for m, r in responses.items():
+ for i, (m, r) in enumerate(responses.items()):
+ pct = 10 + int(((i + 1) / len(responses)) * 50)
+ yield sse({"type": "progress", "step": 1, "total_steps": total, "substep": f"{i+1}/{len(responses)} models responded", "percent": pct})
yield sse({"type": "response", "model": m, "text": r, "role": "respondent"})
if len(responses) > 1:
+ yield sse({"type": "progress", "step": 2, "total_steps": total, "substep": f"Synthesizing with {synthesizer}...", "percent": 70})
yield sse({"type": "status", "message": f"Synthesizing with {synthesizer}..."})
parts = [("QUESTION:", prompt, 1), ("INSTRUCTION:", "Synthesize the best answer. Be concise.", 1)]
for m, r in responses.items():
@@ -3370,6 +3737,7 @@ def run_brainstorm(config):
yield sse({"type": "response", "model": synthesizer, "text": safe_query(synthesizer, sp), "role": "synthesis"})
except Exception as e:
yield sse({"type": "response", "model": synthesizer, "text": str(e), "role": "error"})
+ yield sse({"type": "progress", "step": total, "total_steps": total, "substep": "Complete", "percent": 100})
def run_pipeline(config):
@@ -3986,13 +4354,15 @@ def run_research(config):
models = config.get("models", [])
checker = config.get("checker", models[0] if models else scout)
synth = config.get("synthesizer", models[0] if models else scout)
- num_q = config.get("num_questions", 5)
+ num_q = min(config.get("num_questions", 5), 15) # hard cap at 15
yield sse({"type": "clear"})
+ total_steps = 4
steps = []
all_models = [scout, checker, synth] + models
# Step 1: Scout generates research questions
- yield sse({"type": "status", "message": f"Step 1/4: {scout} generating {num_q} research questions..."})
+ yield sse({"type": "progress", "step": 1, "total_steps": total_steps, "substep": f"{scout} generating {num_q} research questions...", "percent": 5})
+ yield sse({"type": "status", "message": f"Step 1/{total_steps}: {scout} generating {num_q} research questions..."})
try:
q_prompt = (
f"You are a research scout. Given the topic below, generate exactly {num_q} specific, "
@@ -4013,9 +4383,14 @@ def run_research(config):
yield sse({"type": "response", "model": "system", "text": "Failed to parse research questions.", "role": "error"})
return
+ yield sse({"type": "progress", "step": 1, "total_steps": total_steps, "substep": f"Parsed {len(questions)} questions", "percent": 15})
+
# Step 2: Parallel research — distribute questions across models
- yield sse({"type": "status", "message": f"Step 2/4: {len(models)} models researching {len(questions)} questions..."})
+ yield sse({"type": "progress", "step": 2, "total_steps": total_steps, "substep": f"0/{len(questions)} questions researched...", "percent": 18})
+ yield sse({"type": "status", "message": f"Step 2/{total_steps}: {len(models)} models researching {len(questions)} questions..."})
research_results = {}
+ completed_q = 0
+ failed_q = 0
with ThreadPoolExecutor(max_workers=max(len(models), 1)) as pool:
futures = {}
for i, q in enumerate(questions):
@@ -4026,18 +4401,33 @@ def run_research(config):
m, q = futures[future]
try:
answer = future.result()
+ # Cap individual research answers to prevent context explosion
+ if len(answer) > 8000:
+ answer = answer[:7500] + "\n\n[... response truncated for pipeline stability ...]"
research_results[q] = {"model": m, "answer": answer}
+ completed_q += 1
+ pct = 18 + int((completed_q / len(questions)) * 42)
+ yield sse({"type": "progress", "step": 2, "total_steps": total_steps, "substep": f"{completed_q}/{len(questions)} questions researched", "percent": pct})
yield sse({"type": "response", "model": m, "text": f"Q: {q}\n\n{answer}", "role": "researcher"})
except Exception as e:
+ failed_q += 1
+ completed_q += 1
+ pct = 18 + int((completed_q / len(questions)) * 42)
+ yield sse({"type": "progress", "step": 2, "total_steps": total_steps, "substep": f"{completed_q}/{len(questions)} ({failed_q} failed)", "percent": pct})
yield sse({"type": "response", "model": m, "text": f"Q: {q}\n\nError: {e}", "role": "error"})
research_results[q] = {"model": m, "answer": f"Error: {e}"}
steps.append({"step": "research", "results": {q: r["answer"][:500] for q, r in research_results.items()}})
- # Step 3: Fact-check
- yield sse({"type": "status", "message": f"Step 3/4: {checker} fact-checking all findings..."})
+ # Step 3: Fact-check — cap context to prevent OOM
+ yield sse({"type": "progress", "step": 3, "total_steps": total_steps, "substep": f"{checker} fact-checking...", "percent": 62})
+ yield sse({"type": "status", "message": f"Step 3/{total_steps}: {checker} fact-checking all findings..."})
check_prompt = f"Topic: {prompt}\n\nResearch findings to fact-check:\n\n"
+ # Smart truncation: fit within context limits
+ per_answer_cap = min(300, 3000 // max(len(research_results), 1))
for q, r in research_results.items():
- check_prompt += f"Q: {q}\nA: {r['answer'][:300]}\n\n"
+ if r["answer"].startswith("Error:"):
+ continue
+ check_prompt += f"Q: {q}\nA: {r['answer'][:per_answer_cap]}\n\n"
check_prompt += (
"For each finding, mark as:\n"
" VERIFIED — likely accurate\n"
@@ -4053,11 +4443,16 @@ def run_research(config):
check_result = f"Error: {e}"
yield sse({"type": "response", "model": checker, "text": str(e), "role": "error"})
- # Step 4: Synthesize into brief
- yield sse({"type": "status", "message": f"Step 4/4: {synth} synthesizing research brief..."})
+ # Step 4: Synthesize into brief — cap context
+ yield sse({"type": "progress", "step": 4, "total_steps": total_steps, "substep": f"{synth} synthesizing brief...", "percent": 80})
+ yield sse({"type": "status", "message": f"Step 4/{total_steps}: {synth} synthesizing research brief..."})
synth_prompt = f"Topic: {prompt}\n\nResearch findings:\n\n"
+ per_synth_cap = min(400, 4000 // max(len(research_results), 1))
for q, r in research_results.items():
- synth_prompt += f"Q: {q}\nA: {r['answer'][:400]}\n\n"
+ if r["answer"].startswith("Error:"):
+ synth_prompt += f"Q: {q}\nA: [research failed]\n\n"
+ else:
+ synth_prompt += f"Q: {q}\nA: {r['answer'][:per_synth_cap]}\n\n"
synth_prompt += f"\nFact-check notes:\n{check_result[:500]}\n\n"
synth_prompt += (
"Synthesize ALL findings into a structured research brief with these sections:\n"
@@ -4076,6 +4471,8 @@ def run_research(config):
brief = f"Error: {e}"
yield sse({"type": "response", "model": synth, "text": str(e), "role": "error"})
+ yield sse({"type": "progress", "step": 4, "total_steps": total_steps, "substep": "Research complete", "percent": 100})
+
# Save pipeline run
_save_pipeline("research", prompt, steps, {"brief": brief, "questions": questions, "fact_check": check_result[:1000]}, all_models, start)