Backend: - Active run tracking with step/substep/error state - SSE keepalive heartbeat every 15s to prevent nginx timeout - Run log (last 100 completed runs with timing/errors) - Research mode: per-question progress, context caps, graceful failures - Hard cap on research questions (15), answer truncation (8K chars) Frontend: - Real progress bar with step segments, elapsed time, event counter - Progress shimmer animation, step completion indicators - Improved error display with timing context - Green completion state with fade Admin: - /admin/monitor — live process dashboard - Stats: active runs, completed, errors, avg duration - Active run cards with live progress, substep detail, errors - Recent run history with error traces - Auto-polls every 3 seconds - Full retro-brutalist theme matching main UI Nginx: - proxy_read_timeout 600s, proxy_send_timeout 600s - proxy_buffering off for SSE streaming Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4695 lines
245 KiB
Python
4695 lines
245 KiB
Python
#!/usr/bin/env python3
|
|
"""LLM Team UI - Web interface to configure and run multi-model teams."""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import threading
|
|
import secrets
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
import requests
|
|
import random
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
import bcrypt
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from flask import Flask, render_template_string, request, jsonify, Response, redirect, url_for, session
|
|
from functools import wraps
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32))
|
|
|
|
# ─── SECURITY LOGGING ─────────────────────────────────────────
|
|
# Dedicated security log for fail2ban and audit trail
|
|
_sec_handler = logging.FileHandler("/var/log/llm-team-security.log")
|
|
_sec_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
sec_log = logging.getLogger("security")
|
|
sec_log.addHandler(_sec_handler)
|
|
sec_log.setLevel(logging.WARNING)
|
|
|
|
# ─── EMAIL ALERTS ──────────────────────────────────────────────
|
|
SMTP_HOST = os.environ.get("SMTP_HOST", "127.0.0.1")
|
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "1025"))
|
|
ALERT_FROM = os.environ.get("ALERT_FROM", "security@island37.com")
|
|
ALERT_TO = os.environ.get("ALERT_TO", "admin@island37.com")
|
|
|
|
def send_security_alert(subject, body):
|
|
"""Send security alert email (non-blocking)."""
|
|
def _send():
|
|
try:
|
|
import smtplib
|
|
from email.message import EmailMessage
|
|
msg = EmailMessage()
|
|
msg["Subject"] = f"[LLM Team Security] {subject}"
|
|
msg["From"] = ALERT_FROM
|
|
msg["To"] = ALERT_TO
|
|
msg.set_content(body)
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=5) as s:
|
|
s.send_message(msg)
|
|
except Exception as e:
|
|
sec_log.error("EMAIL_FAILED subject=%s error=%s", subject, str(e))
|
|
threading.Thread(target=_send, daemon=True).start()
|
|
|
|
# Known exploit paths that scanners probe
|
|
EXPLOIT_PATTERNS = re.compile(
|
|
r"(\.env|wp-admin|wp-login|phpmyadmin|\.git|/admin\.php|/config\.|"
|
|
r"\.asp|\.aspx|/cgi-bin|/shell|/eval|/exec|/passwd|/etc/shadow|"
|
|
r"\.\./|%2e%2e|<script|%3cscript|union\s+select|;--|UNION|SELECT\s.*FROM)",
|
|
re.IGNORECASE
|
|
)
|
|
|
|
# ─── AUTH + DEMO MODE ─────────────────────────────────────────
|
|
|
|
_rate_limit = {} # ip -> (count, window_start)
|
|
RATE_LIMIT_WINDOW = 60
|
|
RATE_LIMIT_MAX = 60
|
|
LOGIN_RATE_MAX = 5
|
|
|
|
# IPs that never get rate-limited (your LAN, localhost)
|
|
ALLOWLIST_IPS = {"127.0.0.1", "::1", "192.168.1.1"}
|
|
# Demo mode state — toggled by admin at runtime
|
|
_demo_mode = {"active": False, "started_by": None}
|
|
|
|
# Admin-only write routes — blocked in demo for non-admin users
|
|
ADMIN_WRITE_ROUTES = {
|
|
"/api/admin/config": ["POST"],
|
|
"/api/admin/test-provider": ["POST"],
|
|
"/api/auth/login": ["POST"],
|
|
}
|
|
|
|
|
|
def is_allowlisted(ip):
|
|
return ip in ALLOWLIST_IPS or ip.startswith("192.168.1.")
|
|
|
|
|
|
def rate_limited(ip, max_req=RATE_LIMIT_MAX):
|
|
if is_allowlisted(ip):
|
|
return False
|
|
now = time.time()
|
|
if ip not in _rate_limit or now - _rate_limit[ip][1] > RATE_LIMIT_WINDOW:
|
|
_rate_limit[ip] = (1, now)
|
|
return False
|
|
count, start = _rate_limit[ip]
|
|
if count >= max_req:
|
|
return True
|
|
_rate_limit[ip] = (count + 1, start)
|
|
return False
|
|
|
|
|
|
def is_admin():
|
|
return session.get("role") == "admin"
|
|
|
|
|
|
def is_demo():
|
|
return _demo_mode["active"]
|
|
|
|
|
|
def login_required(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
# Demo mode: everyone gets in
|
|
if is_demo() and not session.get("user_id"):
|
|
session["demo_user"] = True
|
|
if not session.get("user_id") and not is_demo():
|
|
if request.path.startswith("/api/"):
|
|
return jsonify({"error": "unauthorized"}), 401
|
|
return redirect("/login")
|
|
return f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
def admin_required(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
# Demo mode: allow read access (GET), block writes unless admin
|
|
if is_demo():
|
|
if request.method == "GET":
|
|
return f(*args, **kwargs)
|
|
if not is_admin():
|
|
return jsonify({"error": "demo mode: read-only", "demo": True}), 403
|
|
if not session.get("user_id"):
|
|
if request.path.startswith("/api/"):
|
|
return jsonify({"error": "unauthorized"}), 401
|
|
return redirect("/login")
|
|
if session.get("role") != "admin":
|
|
return "Forbidden", 403
|
|
return f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
@app.before_request
|
|
def security_checks():
|
|
ip = request.headers.get("X-Real-IP", request.remote_addr)
|
|
path = request.path
|
|
ua = request.headers.get("User-Agent", "")
|
|
|
|
# Exploit scanner detection — log, alert, and block
|
|
if EXPLOIT_PATTERNS.search(path) or EXPLOIT_PATTERNS.search(request.query_string.decode("utf-8", errors="ignore")):
|
|
sec_log.warning("EXPLOIT_SCAN ip=%s path=%s ua=%s", ip, path, ua)
|
|
send_security_alert(
|
|
f"Exploit Scan from {ip}",
|
|
f"IP: {ip}\nPath: {path}\nUser-Agent: {ua}\nTime: {time.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
)
|
|
return "Not Found", 404
|
|
|
|
# Rate limit (allowlisted IPs skip)
|
|
if rate_limited(ip):
|
|
sec_log.warning("RATE_LIMITED ip=%s path=%s", ip, path)
|
|
return jsonify({"error": "rate limited"}), 429
|
|
|
|
# Always allow these
|
|
if path in ("/login", "/api/auth/login", "/api/auth/setup", "/api/demo/status"):
|
|
return
|
|
if path.startswith("/static"):
|
|
return
|
|
|
|
# In demo mode, block admin write routes for non-admins
|
|
if is_demo() and not is_admin():
|
|
for route, methods in ADMIN_WRITE_ROUTES.items():
|
|
if path == route and request.method in methods:
|
|
return jsonify({"error": "demo mode: admin settings are read-only", "demo": True}), 403
|
|
|
|
|
|
@app.after_request
|
|
def security_headers(response):
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
|
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
if request.path.startswith("/api/"):
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
HONEYPOT_404_HTML = """
|
|
<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"><title>404 Not Found</title>
|
|
<style>
|
|
:root{--bg:#0a0c10;--surface:#151820;--border:#272d3f;--text:#e4e4e7;--text2:#a1a1aa;--accent:#6366f1}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column}
|
|
.box{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:40px;max-width:480px;text-align:center;box-shadow:0 0 40px rgba(99,102,241,0.06)}
|
|
h1{font-size:64px;font-weight:800;color:var(--accent);margin-bottom:8px}
|
|
h2{font-size:18px;color:var(--text2);margin-bottom:20px;font-weight:400}
|
|
p{color:var(--text2);font-size:14px;line-height:1.6;margin-bottom:20px}
|
|
a{color:var(--accent);text-decoration:none}
|
|
a:hover{text-decoration:underline}
|
|
.meta{font-size:11px;color:#444;margin-top:24px}
|
|
</style>
|
|
</head><body>
|
|
<div class="box">
|
|
<h1>404</h1>
|
|
<h2>Page not found</h2>
|
|
<p>The page you're looking for doesn't exist or has been moved.</p>
|
|
<a href="/login">Go to login</a>
|
|
<div class="meta">
|
|
<!-- fp:{{FINGERPRINT}} -->
|
|
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" style="display:none"
|
|
onerror="(function(){try{var d={ts:Date.now(),tz:Intl.DateTimeFormat().resolvedOptions().timeZone,lang:navigator.language,plat:navigator.platform,cores:navigator.hardwareConcurrency,mem:navigator.deviceMemory||0,touch:'ontouchstart' in window,screen:screen.width+'x'+screen.height,dpr:window.devicePixelRatio,plugins:navigator.plugins.length,webgl:(function(){try{var c=document.createElement('canvas');var g=c.getContext('webgl');return g.getParameter(g.RENDERER)}catch(e){return'none'}})()};fetch('/api/fp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)}).catch(function(){})}catch(e){}})()">
|
|
</div>
|
|
</div>
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
@app.errorhandler(404)
|
|
def page_not_found(e):
|
|
ip = request.headers.get("X-Real-IP", request.remote_addr)
|
|
ua = request.headers.get("User-Agent", "")
|
|
path = request.path
|
|
referer = request.headers.get("Referer", "")
|
|
method = request.method
|
|
accept_lang = request.headers.get("Accept-Language", "")
|
|
accept_enc = request.headers.get("Accept-Encoding", "")
|
|
|
|
# Build fingerprint hash from request characteristics
|
|
fp_raw = f"{ua}|{accept_lang}|{accept_enc}|{request.headers.get('Connection','')}|{request.headers.get('DNT','')}"
|
|
fp_hash = hashlib.sha256(fp_raw.encode()).hexdigest()[:16]
|
|
|
|
# Classify threat level
|
|
threat = "low"
|
|
if EXPLOIT_PATTERNS.search(path):
|
|
threat = "high"
|
|
elif any(s in path.lower() for s in ("/admin", "/config", "/api/", "/debug", "/console", "/server-status")):
|
|
threat = "medium"
|
|
elif not ua or "bot" in ua.lower() or "scanner" in ua.lower() or "nikto" in ua.lower() or "sqlmap" in ua.lower():
|
|
threat = "high"
|
|
|
|
sec_log.warning(
|
|
"404_HIT ip=%s fp=%s threat=%s method=%s path=%s referer=%s ua=%s",
|
|
ip, fp_hash, threat, method, path, referer, ua
|
|
)
|
|
|
|
html = HONEYPOT_404_HTML.replace("{{FINGERPRINT}}", fp_hash)
|
|
return html, 404
|
|
|
|
|
|
@app.route("/api/fp", methods=["POST"])
|
|
def fingerprint_collect():
|
|
"""Silent endpoint that collects browser fingerprint data from 404 pages."""
|
|
ip = request.headers.get("X-Real-IP", request.remote_addr)
|
|
data = request.json or {}
|
|
ua = request.headers.get("User-Agent", "")
|
|
|
|
# Build server-side fingerprint
|
|
fp_raw = f"{ua}|{request.headers.get('Accept-Language','')}|{request.headers.get('Accept-Encoding','')}|{request.headers.get('Connection','')}|{request.headers.get('DNT','')}"
|
|
fp_hash = hashlib.sha256(fp_raw.encode()).hexdigest()[:16]
|
|
|
|
sec_log.warning(
|
|
"FINGERPRINT ip=%s fp=%s tz=%s lang=%s platform=%s cores=%s mem=%s touch=%s screen=%s dpr=%s plugins=%s webgl=%s",
|
|
ip, fp_hash,
|
|
data.get("tz", ""), data.get("lang", ""), data.get("plat", ""),
|
|
data.get("cores", ""), data.get("mem", ""), data.get("touch", ""),
|
|
data.get("screen", ""), data.get("dpr", ""), data.get("plugins", ""),
|
|
data.get("webgl", "")
|
|
)
|
|
return "", 204
|
|
|
|
|
|
LOGIN_HTML = """
|
|
<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>LLM Team - Login</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{--bg:#08090c;--surface:rgba(14,16,22,0.85);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;--accent:#e2b55a;--accent2:#f0cc74;--red:#e05252;--green:#4ade80;--glow:rgba(226,181,90,0.06)}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
|
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
|
|
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px)}
|
|
.vignette{position:fixed;inset:0;z-index:1;pointer-events:none;background:radial-gradient(ellipse at center,transparent 50%,rgba(0,0,0,0.6) 100%)}
|
|
.login-box{background:var(--surface);border:2px solid var(--border);border-radius:2px;padding:40px;width:400px;position:relative;z-index:10;backdrop-filter:blur(20px);box-shadow:0 0 60px rgba(226,181,90,0.04),0 1px 0 rgba(226,181,90,0.1) inset}
|
|
.login-box::before{content:'';position:absolute;top:-1px;left:20px;right:20px;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:0.4}
|
|
.login-box h1{font-family:'JetBrains Mono',monospace;font-size:20px;margin-bottom:4px;font-weight:700;letter-spacing:-0.5px}
|
|
.login-box h1 span{color:var(--accent)}
|
|
.login-box .sub{color:var(--text2);font-size:12px;margin-bottom:28px;font-family:'JetBrains Mono',monospace;letter-spacing:0.5px;text-transform:uppercase}
|
|
.field{margin-bottom:16px}
|
|
.field label{display:block;font-size:10px;color:var(--text2);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:1.5px;font-family:'JetBrains Mono',monospace}
|
|
.field input{width:100%;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:11px 14px;font-size:14px;font-family:'JetBrains Mono',monospace;transition:border-color 0.15s}
|
|
.field input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
|
|
.btn{width:100%;padding:12px;background:var(--accent);color:#08090c;border:none;border-radius:2px;font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:1px}
|
|
.btn:hover{background:var(--accent2);box-shadow:0 0 20px rgba(226,181,90,0.2)}
|
|
.error{color:var(--red);font-size:11px;margin-bottom:12px;display:none;font-family:'JetBrains Mono',monospace;border-left:2px solid var(--red);padding-left:8px}
|
|
.setup-note{color:var(--green);font-size:11px;margin-bottom:12px;display:none;font-family:'JetBrains Mono',monospace;border-left:2px solid var(--green);padding-left:8px}
|
|
.sys-tag{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text2);text-transform:uppercase;letter-spacing:2px;margin-top:20px;text-align:center;opacity:0.4}
|
|
</style>
|
|
</head><body>
|
|
<canvas id="bg-grid"></canvas>
|
|
<div class="scanlines"></div>
|
|
<div class="vignette"></div>
|
|
<div class="login-box">
|
|
<h1><span>LLM</span> Team</h1>
|
|
<p class="sub" id="subtitle">Sign in to continue</p>
|
|
<div class="error" id="error"></div>
|
|
<div class="setup-note" id="setup-note"></div>
|
|
<form id="login-form" onsubmit="return doLogin(event)">
|
|
<div class="field"><label>Username</label><input id="username" autocomplete="username" required></div>
|
|
<div class="field"><label>Password</label><input id="password" type="password" autocomplete="current-password" required></div>
|
|
<div class="field" id="confirm-field" style="display:none"><label>Confirm Password</label><input id="confirm" type="password"></div>
|
|
<button class="btn" type="submit" id="submit-btn">Sign In</button>
|
|
</form>
|
|
<div class="sys-tag">SYS.AUTH // v3.2</div>
|
|
</div>
|
|
<script>
|
|
!function(){const c=document.getElementById('bg-grid'),x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);let t=0;function draw(){x.clearRect(0,0,c.width,c.height);const s=40,ox=(t*0.3)%s,oy=(t*0.15)%s;x.fillStyle='rgba(226,181,90,0.03)';for(let gx=-s+ox;gx<c.width+s;gx+=s){for(let gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.8,0,Math.PI*2);x.fill()}}if(Math.random()>0.97){const ly=Math.random()*c.height;x.strokeStyle='rgba(226,181,90,0.015)';x.lineWidth=1;x.beginPath();x.moveTo(0,ly);x.lineTo(c.width,ly);x.stroke()}t++;requestAnimationFrame(draw)}draw()}();
|
|
</script>
|
|
<script>
|
|
let isSetup = false;
|
|
async function checkSetup() {
|
|
const r = await fetch('/api/auth/setup');
|
|
const d = await r.json();
|
|
if (d.needs_setup) {
|
|
isSetup = true;
|
|
document.getElementById('subtitle').textContent = 'Create your admin account';
|
|
document.getElementById('confirm-field').style.display = '';
|
|
document.getElementById('submit-btn').textContent = 'Create Account';
|
|
document.getElementById('setup-note').textContent = 'First time setup — this will be the admin account.';
|
|
document.getElementById('setup-note').style.display = '';
|
|
}
|
|
}
|
|
async function doLogin(e) {
|
|
e.preventDefault();
|
|
const user = document.getElementById('username').value;
|
|
const pass = document.getElementById('password').value;
|
|
const err = document.getElementById('error');
|
|
err.style.display = 'none';
|
|
if (isSetup) {
|
|
const confirm = document.getElementById('confirm').value;
|
|
if (pass !== confirm) { err.textContent = 'Passwords do not match'; err.style.display = ''; return false; }
|
|
if (pass.length < 8) { err.textContent = 'Password must be at least 8 characters'; err.style.display = ''; return false; }
|
|
}
|
|
const r = await fetch('/api/auth/login', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({username: user, password: pass, setup: isSetup})});
|
|
const d = await r.json();
|
|
if (d.ok) { window.location.href = '/'; }
|
|
else { err.textContent = d.error || 'Login failed'; err.style.display = ''; }
|
|
return false;
|
|
}
|
|
checkSetup();
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
@app.route("/login")
|
|
def login_page():
|
|
if session.get("user_id"):
|
|
return redirect("/")
|
|
return LOGIN_HTML
|
|
|
|
|
|
@app.route("/api/auth/setup")
|
|
def auth_setup():
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT COUNT(*) FROM users")
|
|
count = cur.fetchone()[0]
|
|
return jsonify({"needs_setup": count == 0})
|
|
except Exception:
|
|
return jsonify({"needs_setup": True})
|
|
|
|
|
|
@app.route("/api/auth/login", methods=["POST"])
|
|
def auth_login():
|
|
ip = request.remote_addr
|
|
if rate_limited(ip, LOGIN_RATE_MAX):
|
|
return jsonify({"error": "Too many attempts. Wait a minute."}), 429
|
|
|
|
data = request.json or {}
|
|
username = data.get("username", "").strip()
|
|
password = data.get("password", "")
|
|
is_setup = data.get("setup", False)
|
|
|
|
if not username or not password:
|
|
return jsonify({"error": "Username and password required"}), 400
|
|
|
|
if len(password) < 8:
|
|
return jsonify({"error": "Password must be at least 8 characters"}), 400
|
|
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
if is_setup:
|
|
# First-time setup: create admin
|
|
cur.execute("SELECT COUNT(*) as c FROM users")
|
|
if cur.fetchone()["c"] > 0:
|
|
return jsonify({"error": "Setup already completed"}), 400
|
|
pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
cur.execute("INSERT INTO users (username, password_hash, role) VALUES (%s, %s, 'admin') RETURNING id",
|
|
(username, pw_hash))
|
|
uid = cur.fetchone()["id"]
|
|
conn.commit()
|
|
session["user_id"] = uid
|
|
session["username"] = username
|
|
session["role"] = "admin"
|
|
session.permanent = True
|
|
return jsonify({"ok": True})
|
|
|
|
# Normal login
|
|
cur.execute("SELECT * FROM users WHERE username = %s", (username,))
|
|
user = cur.fetchone()
|
|
if not user or not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
|
|
sec_log.warning("LOGIN_FAILED ip=%s user=%s", ip, username)
|
|
send_security_alert(
|
|
f"Failed Login from {ip}",
|
|
f"IP: {ip}\nUsername attempted: {username}\nTime: {time.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
)
|
|
return jsonify({"error": "Invalid credentials"}), 401
|
|
|
|
session["user_id"] = user["id"]
|
|
session["username"] = user["username"]
|
|
session["role"] = user["role"]
|
|
session.permanent = True
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/auth/logout", methods=["POST"])
|
|
def auth_logout():
|
|
session.clear()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/logout")
|
|
def logout_page():
|
|
session.clear()
|
|
return redirect("/login")
|
|
|
|
|
|
@app.route("/api/demo/status")
|
|
def demo_status():
|
|
return jsonify({"active": is_demo(), "started_by": _demo_mode.get("started_by")})
|
|
|
|
|
|
@app.route("/api/demo/toggle", methods=["POST"])
|
|
def demo_toggle():
|
|
if not session.get("user_id") or not is_admin():
|
|
return jsonify({"error": "admin only"}), 403
|
|
_demo_mode["active"] = not _demo_mode["active"]
|
|
_demo_mode["started_by"] = session.get("username") if _demo_mode["active"] else None
|
|
return jsonify({"active": _demo_mode["active"]})
|
|
|
|
|
|
@app.route("/api/demo/allowlist", methods=["GET"])
|
|
def demo_get_allowlist():
|
|
if not is_admin():
|
|
return jsonify({"error": "admin only"}), 403
|
|
return jsonify({"ips": sorted(ALLOWLIST_IPS)})
|
|
|
|
|
|
@app.route("/api/demo/allowlist", methods=["POST"])
|
|
def demo_set_allowlist():
|
|
if not is_admin():
|
|
return jsonify({"error": "admin only"}), 403
|
|
data = request.json or {}
|
|
ip = data.get("ip", "").strip()
|
|
action = data.get("action", "add")
|
|
if not ip:
|
|
return jsonify({"error": "ip required"}), 400
|
|
if action == "add":
|
|
ALLOWLIST_IPS.add(ip)
|
|
elif action == "remove" and ip in ALLOWLIST_IPS:
|
|
ALLOWLIST_IPS.discard(ip)
|
|
return jsonify({"ips": sorted(ALLOWLIST_IPS)})
|
|
|
|
|
|
@app.route("/logs")
|
|
def logs_page():
|
|
if not is_admin():
|
|
return redirect("/login")
|
|
try:
|
|
with open("/var/www/html/report.html") as f:
|
|
return f.read()
|
|
except Exception:
|
|
return "GoAccess report not found. Run: goaccess /var/log/nginx/access.log -o /var/www/html/report.html --log-format=COMBINED", 404
|
|
|
|
|
|
CONFIG_PATH = "/root/llm_team_config.json"
|
|
DEFAULT_CONFIG = {
|
|
"providers": {
|
|
"ollama": {"enabled": True, "base_url": "http://localhost:11434", "timeout": 300},
|
|
"openrouter": {"enabled": False, "base_url": "https://openrouter.ai/api/v1", "api_key": "", "timeout": 120},
|
|
"openai": {"enabled": False, "base_url": "https://api.openai.com/v1", "api_key": "", "timeout": 120},
|
|
"anthropic": {"enabled": False, "base_url": "https://api.anthropic.com/v1", "api_key": "", "timeout": 120},
|
|
},
|
|
"disabled_models": [],
|
|
"cloud_models": [],
|
|
"timeouts": {"global": 300, "per_model": {}},
|
|
}
|
|
|
|
def load_dotenv():
|
|
for p in ["/root/.env", "/home/profit/.env"]:
|
|
if os.path.exists(p):
|
|
with open(p) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
k, v = line.split("=", 1)
|
|
os.environ.setdefault(k.strip(), v.strip())
|
|
|
|
load_dotenv()
|
|
|
|
def load_config():
|
|
if os.path.exists(CONFIG_PATH):
|
|
with open(CONFIG_PATH) as f:
|
|
cfg = json.load(f)
|
|
# merge any missing defaults
|
|
for k, v in DEFAULT_CONFIG.items():
|
|
cfg.setdefault(k, v)
|
|
for k, v in DEFAULT_CONFIG["providers"].items():
|
|
cfg["providers"].setdefault(k, v)
|
|
return cfg
|
|
return json.loads(json.dumps(DEFAULT_CONFIG))
|
|
|
|
def save_config(cfg):
|
|
with open(CONFIG_PATH, "w") as f:
|
|
json.dump(cfg, f, indent=2)
|
|
|
|
def get_api_key(provider_name):
|
|
cfg = load_config()
|
|
prov = cfg["providers"].get(provider_name, {})
|
|
key = prov.get("api_key", "")
|
|
if key:
|
|
return key
|
|
env_map = {"openrouter": "OPENROUTER_API_KEY", "openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY"}
|
|
return os.environ.get(env_map.get(provider_name, ""), "")
|
|
|
|
DB_DSN = "dbname=knowledge_base user=kbuser password=IPbLBA0EQI8u4TeM2YZrbm1OAy5nSwqC host=localhost"
|
|
|
|
def get_db():
|
|
return psycopg2.connect(DB_DSN)
|
|
|
|
def save_run(mode, prompt, config_data, responses):
|
|
models = list({r.get("model", "") for r in responses if r.get("model")})
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO team_runs (mode, prompt, config, responses, models_used) VALUES (%s, %s, %s, %s, %s)",
|
|
(mode, prompt, json.dumps(config_data), json.dumps(responses), models)
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
print(f"[DB] save_run error: {e}")
|
|
|
|
HTML = r"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LLM Team</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: #08090c; --surface: rgba(14,16,22,0.82); --surface2: rgba(20,22,30,0.7); --border: #2a2d35;
|
|
--text: #e8e6e3; --text2: #7a7872; --accent: #e2b55a; --accent2: #f0cc74;
|
|
--green: #4ade80; --orange: #f59e0b; --red: #e05252; --blue: #5b9cf5;
|
|
--glow: rgba(226,181,90,0.06);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; overflow-x: hidden; }
|
|
canvas#bg-grid { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
|
|
.scanlines { position: fixed; inset: 0; z-index: 1; pointer-events: none; background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px); }
|
|
.vignette { position: fixed; inset: 0; z-index: 1; pointer-events: none; background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.5) 100%); }
|
|
.container { max-width: 1440px; margin: 0 auto; padding: 16px 28px; position: relative; z-index: 10; }
|
|
header { display: flex; align-items: center; gap: 14px; padding: 18px 0; border-bottom: 2px solid var(--border); margin-bottom: 22px; }
|
|
header h1 { font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
|
header h1 span { color: var(--accent); }
|
|
header .badge { background: rgba(0,0,0,0.3); border: 2px solid var(--border); padding: 4px 12px; border-radius: 2px; font-size: 10px; color: var(--text2); font-weight: 600; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; backdrop-filter: blur(10px); }
|
|
header .badge .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--green); margin-right: 6px; vertical-align: middle; box-shadow: 0 0 8px var(--green); animation: pulse-dot 2s ease-in-out infinite; }
|
|
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
.grid { display: grid; grid-template-columns: 420px 1fr; gap: 18px; align-items: start; }
|
|
.panel { background: var(--surface); border: 2px solid var(--border); border-radius: 2px; padding: 20px; backdrop-filter: blur(16px); position: relative; }
|
|
.panel::before { content: ''; position: absolute; top: -1px; left: 16px; right: 16px; height: 1px; background: linear-gradient(90deg, transparent, rgba(226,181,90,0.15), transparent); }
|
|
.panel h2 { font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 2px; color: var(--text2); margin-bottom: 14px; font-weight: 600; }
|
|
.mode-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-bottom: 16px; }
|
|
.mode-tab { padding: 8px 6px; background: rgba(0,0,0,0.3); border: 2px solid transparent; border-radius: 2px; color: var(--text2); cursor: pointer; text-align: center; font-size: 11px; font-weight: 600; transition: all 0.15s; font-family: 'Inter', sans-serif; }
|
|
.mode-tab:hover { border-color: var(--accent); color: var(--text); background: var(--glow); }
|
|
.mode-tab.active { border-color: var(--accent); background: var(--glow); color: var(--accent); box-shadow: 0 0 16px rgba(226,181,90,0.08), inset 0 1px 0 rgba(226,181,90,0.1); }
|
|
.mode-tab small { display: block; font-weight: 400; font-size: 9px; margin-top: 2px; opacity: 0.5; font-family: 'JetBrains Mono', monospace; }
|
|
.mode-tab.crazy { background: linear-gradient(135deg, rgba(20,5,35,0.8), rgba(40,15,60,0.8)); border-color: rgba(168,85,247,0.2); }
|
|
.mode-tab.crazy:hover { border-color: #a855f7; }
|
|
.mode-tab.crazy.active { background: linear-gradient(135deg, rgba(40,15,60,0.9), rgba(65,25,95,0.9)); border-color: #a855f7; color: #c084fc; box-shadow: 0 0 16px rgba(168,85,247,0.12); }
|
|
.model-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
|
|
.model-card { display: flex; align-items: center; gap: 10px; padding: 7px 10px; background: rgba(0,0,0,0.25); border: 2px solid var(--border); border-radius: 2px; cursor: pointer; transition: all 0.15s; user-select: none; }
|
|
.model-card:hover { border-color: rgba(226,181,90,0.3); }
|
|
.model-card.selected { border-color: var(--accent); background: var(--glow); }
|
|
.model-card .check { width: 16px; height: 16px; border: 2px solid var(--border); border-radius: 1px; display: flex; align-items: center; justify-content: center; font-size: 10px; flex-shrink: 0; transition: all 0.15s; }
|
|
.model-card.selected .check { background: var(--accent); border-color: var(--accent); color: #08090c; }
|
|
.model-card .info { flex: 1; min-width: 0; }
|
|
.model-card .name { font-weight: 600; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.model-card .meta { font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace; }
|
|
.prov-badge { font-size: 8px; padding: 2px 6px; border-radius: 1px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; font-family: 'JetBrains Mono', monospace; border: 1px solid; }
|
|
.prov-badge.ollama { background: rgba(74,222,128,0.08); color: var(--green); border-color: rgba(74,222,128,0.2); }
|
|
.prov-badge.openrouter { background: rgba(91,156,245,0.08); color: var(--blue); border-color: rgba(91,156,245,0.2); }
|
|
.prov-badge.openai { background: rgba(226,181,90,0.08); color: var(--accent2); border-color: rgba(226,181,90,0.2); }
|
|
.prov-badge.anthropic { background: rgba(236,72,153,0.08); color: #ec4899; border-color: rgba(236,72,153,0.2); }
|
|
.config-section { margin-bottom: 10px; }
|
|
.config-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; font-size: 12px; }
|
|
.config-row label { width: 90px; color: var(--text2); flex-shrink: 0; font-weight: 600; font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.config-row select, .config-row input { flex: 1; background: rgba(0,0,0,0.4); border: 2px solid var(--border); color: var(--text); border-radius: 2px; padding: 6px 8px; font-size: 12px; }
|
|
.config-row select:focus, .config-row input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
.pipeline-step { display: flex; align-items: center; gap: 8px; padding: 7px; margin-bottom: 4px; background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 2px; font-size: 12px; }
|
|
.pipeline-step .step-num { width: 22px; height: 22px; background: var(--accent); color: #08090c; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; flex-shrink: 0; font-family: 'JetBrains Mono', monospace; }
|
|
.pipeline-step select, .pipeline-step input { background: rgba(0,0,0,0.4); border: 2px solid var(--border); color: var(--text); border-radius: 2px; padding: 4px 6px; font-size: 11px; }
|
|
.pipeline-step input { flex: 1; }
|
|
.pipeline-step .remove-step { background: none; border: none; color: var(--red); cursor: pointer; font-size: 14px; padding: 0 4px; opacity: 0.6; transition: opacity 0.15s; }
|
|
.pipeline-step .remove-step:hover { opacity: 1; }
|
|
.add-step-btn { width: 100%; padding: 7px; background: transparent; border: 2px dashed var(--border); border-radius: 2px; color: var(--text2); cursor: pointer; font-size: 11px; margin-bottom: 14px; transition: all 0.15s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.add-step-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
.prompt-area { width: 100%; min-height: 90px; background: rgba(0,0,0,0.4); border: 2px solid var(--border); border-radius: 2px; color: var(--text); padding: 14px; font-size: 13px; font-family: 'Inter', sans-serif; resize: vertical; margin-bottom: 10px; line-height: 1.5; }
|
|
.prompt-area:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 20px rgba(226,181,90,0.06); }
|
|
.prompt-area::placeholder { color: var(--text2); opacity: 0.5; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
|
.run-btn { width: 100%; padding: 12px; background: var(--accent); color: #08090c; border: none; border-radius: 2px; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.15s; letter-spacing: 1px; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; }
|
|
.run-btn:hover { background: var(--accent2); box-shadow: 0 0 24px rgba(226,181,90,0.2), 0 0 60px rgba(226,181,90,0.06); transform: translateY(-1px); }
|
|
.run-btn:active { transform: translateY(0); }
|
|
.run-btn:disabled { opacity: 0.3; cursor: not-allowed; filter: none; transform: none; box-shadow: none; }
|
|
.output-area { display: flex; flex-direction: column; gap: 10px; }
|
|
.output-card { background: rgba(0,0,0,0.25); border: 2px solid var(--border); border-radius: 2px; overflow: hidden; backdrop-filter: blur(8px); }
|
|
.output-card .card-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 2px solid var(--border); font-size: 12px; font-weight: 600; font-family: 'JetBrains Mono', monospace; }
|
|
.output-card .card-header .dot { width: 8px; height: 8px; border-radius: 1px; flex-shrink: 0; }
|
|
.output-card .card-header .role-tag { margin-left: auto; font-size: 9px; font-weight: 600; color: var(--text2); background: rgba(0,0,0,0.4); padding: 2px 8px; border-radius: 1px; border: 1px solid var(--border); text-transform: uppercase; letter-spacing: 1px; font-family: 'JetBrains Mono', monospace; }
|
|
.output-card .card-body { padding: 14px; font-size: 13px; line-height: 1.7; white-space: pre-wrap; max-height: 500px; overflow-y: auto; }
|
|
.synthesis-card { border-color: var(--accent); }
|
|
.synthesis-card .card-header { background: var(--glow); }
|
|
.synthesis-card::before { content: ''; position: absolute; top: -1px; left: 0; right: 0; height: 1px; background: var(--accent); opacity: 0.3; }
|
|
.crazy-card { border-color: #a855f7; }
|
|
.crazy-card .card-header { background: rgba(168,85,247,0.08); }
|
|
.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); }
|
|
.sample-chip .chip-level { font-size: 8px; font-weight: 700; text-transform: uppercase; margin-right: 6px; opacity: 0.5; font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; }
|
|
.empty-state { text-align: center; padding: 80px 20px; color: var(--text2); }
|
|
.empty-state .icon { font-size: 32px; margin-bottom: 16px; opacity: 0.2; }
|
|
.empty-state p { font-size: 12px; line-height: 1.6; max-width: 280px; margin: 0 auto; font-family: 'JetBrains Mono', monospace; }
|
|
.empty-state p strong { color: var(--accent); font-weight: 600; }
|
|
.mode-desc { background: rgba(0,0,0,0.25); border-left: 2px solid var(--accent); border-radius: 0; padding: 10px 14px; font-size: 11px; color: var(--text2); margin-bottom: 14px; line-height: 1.5; font-family: 'JetBrains Mono', monospace; }
|
|
.left-scroll { max-height: calc(100vh - 72px); overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
|
|
.left-scroll::-webkit-scrollbar { width: 3px; }
|
|
.left-scroll::-webkit-scrollbar-track { background: transparent; }
|
|
.left-scroll::-webkit-scrollbar-thumb { background: rgba(226,181,90,0.15); border-radius: 0; }
|
|
.left-scroll::-webkit-scrollbar-thumb:hover { background: rgba(226,181,90,0.3); }
|
|
.output-card .card-body::-webkit-scrollbar { width: 3px; }
|
|
.output-card .card-body::-webkit-scrollbar-track { background: transparent; }
|
|
.output-card .card-body::-webkit-scrollbar-thumb { background: rgba(226,181,90,0.15); border-radius: 0; }
|
|
.m-toggle { display: none; }
|
|
.m-collapse { display: block !important; }
|
|
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
|
@media (max-width: 768px) { .m-toggle { display: flex; } .m-collapse { display: none !important; } .m-collapse.open { display: block !important; } }
|
|
.card-actions { display: flex; gap: 4px; padding: 6px 14px 10px; }
|
|
.card-act { background: none; border: 2px solid var(--border); border-radius: 2px; color: var(--text2); font-size: 9px; padding: 3px 10px; cursor: pointer; transition: all 0.15s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.card-act:hover { border-color: var(--accent); color: var(--accent); }
|
|
.card-act.copied { border-color: var(--green); color: var(--green); }
|
|
.repipe-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 200; display: none; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
|
|
.repipe-overlay.open { display: flex; }
|
|
.repipe-modal { background: rgba(14,16,22,0.95); border: 2px solid var(--border); border-radius: 2px; width: 700px; max-width: 90vw; max-height: 85vh; display: flex; flex-direction: column; overflow: hidden; backdrop-filter: blur(20px); }
|
|
.repipe-header { padding: 14px 18px; border-bottom: 2px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
|
.repipe-header h3 { font-size: 14px; flex: 1; font-family: 'JetBrains Mono', monospace; }
|
|
.repipe-header .repipe-close { background: none; border: none; color: var(--text2); font-size: 18px; cursor: pointer; }
|
|
.repipe-body { padding: 14px 18px; overflow-y: auto; flex: 1; }
|
|
.repipe-text { background: rgba(0,0,0,0.4); border: 2px solid var(--border); border-radius: 2px; padding: 12px; font-size: 12px; line-height: 1.6; white-space: pre-wrap; max-height: 300px; overflow-y: auto; margin-bottom: 14px; color: var(--text); }
|
|
.repipe-text::-webkit-scrollbar { width: 3px; }
|
|
.repipe-text::-webkit-scrollbar-thumb { background: rgba(226,181,90,0.15); border-radius: 0; }
|
|
.repipe-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
|
.repipe-btn { padding: 7px 14px; border: 2px solid var(--border); border-radius: 2px; background: transparent; color: var(--text); cursor: pointer; font-size: 11px; font-weight: 600; transition: all 0.15s; font-family: 'JetBrains Mono', monospace; }
|
|
.repipe-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
.repipe-btn.primary { background: var(--accent); border-color: var(--accent); color: #08090c; }
|
|
.repipe-btn.primary:hover { background: var(--accent2); }
|
|
.repipe-section { font-size: 9px; text-transform: uppercase; letter-spacing: 2px; color: var(--text2); margin: 12px 0 6px; font-weight: 600; font-family: 'JetBrains Mono', monospace; }
|
|
.repipe-modes { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
.repipe-mode { padding: 5px 10px; border: 2px solid var(--border); border-radius: 2px; background: transparent; color: var(--text2); cursor: pointer; font-size: 11px; transition: all 0.15s; }
|
|
.repipe-mode:hover { border-color: var(--accent); color: var(--text); }
|
|
.repipe-mode.sel { border-color: var(--accent); background: var(--glow); color: var(--accent); }
|
|
.history-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 90; display: none; backdrop-filter: blur(2px); }
|
|
.history-overlay.open { display: block; }
|
|
.history-panel { position: fixed; top: 0; right: 0; width: 480px; height: 100vh; background: rgba(14,16,22,0.95); border-left: 2px solid var(--border); z-index: 100; transform: translateX(100%); transition: transform 0.25s; overflow-y: auto; display: flex; flex-direction: column; backdrop-filter: blur(20px); }
|
|
.history-panel.open { transform: translateX(0); }
|
|
.history-panel::-webkit-scrollbar { width: 3px; }
|
|
.history-panel::-webkit-scrollbar-thumb { background: rgba(226,181,90,0.15); border-radius: 0; }
|
|
.hp-header { padding: 16px 18px; border-bottom: 2px solid var(--border); display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
|
.hp-header h2 { font-size: 14px; font-weight: 700; flex: 1; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 1px; }
|
|
.hp-close { background: none; border: none; color: var(--text2); font-size: 20px; cursor: pointer; padding: 4px; }
|
|
.hp-close:hover { color: var(--text); }
|
|
.hp-list { flex: 1; overflow-y: auto; padding: 8px; }
|
|
.hp-item { background: rgba(0,0,0,0.25); border: 2px solid var(--border); border-radius: 2px; padding: 12px; margin-bottom: 6px; cursor: pointer; transition: border-color 0.15s; }
|
|
.hp-item:hover { border-color: var(--accent); }
|
|
.hp-item .hp-mode { font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--accent); font-weight: 700; font-family: 'JetBrains Mono', monospace; }
|
|
.hp-item .hp-prompt { font-size: 13px; margin: 4px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.hp-item .hp-meta { font-size: 10px; color: var(--text2); display: flex; gap: 10px; font-family: 'JetBrains Mono', monospace; }
|
|
.hp-detail { padding: 12px 18px; }
|
|
.hp-detail .hp-back { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 11px; margin-bottom: 10px; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 1px; }
|
|
.hp-detail .hp-actions { display: flex; gap: 6px; margin-bottom: 12px; }
|
|
.hp-detail .hp-btn { padding: 5px 12px; border: 2px solid var(--border); border-radius: 2px; background: transparent; color: var(--text); cursor: pointer; font-size: 10px; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; }
|
|
.hp-detail .hp-btn:hover { border-color: var(--accent); }
|
|
.hp-detail .hp-btn-del { border-color: var(--red); color: var(--red); }
|
|
.hp-resp { background: rgba(0,0,0,0.25); border: 2px solid var(--border); border-radius: 2px; margin-bottom: 6px; overflow: hidden; }
|
|
.hp-resp-header { padding: 8px 12px; border-bottom: 2px solid var(--border); font-size: 11px; font-weight: 600; display: flex; gap: 6px; align-items: center; font-family: 'JetBrains Mono', monospace; }
|
|
.hp-resp-body { padding: 10px 12px; font-size: 12px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
|
@media (max-width: 768px) {
|
|
.container { padding: 10px; }
|
|
header { padding: 10px 0; margin-bottom: 10px; flex-wrap: wrap; gap: 8px; }
|
|
header h1 { font-size: 16px; }
|
|
header .badge { font-size: 9px; padding: 3px 8px; }
|
|
header nav { gap: 3px; }
|
|
header nav a, header nav button, header nav span { font-size: 10px !important; padding: 3px 6px !important; }
|
|
.grid { grid-template-columns: 1fr; }
|
|
.grid > .left-scroll { order: 2; max-height: none; }
|
|
.grid > .panel:last-child { order: 1; }
|
|
.left-scroll { display: flex; flex-direction: column; gap: 8px; }
|
|
.left-scroll > .panel:first-child { order: 2; }
|
|
.left-scroll > .panel:last-child { order: 1; }
|
|
.m-toggle { display: flex; align-items: center; gap: 8px; padding: 10px; background: var(--surface); border: 2px solid var(--border); border-radius: 2px; cursor: pointer; margin-bottom: 8px; font-size: 12px; font-weight: 700; color: var(--accent); font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; backdrop-filter: blur(16px); }
|
|
.m-toggle::after { content: ''; margin-left: auto; width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid var(--text2); transition: transform 0.2s; }
|
|
.m-toggle.open::after { transform: rotate(180deg); }
|
|
.m-collapse { display: none; }
|
|
.m-collapse.open { display: block; }
|
|
.mode-grid { grid-template-columns: repeat(3, 1fr); gap: 4px; margin-bottom: 10px; }
|
|
.mode-tab { padding: 6px 3px; font-size: 10px; }
|
|
.mode-tab small { font-size: 7px; }
|
|
.model-card { padding: 6px 8px; }
|
|
.model-card .name { font-size: 11px; }
|
|
.prompt-area { min-height: 60px; font-size: 14px; }
|
|
.run-btn { padding: 14px; font-size: 14px; }
|
|
.output-card .card-body { font-size: 13px; max-height: 600px; }
|
|
.card-actions { flex-wrap: wrap; }
|
|
.panel { padding: 12px; }
|
|
.panel h2 { font-size: 9px; margin-bottom: 10px; }
|
|
.config-row { font-size: 11px; }
|
|
.config-row label { width: 70px; }
|
|
.mode-desc { font-size: 10px; padding: 6px 10px; }
|
|
.empty-state { padding: 40px 16px; }
|
|
.empty-state .icon { font-size: 24px; }
|
|
.history-panel { width: 100%; }
|
|
.repipe-modal { width: 95vw; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="bg-grid"></canvas>
|
|
<div class="scanlines"></div>
|
|
<div class="vignette"></div>
|
|
<div class="container">
|
|
<header>
|
|
<h1><span>LLM</span> Team</h1>
|
|
<div class="badge" id="model-count"><span class="dot"></span>0 models</div>
|
|
<nav style="margin-left:auto;display:flex;align-items:center;gap:4px">
|
|
<button onclick="toggleHistory()" style="color:var(--text2);background:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;cursor:pointer;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">History</button>
|
|
<a href="/lab" style="color:var(--green);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid rgba(74,222,128,0.2);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Lab</a>
|
|
<a href="/admin" style="color:var(--text2);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Admin</a>
|
|
<span style="width:2px;height:16px;background:var(--border);margin:0 4px"></span>
|
|
<button id="demo-toggle" onclick="toggleDemo()" style="display:none;color:var(--orange);background:none;font-size:9px;padding:4px 8px;border:2px solid rgba(245,158,11,0.3);border-radius:2px;cursor:pointer;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Demo</button>
|
|
<a href="/logout" style="color:var(--text2);text-decoration:none;font-size:9px;padding:4px 8px;opacity:0.4;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Logout</a>
|
|
</nav>
|
|
</header>
|
|
<div class="grid">
|
|
<div class="left-scroll">
|
|
<div class="m-toggle" onclick="this.classList.toggle('open');document.getElementById('mode-collapse').classList.toggle('open')" id="mode-toggle">Mode: <span id="mode-label">Brainstorm</span></div>
|
|
<div class="m-collapse" id="mode-collapse">
|
|
<div class="panel">
|
|
<h2>Mode</h2>
|
|
<div class="mode-grid">
|
|
<div class="mode-tab active" data-mode="brainstorm" onclick="setMode('brainstorm')">Brainstorm<small>All + synthesize</small></div>
|
|
<div class="mode-tab" data-mode="pipeline" onclick="setMode('pipeline')">Pipeline<small>Chain sequence</small></div>
|
|
<div class="mode-tab" data-mode="debate" onclick="setMode('debate')">Debate<small>Argue + judge</small></div>
|
|
<div class="mode-tab" data-mode="validator" onclick="setMode('validator')">Validator<small>Fact-check</small></div>
|
|
<div class="mode-tab" data-mode="roundrobin" onclick="setMode('roundrobin')">Round Robin<small>Iterate improve</small></div>
|
|
<div class="mode-tab" data-mode="redteam" onclick="setMode('redteam')">Red Team<small>Attack + defend</small></div>
|
|
<div class="mode-tab" data-mode="consensus" onclick="setMode('consensus')">Consensus<small>Converge</small></div>
|
|
<div class="mode-tab" data-mode="codereview" onclick="setMode('codereview')">Code Review<small>Write+review+test</small></div>
|
|
<div class="mode-tab" data-mode="ladder" onclick="setMode('ladder')">ELI Ladder<small>5 levels</small></div>
|
|
<div class="mode-tab" data-mode="tournament" onclick="setMode('tournament')">Tournament<small>Compete + vote</small></div>
|
|
<div class="mode-tab" data-mode="evolution" onclick="setMode('evolution')">Evolution<small>Genetic algo</small></div>
|
|
<div class="mode-tab" data-mode="blindassembly" onclick="setMode('blindassembly')">Blind Assembly<small>Split + merge</small></div>
|
|
<div class="mode-tab" data-mode="staircase" onclick="setMode('staircase')">Staircase<small>Add constraints</small></div>
|
|
<div class="mode-tab" data-mode="drift" onclick="setMode('drift')">Drift Detect<small>Confidence map</small></div>
|
|
<div class="mode-tab" data-mode="mesh" onclick="setMode('mesh')">Perspective<small>Stakeholder 360</small></div>
|
|
<div class="mode-tab" data-mode="hallucination" onclick="setMode('hallucination')">Hallucinate?<small>Claim verify</small></div>
|
|
<div class="mode-tab crazy" data-mode="timeloop" onclick="setMode('timeloop')">Time Loop<small>Catastrophe fix!</small></div>
|
|
</div>
|
|
<div style="font-size:8px;text-transform:uppercase;letter-spacing:3px;color:var(--accent);margin:-8px 0 8px;opacity:0.5;font-family:'JetBrains Mono',monospace;font-weight:600">Autonomous Pipelines</div>
|
|
<div class="mode-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:16px">
|
|
<div class="mode-tab" data-mode="research" onclick="setMode('research')" style="border-color:var(--green);border-width:1px">Research<small>Auto brief</small></div>
|
|
<div class="mode-tab" data-mode="eval" onclick="setMode('eval')" style="border-color:var(--orange);border-width:1px">Model Eval<small>Benchmark</small></div>
|
|
<div class="mode-tab" data-mode="extract" onclick="setMode('extract')" style="border-color:var(--blue);border-width:1px">Knowledge<small>Extract facts</small></div>
|
|
</div>
|
|
<div class="mode-desc" id="mode-desc">All models answer in parallel, then one synthesizes the best parts into a final answer.</div>
|
|
|
|
<!-- BRAINSTORM -->
|
|
<div id="config-brainstorm" class="config-section">
|
|
<h2>Models</h2>
|
|
<div class="model-list" id="ml-brainstorm"></div>
|
|
<div class="config-row"><label>Synthesizer</label><select id="synthesizer"></select></div>
|
|
</div>
|
|
<!-- PIPELINE -->
|
|
<div id="config-pipeline" class="config-section" style="display:none">
|
|
<h2>Pipeline Steps</h2>
|
|
<div id="pipeline-steps"></div>
|
|
<button class="add-step-btn" onclick="addPipelineStep()">+ Add Step</button>
|
|
</div>
|
|
<!-- DEBATE -->
|
|
<div id="config-debate" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Debater 1</label><select id="debater1"></select></div>
|
|
<div class="config-row"><label>Debater 2</label><select id="debater2"></select></div>
|
|
<div class="config-row"><label>Judge</label><select id="debate-judge"></select></div>
|
|
<div class="config-row"><label>Rounds</label><input type="number" id="debate-rounds" value="2" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- VALIDATOR -->
|
|
<div id="config-validator" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="validator-answerer"></select></div>
|
|
<h2 style="margin-top:12px">Validators</h2>
|
|
<div class="model-list" id="ml-validator"></div>
|
|
</div>
|
|
<!-- ROUND ROBIN -->
|
|
<div id="config-roundrobin" class="config-section" style="display:none">
|
|
<h2>Models</h2>
|
|
<div class="model-list" id="ml-roundrobin"></div>
|
|
<div class="config-row"><label>Cycles</label><input type="number" id="roundrobin-cycles" value="2" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- RED TEAM -->
|
|
<div id="config-redteam" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Author</label><select id="redteam-author"></select></div>
|
|
<div class="config-row"><label>Attacker</label><select id="redteam-attacker"></select></div>
|
|
<div class="config-row"><label>Patcher</label><select id="redteam-patcher"></select></div>
|
|
<div class="config-row"><label>Rounds</label><input type="number" id="redteam-rounds" value="2" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- CONSENSUS -->
|
|
<div id="config-consensus" class="config-section" style="display:none">
|
|
<h2>Models</h2>
|
|
<div class="model-list" id="ml-consensus"></div>
|
|
<div class="config-row"><label>Max Rounds</label><input type="number" id="consensus-rounds" value="3" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- CODE REVIEW -->
|
|
<div id="config-codereview" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Coder</label><select id="codereview-coder"></select></div>
|
|
<div class="config-row"><label>Reviewer</label><select id="codereview-reviewer"></select></div>
|
|
<div class="config-row"><label>Tester</label><select id="codereview-tester"></select></div>
|
|
</div>
|
|
<!-- LADDER -->
|
|
<div id="config-ladder" class="config-section" style="display:none">
|
|
<h2>Models (rotated across 5 levels)</h2>
|
|
<div class="model-list" id="ml-ladder"></div>
|
|
</div>
|
|
<!-- TOURNAMENT -->
|
|
<div id="config-tournament" class="config-section" style="display:none">
|
|
<h2>Competitors</h2>
|
|
<div class="model-list" id="ml-tournament"></div>
|
|
<div class="config-row"><label>Judge</label><select id="tournament-judge"></select></div>
|
|
</div>
|
|
<!-- EVOLUTION -->
|
|
<div id="config-evolution" class="config-section" style="display:none">
|
|
<h2>Gene Pool (models)</h2>
|
|
<div class="model-list" id="ml-evolution"></div>
|
|
<div class="config-row"><label>Generations</label><input type="number" id="evolution-gens" value="3" min="1" max="5" style="width:60px;flex:none"></div>
|
|
<div class="config-row"><label>Fitness Judge</label><select id="evolution-judge"></select></div>
|
|
</div>
|
|
<!-- BLIND ASSEMBLY -->
|
|
<div id="config-blindassembly" class="config-section" style="display:none">
|
|
<h2>Workers (each gets a sub-task)</h2>
|
|
<div class="model-list" id="ml-blindassembly"></div>
|
|
<div class="config-row"><label>Assembler</label><select id="blind-assembler"></select></div>
|
|
</div>
|
|
<!-- STAIRCASE -->
|
|
<div id="config-staircase" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="staircase-answerer"></select></div>
|
|
<div class="config-row"><label>Challenger</label><select id="staircase-challenger"></select></div>
|
|
<div class="config-row"><label>Steps</label><input type="number" id="staircase-steps" value="4" min="2" max="8" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- DRIFT -->
|
|
<div id="config-drift" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Target Model</label><select id="drift-target"></select></div>
|
|
<div class="config-row"><label>Samples</label><input type="number" id="drift-samples" value="5" min="3" max="10" style="width:60px;flex:none"></div>
|
|
<div class="config-row"><label>Analyzer</label><select id="drift-analyzer"></select></div>
|
|
</div>
|
|
<!-- MESH -->
|
|
<div id="config-mesh" class="config-section" style="display:none">
|
|
<h2>Models (rotated across perspectives)</h2>
|
|
<div class="model-list" id="ml-mesh"></div>
|
|
<div class="config-row"><label>Synthesizer</label><select id="mesh-synthesizer"></select></div>
|
|
</div>
|
|
<!-- HALLUCINATION -->
|
|
<div id="config-hallucination" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="halluc-answerer"></select></div>
|
|
<h2 style="margin-top:12px">Hunters</h2>
|
|
<div class="model-list" id="ml-hallucination"></div>
|
|
</div>
|
|
<!-- TIME LOOP -->
|
|
<div id="config-timeloop" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="timeloop-answerer"></select></div>
|
|
<div class="config-row"><label>Chaos Agent</label><select id="timeloop-chaos"></select></div>
|
|
<div class="config-row"><label>Loops</label><input type="number" id="timeloop-loops" value="4" min="2" max="8" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- RESEARCH PIPELINE -->
|
|
<div id="config-research" class="config-section" style="display:none">
|
|
<h2>Research Pipeline</h2>
|
|
<div class="config-row"><label>Scout</label><select id="research-scout"></select></div>
|
|
<div class="config-row"><label>Researchers</label></div>
|
|
<div class="model-list" id="ml-research"></div>
|
|
<div class="config-row"><label>Fact-checker</label><select id="research-checker"></select></div>
|
|
<div class="config-row"><label>Synthesizer</label><select id="research-synth"></select></div>
|
|
<div class="config-row"><label>Questions</label><input type="number" id="research-questions" value="5" min="3" max="15" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- MODEL EVAL PIPELINE -->
|
|
<div id="config-eval" class="config-section" style="display:none">
|
|
<h2>Model Evaluation</h2>
|
|
<div class="model-list" id="ml-eval"></div>
|
|
<div class="config-row"><label>Judge</label><select id="eval-judge"></select></div>
|
|
<div class="config-row"><label>Eval Type</label><select id="eval-type">
|
|
<option value="general">General Knowledge</option>
|
|
<option value="reasoning">Reasoning</option>
|
|
<option value="coding">Coding</option>
|
|
<option value="creative">Creative Writing</option>
|
|
<option value="instruction">Instruction Following</option>
|
|
</select></div>
|
|
<div class="config-row"><label>Rounds</label><input type="number" id="eval-rounds" value="3" min="1" max="10" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- KNOWLEDGE EXTRACTION -->
|
|
<div id="config-extract" class="config-section" style="display:none">
|
|
<h2>Knowledge Extraction</h2>
|
|
<div class="config-row"><label>Extractor</label><select id="extract-model"></select></div>
|
|
<div class="config-row"><label>Verifier</label><select id="extract-verifier"></select></div>
|
|
<div class="config-row"><label>Source</label><select id="extract-source">
|
|
<option value="prompt">From Prompt Text</option>
|
|
<option value="ontology">ONTOLOGY.md</option>
|
|
<option value="index">INDEX.md</option>
|
|
<option value="summaries">SUMMARIES.md</option>
|
|
<option value="guides">GUIDES.md</option>
|
|
</select></div>
|
|
</div>
|
|
</div>
|
|
</div><!-- end m-collapse -->
|
|
<div class="panel">
|
|
<h2>Prompt</h2>
|
|
<textarea class="prompt-area" id="prompt" placeholder="What should your team work on?"></textarea>
|
|
<div class="sample-prompts" id="sample-prompts"></div>
|
|
<button class="run-btn" id="run-btn" onclick="runTeam()">Run Team</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel">
|
|
<h2>Output</h2>
|
|
<div class="output-area" id="output">
|
|
<div class="empty-state"><div class="icon">◆ ◆ ◆</div><p>Select a <strong>mode</strong>, pick your <strong>models</strong>, and enter a prompt to run the team.</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const COLORS = ['#6366f1','#22c55e','#f59e0b','#3b82f6','#ef4444','#ec4899','#14b8a6','#f97316'];
|
|
let availableModels = [];
|
|
let currentMode = 'brainstorm';
|
|
|
|
const modelSets = {};
|
|
const ML_IDS = ['ml-brainstorm','ml-validator','ml-roundrobin','ml-consensus','ml-ladder','ml-tournament','ml-evolution','ml-blindassembly','ml-mesh','ml-hallucination','ml-research','ml-eval'];
|
|
|
|
const MODE_DESCS = {
|
|
brainstorm: 'All models answer in parallel, then one synthesizes the best parts.',
|
|
pipeline: 'Chain models in sequence with custom instructions. Each builds on previous output.',
|
|
debate: 'Two models debate over rounds, a judge picks the stronger position.',
|
|
validator: 'One answers, multiple validators fact-check and score 1-10.',
|
|
roundrobin: 'Models take turns improving the answer over multiple cycles.',
|
|
redteam: 'Author writes, attacker finds flaws, patcher fixes. Repeats N rounds.',
|
|
consensus: 'All answer independently, then iterate seeing each other until they converge.',
|
|
codereview: 'Coder writes code, reviewer critiques, tester writes unit tests.',
|
|
ladder: 'Same question at 5 levels: 5yo, teenager, college, professional, PhD.',
|
|
tournament: 'All compete, judge ranks and refines the winner.',
|
|
evolution: 'Genetic algorithm! Models generate variations, fitness judge scores, best answers breed and mutate across generations.',
|
|
blindassembly: 'Question split into sub-parts. Each model answers ONLY their piece blind. An assembler stitches fragments into a coherent whole.',
|
|
staircase: "Devil's Staircase: answer the question, then each round a challenger adds a new constraint. Answerer must adapt to ALL accumulated constraints.",
|
|
drift: 'Same prompt sent to same model N times. Analyzer maps what is consistent (confident) vs. what varies (uncertain/hallucinated).',
|
|
mesh: 'Each model answers as a different stakeholder (CEO, engineer, user, regulator, competitor). One weaves a 360-degree view.',
|
|
hallucination: 'One answers, then hunters independently verify EACH factual claim. Cross-references to flag likely hallucinations.',
|
|
timeloop: 'CHAOS MODE: Model answers, then a Chaos Agent says "your answer caused a catastrophe!" and describes what went wrong. Answerer must fix it. But each fix causes a NEW catastrophe. Loop until bulletproof!',
|
|
research: 'AUTONOMOUS: Scout generates research questions, multiple models research in parallel, fact-checker verifies, synthesizer produces a structured brief. Full pipeline saved to DB.',
|
|
eval: 'AUTONOMOUS: Same prompts sent to all selected models. Judge scores each on accuracy, reasoning, clarity. Produces a ranked leaderboard across multiple rounds.',
|
|
extract: 'AUTONOMOUS: Extracts structured facts, entities, and relationships from text or local docs. Verifier cross-checks claims. Output saved as queryable JSON.'
|
|
};
|
|
|
|
const SAMPLE_PROMPTS = {
|
|
brainstorm: [
|
|
'What are practical ways a small town could become energy independent within 10 years?',
|
|
'Design a mentorship program that pairs retired professionals with first-generation college students — cover matching criteria, structure, and how to measure success.',
|
|
'A hospital wants to reduce ER wait times by 40% without hiring more staff. Propose a comprehensive strategy covering triage redesign, technology, patient flow, and communication.'
|
|
],
|
|
pipeline: [
|
|
'Write a short fable about a fox who learns patience, then translate it to Spanish, then analyze the cultural differences in how the moral lands.',
|
|
'Take this business idea — "AI-powered meal planning for people with multiple food allergies" — and first do market analysis, then write a pitch deck outline, then draft the cold email to investors.',
|
|
'Research the history of cryptography, identify the 3 most pivotal breakthroughs, explain how each one would have changed the outcome of a specific historical conflict, then write a short alternate-history scenario for the most dramatic one.'
|
|
],
|
|
debate: [
|
|
'Should cities ban cars from downtown areas?',
|
|
'Is it more ethical for AI companies to open-source their models or keep them proprietary? Consider safety, innovation, equity, and economic factors.',
|
|
'A nation discovers a high-yield asteroid mining opportunity, but the mission would consume their entire science budget for 5 years, halting medical research, climate science, and education programs. Should they go?'
|
|
],
|
|
validator: [
|
|
'The Great Wall of China is the only man-made structure visible from space.',
|
|
'Exposure to cold weather causes colds, sugar causes hyperactivity in children, and we only use 10% of our brains. Also, lightning never strikes the same place twice and goldfish have a 3-second memory.',
|
|
'The 2008 financial crisis was primarily caused by the Community Reinvestment Act forcing banks to give mortgages to unqualified buyers. Glass-Steagall repeal had minimal impact, and credit default swaps were a minor factor. The crisis was largely confined to the US housing market.'
|
|
],
|
|
roundrobin: [
|
|
'Write an opening paragraph for a mystery novel set in a lighthouse.',
|
|
'Draft a product requirements document for a mobile app that helps people split household chores fairly among roommates. Each iteration should add depth to a different section.',
|
|
'Create a comprehensive disaster recovery plan for a mid-size SaaS company. Cover data backup, infrastructure failover, communication protocols, compliance requirements, and testing schedules.'
|
|
],
|
|
redteam: [
|
|
'Here is our password policy: minimum 8 characters, must include a number. Find the weaknesses.',
|
|
'Our startup plans to store user health data in a Firebase Realtime Database with client-side security rules. The mobile app sends JWT tokens directly from the client. Identify every attack vector.',
|
|
'We are building an AI hiring tool that screens resumes, scores candidates 1-100, and auto-rejects below 60. It was trained on our last 5 years of successful hires. The system also parses social media for culture fit. Red team this for bias, legal risk, and adversarial attacks.'
|
|
],
|
|
consensus: [
|
|
'What is the single most important skill for a new software developer to learn first?',
|
|
'A company has $500K to invest in employee development. Should they spend it on individual training budgets, a company-wide mentorship program, sending teams to conferences, or building an internal learning platform?',
|
|
'How should a democratic society balance free speech with protection from misinformation, considering platform responsibility, individual rights, government regulation, and algorithmic amplification?'
|
|
],
|
|
codereview: [
|
|
'Write a Python function that finds all anagrams in a list of words.',
|
|
'Build a rate limiter middleware for Express.js that supports per-user limits, sliding windows, and graceful degradation when Redis is unavailable.',
|
|
'Implement a concurrent-safe LRU cache in Go with TTL expiration, size-based eviction, hit/miss metrics, and a write-behind buffer that batches persistence to disk.'
|
|
],
|
|
ladder: [
|
|
'How does encryption work?',
|
|
'Why do economies go through boom and bust cycles? Cover from basic intuition through monetary policy, credit cycles, behavioral economics, and systemic risk modeling.',
|
|
'How does CRISPR gene editing work, what are the ethical implications of germline editing, and what regulatory frameworks exist across different countries?'
|
|
],
|
|
tournament: [
|
|
'Write the most compelling opening line for a sci-fi novel.',
|
|
'Propose the best strategy for a small e-commerce business to compete with Amazon on a specific product category. Each model picks a different strategy.',
|
|
'Design an algorithm to fairly allocate limited vaccine doses across a city of 2 million during a pandemic. Optimize for minimizing deaths while considering equity, essential workers, and logistics.'
|
|
],
|
|
evolution: [
|
|
'Generate a company name for a sustainable packaging startup.',
|
|
'Evolve the perfect elevator pitch for a startup that uses satellite imagery and AI to predict crop failures before they happen. Mutate for clarity, impact, and memorability.',
|
|
'Evolve an optimal urban intersection design that minimizes pedestrian fatalities, maximizes throughput, accommodates cyclists and wheelchairs, handles emergency vehicles, and works in all seasons.'
|
|
],
|
|
blindassembly: [
|
|
'Explain how the internet works, with each model covering a different layer of the stack.',
|
|
'Write a business plan for a coworking space — split into market analysis, financial model, operations plan, and marketing strategy. No model sees the others.',
|
|
'Design a smart city emergency response system. Split into: sensor network, dispatch AI, citizen communication, hospital coordination, and post-incident analysis. Each model works blind.'
|
|
],
|
|
staircase: [
|
|
'Plan a birthday party. Then: budget is only $50. Then: one guest has severe allergies. Then: it starts raining.',
|
|
'Design a social media app. Add: must work offline-first. Add: no centralized server. Add: must be accessible to visually impaired users. Add: must comply with GDPR, COPPA, and CCPA.',
|
|
'Write a peace treaty between two fictional nations. Add: one side has all the water. Add: the other has all the farmland. Add: a third nation controls the only trade route. Add: election in 30 days. Add: climate disaster in 90 days.'
|
|
],
|
|
drift: [
|
|
'What year was the first email sent?',
|
|
'Explain the trolley problem and give your definitive answer on the correct moral choice. Map whether the model is consistent or waffles between positions.',
|
|
'Estimate the total number of piano tuners in Chicago, then describe the exact sequence of events causing the 2003 Northeast blackout. Map which claims are rock-solid vs. which shift each run.'
|
|
],
|
|
mesh: [
|
|
'Should our company adopt a 4-day work week?',
|
|
'A tech company wants to deploy facial recognition in their office. Get perspectives from the CISO, employees, legal team, disability advocates, and night-shift cleaning staff.',
|
|
'A pharma company discovers their blockbuster drug has a rare side effect (1 in 50,000) but helps 2 million people. Get views from the CEO, chief medical officer, patient advocates, the FDA, a plaintiff attorney, shareholders, and an investigative journalist.'
|
|
],
|
|
hallucination: [
|
|
'Tell me about the founding of Stanford University.',
|
|
'Explain the Tuskegee Syphilis Study — when it started, who ran it, what happened, when and why it ended, and what policy changes resulted. Include specific dates and names.',
|
|
'Describe the Therac-25 radiation therapy incidents. Include specific hospitals, dates, doses, the exact software bugs, and resulting regulatory changes. Flag every claim that could be confabulated.'
|
|
],
|
|
timeloop: [
|
|
'How should a restaurant handle a sudden rush of 200 customers?',
|
|
'Design a public transit system for a growing city of 500,000. Watch each solution create new problems — traffic displacement, gentrification, budget overruns — and evolve under chaos.',
|
|
'You are AI advisor to a country that detected an incoming solar storm knocking out 60% of the power grid in 72 hours. Survive cascading failures: infrastructure collapse, public panic, hospital backup exhaustion, communication blackouts, and economic aftershocks.'
|
|
],
|
|
research: [
|
|
'What is the current state of solid-state battery technology?',
|
|
'Investigate AI-powered drug discovery: key players, approaches, drugs in clinical trials, and limitations of the field.',
|
|
'Produce a research brief on the global rare earth mineral supply chain: who controls extraction and processing, geopolitical vulnerabilities, alternatives, and disruption impact on semiconductors, EVs, and defense.'
|
|
],
|
|
eval: [
|
|
'What is the capital of Australia, and why do people often get it wrong?',
|
|
'A trolley heads toward 5 people — you can divert it to hit 1 child. Evaluate each model on moral reasoning depth, consistency, and ability to handle complexity.',
|
|
'Write a Python function solving N-Queens, explain the approach, analyze time complexity, and suggest an optimization. Evaluate correctness, code quality, explanation clarity, and optimization validity.'
|
|
],
|
|
extract: [
|
|
'The James Webb Space Telescope launched December 25, 2021. It orbits the Sun-Earth L2 point, 1.5 million km from Earth. Its 6.5m primary mirror has 18 gold-plated beryllium segments.',
|
|
'Extract all entities, relationships, and claims from the Apollo 11 Wikipedia article. Structure as people, organizations, dates, technical specs, and disputed claims.',
|
|
'Process the Paris Climate Agreement. Extract signatory obligations by category, numeric targets, compliance mechanisms, financial commitments, and identify legally binding vs. aspirational obligations.'
|
|
]
|
|
};
|
|
|
|
function renderSamplePrompts() {
|
|
const container = document.getElementById('sample-prompts');
|
|
const prompts = SAMPLE_PROMPTS[currentMode] || [];
|
|
const levels = ['basic', 'mid', 'advanced'];
|
|
container.textContent = '';
|
|
prompts.forEach(function(p, i) {
|
|
const chip = document.createElement('div');
|
|
chip.className = 'sample-chip';
|
|
chip.title = p;
|
|
chip.dataset.prompt = p;
|
|
const lbl = document.createElement('span');
|
|
lbl.className = 'chip-level';
|
|
lbl.textContent = levels[i];
|
|
chip.appendChild(lbl);
|
|
chip.appendChild(document.createTextNode(p.length > 70 ? p.slice(0, 67) + '...' : p));
|
|
chip.addEventListener('click', function() {
|
|
document.getElementById('prompt').value = this.dataset.prompt;
|
|
this.style.borderColor = 'var(--green)';
|
|
setTimeout(function() { chip.style.borderColor = ''; }, 800);
|
|
});
|
|
container.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
async function loadModels() {
|
|
const resp = await fetch('/api/models');
|
|
const data = await resp.json();
|
|
availableModels = data.models;
|
|
const local = availableModels.filter(m => m.provider === 'ollama').length;
|
|
const cloud = availableModels.length - local;
|
|
const label = cloud ? local + ' local + ' + cloud + ' cloud' : availableModels.length + ' models';
|
|
document.getElementById('model-count').innerHTML = '<span class="dot"></span>' + label;
|
|
ML_IDS.forEach(id => { modelSets[id] = new Set(availableModels.map(m => m.name)); });
|
|
renderAllModelLists();
|
|
populateAllSelects();
|
|
initPipeline();
|
|
}
|
|
|
|
function renderModelList(listId) {
|
|
const list = document.getElementById(listId);
|
|
if (!list) return;
|
|
const set = modelSets[listId];
|
|
list.innerHTML = availableModels.map((m, i) => {
|
|
const sel = set.has(m.name) ? 'selected' : '';
|
|
const dn = m.display_name || m.name;
|
|
const badge = m.provider && m.provider !== 'ollama' ? ` <span class="prov-badge ${m.provider}">${m.provider_label}</span>` : '';
|
|
return `<div class="model-card ${sel}" onclick="toggleModelIn('${listId}','${m.name}')">
|
|
<div class="check">${sel ? '✓' : ''}</div>
|
|
<div class="info"><div class="name">${dn}${badge}</div><div class="meta">${m.size}</div></div>
|
|
<div style="width:10px;height:10px;border-radius:50%;background:${COLORS[i%COLORS.length]}"></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function toggleModelIn(listId, name) {
|
|
const set = modelSets[listId];
|
|
if (set.has(name)) set.delete(name); else set.add(name);
|
|
renderModelList(listId);
|
|
}
|
|
|
|
function renderAllModelLists() { ML_IDS.forEach(renderModelList); }
|
|
|
|
function populateAllSelects() {
|
|
const ids = ['synthesizer','debater1','debater2','debate-judge','validator-answerer',
|
|
'redteam-author','redteam-attacker','redteam-patcher','codereview-coder','codereview-reviewer',
|
|
'codereview-tester','tournament-judge','evolution-judge','blind-assembler','staircase-answerer',
|
|
'staircase-challenger','drift-target','drift-analyzer','mesh-synthesizer','halluc-answerer',
|
|
'timeloop-answerer','timeloop-chaos',
|
|
'research-scout','research-checker','research-synth',
|
|
'eval-judge','extract-model','extract-verifier'];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.innerHTML = availableModels.map(m => `<option value="${m.name}">${m.display_name || m.name}${m.provider && m.provider!=='ollama'?' ('+m.provider_label+')':''}</option>`).join('');
|
|
});
|
|
const n = (i) => availableModels[i % availableModels.length]?.name;
|
|
if (availableModels.length >= 2) {
|
|
['debater2','redteam-attacker','codereview-reviewer','staircase-challenger','drift-analyzer','timeloop-chaos'].forEach(id => {
|
|
const el = document.getElementById(id); if (el) el.value = n(1);
|
|
});
|
|
}
|
|
if (availableModels.length >= 3) {
|
|
['debate-judge','redteam-patcher','codereview-tester'].forEach(id => {
|
|
const el = document.getElementById(id); if (el) el.value = n(2);
|
|
});
|
|
}
|
|
}
|
|
|
|
function setMode(mode) {
|
|
currentMode = mode;
|
|
document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === mode));
|
|
document.querySelectorAll('.config-section').forEach(s => s.style.display = 'none');
|
|
const cfg = document.getElementById('config-' + mode);
|
|
if (cfg) cfg.style.display = '';
|
|
document.getElementById('mode-desc').textContent = MODE_DESCS[mode] || '';
|
|
const ml = document.getElementById('mode-label');
|
|
if (ml) ml.textContent = mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
renderSamplePrompts();
|
|
}
|
|
|
|
let pipelineSteps = [];
|
|
function initPipeline() {
|
|
if (!availableModels.length) return;
|
|
const n = (i) => availableModels[i % availableModels.length].name;
|
|
pipelineSteps = [
|
|
{ model: n(0), instruction: 'Draft an initial answer to: {input}' },
|
|
{ model: n(1), instruction: 'Review and improve this draft:\n\n{input}' },
|
|
{ model: n(2), instruction: 'Polish into a final response:\n\n{input}' },
|
|
];
|
|
renderPipeline();
|
|
}
|
|
function renderPipeline() {
|
|
document.getElementById('pipeline-steps').innerHTML = pipelineSteps.map((step, i) => {
|
|
const opts = availableModels.map(m => `<option value="${m.name}" ${m.name===step.model?'selected':''}>${m.name}</option>`).join('');
|
|
return `<div class="pipeline-step"><div class="step-num">${i+1}</div><select onchange="pipelineSteps[${i}].model=this.value">${opts}</select><input type="text" value="${step.instruction}" onchange="pipelineSteps[${i}].instruction=this.value"><button class="remove-step" onclick="removePipelineStep(${i})">✕</button></div>`;
|
|
}).join('');
|
|
}
|
|
function addPipelineStep() { pipelineSteps.push({ model: availableModels[0]?.name, instruction: 'Process: {input}' }); renderPipeline(); }
|
|
function removePipelineStep(i) { pipelineSteps.splice(i, 1); renderPipeline(); }
|
|
|
|
function getModels(listId) { return [...(modelSets[listId] || [])]; }
|
|
function getVal(id) { const el = document.getElementById(id); return el ? el.value : ''; }
|
|
function getNum(id) { return parseInt(getVal(id)) || 2; }
|
|
|
|
function buildConfig() {
|
|
const prompt = document.getElementById('prompt').value.trim();
|
|
if (!prompt) return null;
|
|
let c = { mode: currentMode, prompt };
|
|
switch (currentMode) {
|
|
case 'brainstorm': c.models = getModels('ml-brainstorm'); c.synthesizer = getVal('synthesizer'); break;
|
|
case 'pipeline': c.steps = pipelineSteps; break;
|
|
case 'debate': c.debater1 = getVal('debater1'); c.debater2 = getVal('debater2'); c.judge = getVal('debate-judge'); c.rounds = getNum('debate-rounds'); break;
|
|
case 'validator': c.answerer = getVal('validator-answerer'); c.validators = getModels('ml-validator').filter(m => m !== c.answerer); break;
|
|
case 'roundrobin': c.models = getModels('ml-roundrobin'); c.cycles = getNum('roundrobin-cycles'); break;
|
|
case 'redteam': c.author = getVal('redteam-author'); c.attacker = getVal('redteam-attacker'); c.patcher = getVal('redteam-patcher'); c.rounds = getNum('redteam-rounds'); break;
|
|
case 'consensus': c.models = getModels('ml-consensus'); c.max_rounds = getNum('consensus-rounds'); break;
|
|
case 'codereview': c.coder = getVal('codereview-coder'); c.reviewer = getVal('codereview-reviewer'); c.tester = getVal('codereview-tester'); break;
|
|
case 'ladder': c.models = getModels('ml-ladder'); break;
|
|
case 'tournament': c.models = getModels('ml-tournament'); c.judge = getVal('tournament-judge'); break;
|
|
case 'evolution': c.models = getModels('ml-evolution'); c.generations = getNum('evolution-gens'); c.judge = getVal('evolution-judge'); break;
|
|
case 'blindassembly': c.models = getModels('ml-blindassembly'); c.assembler = getVal('blind-assembler'); break;
|
|
case 'staircase': c.answerer = getVal('staircase-answerer'); c.challenger = getVal('staircase-challenger'); c.steps = getNum('staircase-steps'); break;
|
|
case 'drift': c.target = getVal('drift-target'); c.samples = getNum('drift-samples'); c.analyzer = getVal('drift-analyzer'); break;
|
|
case 'mesh': c.models = getModels('ml-mesh'); c.synthesizer = getVal('mesh-synthesizer'); break;
|
|
case 'hallucination': c.answerer = getVal('halluc-answerer'); c.hunters = getModels('ml-hallucination').filter(m => m !== c.answerer); break;
|
|
case 'timeloop': c.answerer = getVal('timeloop-answerer'); c.chaos = getVal('timeloop-chaos'); c.loops = getNum('timeloop-loops'); break;
|
|
case 'research': c.scout = getVal('research-scout'); c.models = getModels('ml-research'); c.checker = getVal('research-checker'); c.synthesizer = getVal('research-synth'); c.num_questions = getNum('research-questions'); break;
|
|
case 'eval': c.models = getModels('ml-eval'); c.judge = getVal('eval-judge'); c.eval_type = getVal('eval-type'); c.rounds = getNum('eval-rounds'); break;
|
|
case 'extract': c.extractor = getVal('extract-model'); c.verifier = getVal('extract-verifier'); c.source = getVal('extract-source'); break;
|
|
}
|
|
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');
|
|
_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 = '';
|
|
while (true) {
|
|
const {value, done} = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, {stream: true});
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) { try { _runEventCount++; handleEvent(JSON.parse(line.slice(6))); } catch(e) {} }
|
|
}
|
|
}
|
|
} catch(e) {
|
|
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') {
|
|
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 {
|
|
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];
|
|
const displayName = mi >= 0 ? (availableModels[mi].display_name || evt.model) : evt.model;
|
|
const hl = ['synthesis','judge','verdict','final','consensus','patcher','assembler','analyzer','survivor','mesh-360'].includes(evt.role);
|
|
const isCrazy = evt.role && (evt.role.includes('catastrophe') || evt.role.includes('chaos') || evt.role === 'survivor');
|
|
const card = document.createElement('div');
|
|
card.className = 'output-card' + (hl ? ' synthesis-card' : '') + (isCrazy ? ' crazy-card' : '');
|
|
const roleTag = evt.role ? `<span class="role-tag">${evt.role}</span>` : '';
|
|
const uid = 'resp-' + Date.now() + '-' + Math.random().toString(36).substr(2,4);
|
|
card.innerHTML = `<div class="card-header" style="cursor:pointer" onclick="openRepipe('${uid}')"><div class="dot" style="background:${color}"></div>${displayName}${roleTag}</div><div class="card-body" id="${uid}">${escapeHtml(evt.text)}</div><div class="card-actions"><button class="card-act" onclick="event.stopPropagation();copyCard('${uid}',this)">Copy</button><button class="card-act" onclick="event.stopPropagation();useAsPrompt('${uid}')">Use as Prompt</button><button class="card-act" onclick="event.stopPropagation();openRepipe('${uid}')">Iterate</button></div>`;
|
|
card.dataset.model = evt.model;
|
|
card.dataset.role = evt.role || '';
|
|
card.dataset.displayName = displayName;
|
|
output.appendChild(card);
|
|
}
|
|
}
|
|
|
|
function escapeHtml(t) { return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
|
// ─── CARD ACTIONS ────────────────────────────────────
|
|
function copyCard(uid, btn) {
|
|
const el = document.getElementById(uid);
|
|
if (!el) return;
|
|
navigator.clipboard.writeText(el.textContent).then(() => {
|
|
btn.textContent = 'Copied!';
|
|
btn.classList.add('copied');
|
|
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
|
|
});
|
|
}
|
|
|
|
function useAsPrompt(uid) {
|
|
const el = document.getElementById(uid);
|
|
if (!el) return;
|
|
document.getElementById('prompt').value = el.textContent;
|
|
document.getElementById('prompt').focus();
|
|
document.getElementById('prompt').scrollIntoView({behavior:'smooth'});
|
|
}
|
|
|
|
let repipeText = '';
|
|
let repipeModel = '';
|
|
let repipeSelectedMode = '';
|
|
|
|
function openRepipe(uid) {
|
|
const el = document.getElementById(uid);
|
|
if (!el) return;
|
|
const card = el.closest('.output-card') || el.closest('.hp-resp');
|
|
repipeText = el.textContent;
|
|
repipeModel = card?.dataset?.model || card?.dataset?.displayName || '';
|
|
const dn = card?.dataset?.displayname || card?.dataset?.displayName || repipeModel;
|
|
repipeSelectedMode = '';
|
|
|
|
const modal = document.getElementById('repipe-overlay');
|
|
document.getElementById('repipe-title').textContent = dn + (card?.dataset?.role ? ' (' + card.dataset.role + ')' : '');
|
|
document.getElementById('repipe-text').textContent = repipeText;
|
|
renderRepipeModes();
|
|
modal.classList.add('open');
|
|
}
|
|
|
|
function closeRepipe() {
|
|
document.getElementById('repipe-overlay').classList.remove('open');
|
|
}
|
|
|
|
function renderRepipeModes() {
|
|
const modes = ['brainstorm','pipeline','debate','validator','roundrobin','redteam','consensus','codereview',
|
|
'ladder','tournament','evolution','blindassembly','staircase','drift','mesh','hallucination','timeloop',
|
|
'research','eval','extract'];
|
|
document.getElementById('repipe-modes').innerHTML = modes.map(m =>
|
|
`<div class="repipe-mode ${m===repipeSelectedMode?'sel':''}" onclick="repipeSelectedMode='${m}';renderRepipeModes()">${m}</div>`
|
|
).join('');
|
|
}
|
|
|
|
function repipeCopy() {
|
|
navigator.clipboard.writeText(repipeText);
|
|
const btn = event.target;
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => btn.textContent = 'Copy to Clipboard', 1500);
|
|
}
|
|
|
|
function repipeUseAsPrompt() {
|
|
document.getElementById('prompt').value = repipeText;
|
|
closeRepipe();
|
|
document.getElementById('prompt').focus();
|
|
}
|
|
|
|
function repipeAppendToPrompt() {
|
|
const p = document.getElementById('prompt');
|
|
p.value = p.value ? p.value + '\n\n---\n\n' + repipeText : repipeText;
|
|
closeRepipe();
|
|
p.focus();
|
|
}
|
|
|
|
function repipeRunInMode() {
|
|
if (!repipeSelectedMode) return;
|
|
document.getElementById('prompt').value = repipeText;
|
|
setMode(repipeSelectedMode);
|
|
closeRepipe();
|
|
document.getElementById('prompt').scrollIntoView({behavior:'smooth'});
|
|
}
|
|
|
|
function repipeRunNow() {
|
|
if (!repipeSelectedMode) return;
|
|
document.getElementById('prompt').value = repipeText;
|
|
setMode(repipeSelectedMode);
|
|
closeRepipe();
|
|
setTimeout(() => runTeam(), 100);
|
|
}
|
|
|
|
// ─── HISTORY ─────────────────────────────────────────
|
|
let historyRuns = [];
|
|
|
|
function toggleHistory() {
|
|
const panel = document.getElementById('history-panel');
|
|
const overlay = document.getElementById('history-overlay');
|
|
const isOpen = panel.classList.contains('open');
|
|
if (isOpen) {
|
|
panel.classList.remove('open');
|
|
overlay.classList.remove('open');
|
|
} else {
|
|
loadHistory();
|
|
panel.classList.add('open');
|
|
overlay.classList.add('open');
|
|
}
|
|
}
|
|
|
|
async function loadHistory() {
|
|
const r = await fetch('/api/runs');
|
|
const data = await r.json();
|
|
historyRuns = data.runs || [];
|
|
renderHistoryList();
|
|
}
|
|
|
|
function renderHistoryList() {
|
|
const el = document.getElementById('hp-content');
|
|
if (!historyRuns.length) {
|
|
el.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text2)">No runs saved yet. Run a team to see history here.</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = '<div class="hp-list">' + historyRuns.map(r => {
|
|
const d = new Date(r.created_at);
|
|
const time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
|
const models = (r.models_used || []).length;
|
|
const prompt = (r.prompt || '').substring(0, 80);
|
|
return `<div class="hp-item" onclick="viewRun(${r.id})">
|
|
<div class="hp-mode">${r.mode}</div>
|
|
<div class="hp-prompt">${escapeHtml(prompt)}</div>
|
|
<div class="hp-meta"><span>${time}</span><span>${models} model${models!==1?'s':''}</span></div>
|
|
</div>`;
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
async function viewRun(id) {
|
|
const r = await fetch('/api/runs/' + id);
|
|
const run = await r.json();
|
|
if (run.error) return;
|
|
const el = document.getElementById('hp-content');
|
|
const responses = run.responses || [];
|
|
let html = '<div class="hp-detail">';
|
|
html += `<button class="hp-back" onclick="renderHistoryList()">← Back to list</button>`;
|
|
html += `<div class="hp-mode" style="font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--accent2);font-weight:600;margin-bottom:4px">${run.mode}</div>`;
|
|
html += `<div style="font-size:13px;margin-bottom:8px">${escapeHtml(run.prompt)}</div>`;
|
|
html += `<div class="hp-actions">`;
|
|
html += `<button class="hp-btn" onclick="rerunFromHistory(${id})">Re-run</button>`;
|
|
html += `<button class="hp-btn hp-btn-del" onclick="deleteRun(${id})">Delete</button>`;
|
|
html += `</div>`;
|
|
responses.forEach((resp, ri) => {
|
|
const mi = availableModels.findIndex(m => m.name === resp.model);
|
|
const color = COLORS[(mi >= 0 ? mi : 0) % COLORS.length];
|
|
const dn = mi >= 0 ? (availableModels[mi].display_name || resp.model) : resp.model;
|
|
const hid = 'hist-resp-' + id + '-' + ri;
|
|
html += `<div class="hp-resp" data-model="${resp.model}" data-role="${resp.role||''}" data-display-name="${dn}">
|
|
<div class="hp-resp-header" style="cursor:pointer" onclick="openRepipe('${hid}')"><div style="width:6px;height:6px;border-radius:50%;background:${color}"></div>${dn}${resp.role ? ' <span style="color:var(--text2);font-weight:400">'+resp.role+'</span>' : ''}</div>
|
|
<div class="hp-resp-body" id="${hid}">${escapeHtml(resp.text)}</div>
|
|
<div class="card-actions"><button class="card-act" onclick="copyCard('${hid}',this)">Copy</button><button class="card-act" onclick="useAsPrompt('${hid}');toggleHistory()">Use as Prompt</button><button class="card-act" onclick="openRepipe('${hid}')">Iterate</button></div>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
async function rerunFromHistory(id) {
|
|
const r = await fetch('/api/runs/' + id);
|
|
const run = await r.json();
|
|
if (!run.config) return;
|
|
document.getElementById('prompt').value = run.prompt || '';
|
|
if (run.mode) setMode(run.mode);
|
|
toggleHistory();
|
|
}
|
|
|
|
async function deleteRun(id) {
|
|
await fetch('/api/runs/' + id, {method: 'DELETE'});
|
|
await loadHistory();
|
|
renderHistoryList();
|
|
}
|
|
|
|
// ─── DEMO MODE ───────────────────────────────
|
|
async function checkDemo() {
|
|
try {
|
|
const r = await fetch('/api/demo/status');
|
|
const d = await r.json();
|
|
updateDemoUI(d.active);
|
|
} catch(e) {}
|
|
}
|
|
|
|
function updateDemoUI(active) {
|
|
const btn = document.getElementById('demo-toggle');
|
|
const banner = document.getElementById('demo-banner');
|
|
if (btn) {
|
|
btn.style.display = '';
|
|
btn.textContent = active ? 'Demo ON' : 'Demo';
|
|
btn.style.color = active ? '#22c55e' : 'var(--orange)';
|
|
btn.style.borderColor = active ? 'rgba(34,197,94,0.4)' : 'rgba(245,158,11,0.3)';
|
|
}
|
|
if (active) {
|
|
if (!banner) {
|
|
const b = document.createElement('div');
|
|
b.id = 'demo-banner';
|
|
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,rgba(34,197,94,0.08),rgba(34,197,94,0.15),rgba(34,197,94,0.08));border-bottom:1px solid rgba(34,197,94,0.25);color:#22c55e;text-align:center;font-size:12px;padding:6px;z-index:50;font-weight:600;letter-spacing:1px';
|
|
b.textContent = 'DEMO MODE';
|
|
document.body.prepend(b);
|
|
}
|
|
} else if (banner) {
|
|
banner.remove();
|
|
}
|
|
}
|
|
|
|
async function toggleDemo() {
|
|
const r = await fetch('/api/demo/toggle', {method:'POST'});
|
|
const d = await r.json();
|
|
if (d.error) return;
|
|
updateDemoUI(d.active);
|
|
}
|
|
|
|
loadModels();
|
|
renderSamplePrompts();
|
|
checkDemo();
|
|
|
|
// Background grid animation
|
|
!function(){const c=document.getElementById('bg-grid');if(!c)return;const x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);let t=0;function draw(){x.clearRect(0,0,c.width,c.height);const s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(let gx=-s+ox;gx<c.width+s;gx+=s){for(let gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}}if(Math.random()>0.985){const ly=Math.random()*c.height;x.strokeStyle='rgba(226,181,90,0.012)';x.lineWidth=1;x.beginPath();x.moveTo(0,ly);x.lineTo(c.width,ly);x.stroke()}if(Math.random()>0.995){const lx=Math.random()*c.width;x.strokeStyle='rgba(226,181,90,0.008)';x.lineWidth=40;x.beginPath();x.moveTo(lx,0);x.lineTo(lx,c.height);x.stroke()}t++;requestAnimationFrame(draw)}draw()}();
|
|
</script>
|
|
<div id="repipe-overlay" class="repipe-overlay" onclick="if(event.target===this)closeRepipe()">
|
|
<div class="repipe-modal">
|
|
<div class="repipe-header">
|
|
<h3 id="repipe-title">Response</h3>
|
|
<button class="repipe-close" onclick="closeRepipe()">×</button>
|
|
</div>
|
|
<div class="repipe-body">
|
|
<div class="repipe-text" id="repipe-text"></div>
|
|
<div class="repipe-actions">
|
|
<button class="repipe-btn" onclick="repipeCopy()">Copy to Clipboard</button>
|
|
<button class="repipe-btn" onclick="repipeUseAsPrompt()">Replace Prompt</button>
|
|
<button class="repipe-btn" onclick="repipeAppendToPrompt()">Append to Prompt</button>
|
|
</div>
|
|
<div class="repipe-section">Re-pipe into mode</div>
|
|
<div class="repipe-modes" id="repipe-modes"></div>
|
|
<div class="repipe-actions" style="margin-top:10px">
|
|
<button class="repipe-btn primary" onclick="repipeRunNow()">Run Now</button>
|
|
<button class="repipe-btn" onclick="repipeRunInMode()">Load & Configure</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="history-overlay" class="history-overlay" onclick="toggleHistory()"></div>
|
|
<div id="history-panel" class="history-panel">
|
|
<div class="hp-header"><h2>History</h2><button class="hp-close" onclick="toggleHistory()">×</button></div>
|
|
<div id="hp-content"></div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
ADMIN_HTML = r"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LLM Team - Admin</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0a0c10; --surface: #151820; --surface2: #1c2030; --border: #272d3f;
|
|
--text: #e4e4e7; --text2: #a1a1aa; --accent: #6366f1; --accent2: #818cf8;
|
|
--green: #22c55e; --orange: #f59e0b; --red: #ef4444; --blue: #3b82f6;
|
|
--glow: rgba(99,102,241,0.12);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
.container { max-width: 1100px; margin: 0 auto; padding: 16px 24px; }
|
|
header { display: flex; align-items: center; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
|
header h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
|
|
header h1 span { background: linear-gradient(135deg, var(--accent2), #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
header a { color: var(--accent2); text-decoration: none; font-size: 13px; margin-left: auto; }
|
|
header a:hover { text-decoration: underline; }
|
|
.tabs { display: flex; gap: 4px; margin-bottom: 20px; }
|
|
.tab { padding: 8px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; }
|
|
.tab:hover { border-color: var(--accent); color: var(--text); }
|
|
.tab.active { border-color: var(--accent); background: var(--glow); color: var(--accent2); }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 12px; }
|
|
.card h3 { font-size: 15px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
|
.card h3 .prov-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
.row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; font-size: 13px; }
|
|
.row label { width: 100px; color: var(--text2); flex-shrink: 0; font-weight: 500; }
|
|
.row input, .row select { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 7px 10px; font-size: 13px; }
|
|
.row input:focus, .row select:focus { outline: none; border-color: var(--accent); }
|
|
.toggle { position: relative; width: 40px; height: 22px; flex-shrink: 0; }
|
|
.toggle input { opacity: 0; width: 0; height: 0; }
|
|
.toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 22px; cursor: pointer; transition: 0.2s; }
|
|
.toggle .slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--text2); border-radius: 50%; transition: 0.2s; }
|
|
.toggle input:checked + .slider { background: var(--accent); }
|
|
.toggle input:checked + .slider::before { transform: translateX(18px); background: white; }
|
|
.btn { padding: 7px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; }
|
|
.btn:hover { border-color: var(--accent); color: var(--accent2); }
|
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
|
|
.btn-primary:hover { filter: brightness(1.15); }
|
|
.btn-sm { padding: 4px 10px; font-size: 11px; }
|
|
.btn-green { background: rgba(34,197,94,0.15); border-color: var(--green); color: var(--green); }
|
|
.btn-red { background: rgba(239,68,68,0.1); border-color: var(--red); color: var(--red); }
|
|
.toast { position: fixed; top: 20px; right: 20px; padding: 10px 16px; border-radius: 8px; font-size: 13px; z-index: 100; animation: fadeIn 0.2s; }
|
|
.toast.ok { background: rgba(34,197,94,0.15); border: 1px solid var(--green); color: var(--green); }
|
|
.toast.err { background: rgba(239,68,68,0.1); border: 1px solid var(--red); color: var(--red); }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; } }
|
|
.model-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 4px; font-size: 13px; }
|
|
.model-row .name { flex: 1; font-weight: 500; }
|
|
.model-row .meta { color: var(--text2); font-size: 11px; }
|
|
.search-input { width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; margin-bottom: 12px; }
|
|
.search-input:focus { outline: none; border-color: var(--accent); }
|
|
.or-list { max-height: 500px; overflow-y: auto; }
|
|
.or-list::-webkit-scrollbar { width: 4px; }
|
|
.or-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
.timeout-row { display: grid; grid-template-columns: 1fr 100px; gap: 10px; align-items: center; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
|
|
.timeout-row:last-child { border: none; }
|
|
.timeout-row input { width: 80px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 4px 8px; font-size: 12px; text-align: center; }
|
|
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--text2); margin: 16px 0 10px; font-weight: 600; }
|
|
.empty { text-align: center; padding: 30px; color: var(--text2); font-size: 13px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1><span>LLM</span> Team Admin</h1>
|
|
<nav style="margin-left:auto;display:flex;gap:8px;align-items:center"><a href="/">Team UI</a><a href="/lab" style="color:var(--green)">Lab</a><a href="/logs" style="color:var(--orange)">Logs</a><span style="opacity:0.3">|</span><a href="/logout" style="opacity:0.5;font-size:11px">Logout</a></nav>
|
|
</header>
|
|
<div class="tabs">
|
|
<div class="tab active" onclick="switchTab('providers')">Providers</div>
|
|
<div class="tab" onclick="switchTab('models')">Models</div>
|
|
<div class="tab" onclick="switchTab('openrouter')">OpenRouter</div>
|
|
<div class="tab" onclick="switchTab('timeouts')">Timeouts</div>
|
|
<div class="tab" onclick="switchTab('security')">Security</div>
|
|
</div>
|
|
|
|
<!-- PROVIDERS TAB -->
|
|
<div id="tab-providers" class="tab-content active">
|
|
<div class="card" id="prov-ollama">
|
|
<h3><div class="prov-dot" style="background:var(--green)"></div> Ollama (Local)
|
|
<label class="toggle" style="margin-left:auto"><input type="checkbox" id="ollama-enabled" checked onchange="updateProvider('ollama')"><span class="slider"></span></label></h3>
|
|
<div class="row"><label>Base URL</label><input id="ollama-url" value="http://localhost:11434" onchange="updateProvider('ollama')"></div>
|
|
<div class="row"><label>Timeout (s)</label><input id="ollama-timeout" type="number" value="300" style="width:80px;flex:none" onchange="updateProvider('ollama')">
|
|
<button class="btn" onclick="testProvider('ollama')">Test</button></div>
|
|
</div>
|
|
<div class="card" id="prov-openrouter">
|
|
<h3><div class="prov-dot" style="background:var(--blue)"></div> OpenRouter
|
|
<label class="toggle" style="margin-left:auto"><input type="checkbox" id="openrouter-enabled" onchange="updateProvider('openrouter')"><span class="slider"></span></label></h3>
|
|
<div class="row"><label>API Key</label><input id="openrouter-key" type="password" placeholder="sk-or-..." onchange="updateProvider('openrouter')">
|
|
<button class="btn btn-sm" onclick="toggleVis('openrouter-key')">Show</button></div>
|
|
<div class="row"><label>Base URL</label><input id="openrouter-url" value="https://openrouter.ai/api/v1" onchange="updateProvider('openrouter')"></div>
|
|
<div class="row"><label>Timeout (s)</label><input id="openrouter-timeout" type="number" value="120" style="width:80px;flex:none" onchange="updateProvider('openrouter')">
|
|
<button class="btn" onclick="testProvider('openrouter')">Test</button></div>
|
|
</div>
|
|
<div class="card" id="prov-openai">
|
|
<h3><div class="prov-dot" style="background:var(--accent2)"></div> OpenAI
|
|
<label class="toggle" style="margin-left:auto"><input type="checkbox" id="openai-enabled" onchange="updateProvider('openai')"><span class="slider"></span></label></h3>
|
|
<div class="row"><label>API Key</label><input id="openai-key" type="password" placeholder="sk-..." onchange="updateProvider('openai')">
|
|
<button class="btn btn-sm" onclick="toggleVis('openai-key')">Show</button></div>
|
|
<div class="row"><label>Base URL</label><input id="openai-url" value="https://api.openai.com/v1" onchange="updateProvider('openai')"></div>
|
|
<div class="row"><label>Timeout (s)</label><input id="openai-timeout" type="number" value="120" style="width:80px;flex:none" onchange="updateProvider('openai')">
|
|
<button class="btn" onclick="testProvider('openai')">Test</button></div>
|
|
</div>
|
|
<div class="card" id="prov-anthropic">
|
|
<h3><div class="prov-dot" style="background:#ec4899"></div> Anthropic
|
|
<label class="toggle" style="margin-left:auto"><input type="checkbox" id="anthropic-enabled" onchange="updateProvider('anthropic')"><span class="slider"></span></label></h3>
|
|
<div class="row"><label>API Key</label><input id="anthropic-key" type="password" placeholder="sk-ant-..." onchange="updateProvider('anthropic')">
|
|
<button class="btn btn-sm" onclick="toggleVis('anthropic-key')">Show</button></div>
|
|
<div class="row"><label>Base URL</label><input id="anthropic-url" value="https://api.anthropic.com/v1" onchange="updateProvider('anthropic')"></div>
|
|
<div class="row"><label>Timeout (s)</label><input id="anthropic-timeout" type="number" value="120" style="width:80px;flex:none" onchange="updateProvider('anthropic')">
|
|
<button class="btn" onclick="testProvider('anthropic')">Test</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MODELS TAB -->
|
|
<div id="tab-models" class="tab-content">
|
|
<div class="card">
|
|
<h3>Local Models (Ollama)</h3>
|
|
<div id="ollama-model-list"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Cloud Models <button class="btn btn-sm btn-primary" style="margin-left:auto" onclick="showAddCloud()">+ Add Model</button></h3>
|
|
<div id="cloud-model-list"><div class="empty">No cloud models configured.</div></div>
|
|
</div>
|
|
<div id="add-cloud-modal" class="card" style="display:none;border-color:var(--accent)">
|
|
<h3>Add Cloud Model</h3>
|
|
<div class="row"><label>Provider</label><select id="add-cloud-prov"><option value="openrouter">OpenRouter</option><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select></div>
|
|
<div class="row"><label>Model ID</label><input id="add-cloud-id" placeholder="e.g. meta-llama/llama-3-8b-instruct:free"></div>
|
|
<div class="row"><label>Display Name</label><input id="add-cloud-name" placeholder="e.g. Llama 3 8B Free"></div>
|
|
<div class="row" style="justify-content:flex-end;gap:6px">
|
|
<button class="btn" onclick="hideAddCloud()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="addCloudModel()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- OPENROUTER TAB -->
|
|
<div id="tab-openrouter" class="tab-content">
|
|
<div class="card">
|
|
<h3>Free Models on OpenRouter <button class="btn btn-primary" style="margin-left:auto" onclick="fetchORModels()">Fetch Models</button></h3>
|
|
<input class="search-input" id="or-search" placeholder="Search models..." oninput="filterOR()">
|
|
<div class="or-list" id="or-model-list"><div class="empty">Click "Fetch Models" to load the list.</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TIMEOUTS TAB -->
|
|
<div id="tab-timeouts" class="tab-content">
|
|
<div class="card">
|
|
<h3>Global Default</h3>
|
|
<div class="row"><label>Timeout (s)</label><input id="global-timeout" type="number" value="300" style="width:100px;flex:none" onchange="saveTimeouts()"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Per-Model Overrides</h3>
|
|
<div id="timeout-list"><div class="empty">Loading models...</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DEMO & SECURITY TAB -->
|
|
<div id="tab-security" class="tab-content">
|
|
<div class="card">
|
|
<h3>Demo Mode
|
|
<button class="btn" id="admin-demo-btn" style="margin-left:auto" onclick="adminToggleDemo()">Enable Demo</button>
|
|
</h3>
|
|
<p style="font-size:12px;color:var(--text2);margin-bottom:10px">When active, the public can view and use the Team UI, Lab, and all modes without logging in. Admin settings (API keys, config saves) are read-only for non-admins.</p>
|
|
<div id="demo-status-admin" style="font-size:13px">Status: <strong style="color:var(--text2)">Off</strong></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>IP Allowlist <button class="btn" style="margin-left:auto" onclick="addAllowlistIP()">+ Add IP</button></h3>
|
|
<p style="font-size:12px;color:var(--text2);margin-bottom:10px">These IPs are never rate-limited. Your local network (192.168.1.*) is always allowed.</p>
|
|
<div id="allowlist"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let config = {};
|
|
let orModels = [];
|
|
|
|
async function loadConfig() {
|
|
const r = await fetch('/api/admin/config');
|
|
config = await r.json();
|
|
applyConfig();
|
|
}
|
|
|
|
function applyConfig() {
|
|
const p = config.providers || {};
|
|
for (const [name, prov] of Object.entries(p)) {
|
|
const en = document.getElementById(name+'-enabled');
|
|
if (en) en.checked = prov.enabled;
|
|
const url = document.getElementById(name+'-url');
|
|
if (url) url.value = prov.base_url || '';
|
|
const to = document.getElementById(name+'-timeout');
|
|
if (to) to.value = prov.timeout || 120;
|
|
const key = document.getElementById(name+'-key');
|
|
if (key && prov.api_key_set) key.placeholder = '••••••• (key set)';
|
|
}
|
|
document.getElementById('global-timeout').value = (config.timeouts||{}).global || 300;
|
|
loadOllamaModels();
|
|
renderCloudModels();
|
|
renderTimeouts();
|
|
}
|
|
|
|
async function loadOllamaModels() {
|
|
const r = await fetch('/api/admin/ollama-models');
|
|
const data = await r.json();
|
|
const el = document.getElementById('ollama-model-list');
|
|
if (!data.models.length) { el.innerHTML = '<div class="empty">No Ollama models found.</div>'; return; }
|
|
el.innerHTML = data.models.map(m => `
|
|
<div class="model-row">
|
|
<label class="toggle"><input type="checkbox" ${m.disabled?'':'checked'} onchange="toggleOllama('${m.name}',this.checked)"><span class="slider"></span></label>
|
|
<span class="name">${m.name}</span>
|
|
<span class="meta">${m.size}</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderCloudModels() {
|
|
const el = document.getElementById('cloud-model-list');
|
|
const cms = config.cloud_models || [];
|
|
if (!cms.length) { el.innerHTML = '<div class="empty">No cloud models configured. Add some from the OpenRouter tab or manually.</div>'; return; }
|
|
el.innerHTML = cms.map((m,i) => `
|
|
<div class="model-row">
|
|
<label class="toggle"><input type="checkbox" ${m.enabled!==false?'checked':''} onchange="toggleCloud(${i},this.checked)"><span class="slider"></span></label>
|
|
<span class="name">${m.display_name || m.id}</span>
|
|
<span class="meta">${m.id.split('::')[0]}</span>
|
|
<button class="btn btn-sm btn-red" onclick="removeCloud(${i})">Remove</button>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderTimeouts() {
|
|
const el = document.getElementById('timeout-list');
|
|
// merge all known models
|
|
const models = [];
|
|
const cms = config.cloud_models || [];
|
|
// we'll load from the combined /api/models
|
|
fetch('/api/models').then(r=>r.json()).then(data => {
|
|
const per = (config.timeouts||{}).per_model || {};
|
|
if (!data.models.length) { el.innerHTML = '<div class="empty">No models available.</div>'; return; }
|
|
el.innerHTML = data.models.map(m => `
|
|
<div class="timeout-row">
|
|
<span>${m.display_name || m.name} <span style="color:var(--text2);font-size:10px">(${m.provider_label})</span></span>
|
|
<input type="number" value="${per[m.name] || ''}" placeholder="${(config.timeouts||{}).global||300}" onchange="setModelTimeout('${m.name}',this.value)">
|
|
</div>`).join('');
|
|
});
|
|
}
|
|
|
|
async function updateProvider(name) {
|
|
const prov = {};
|
|
const en = document.getElementById(name+'-enabled');
|
|
if (en) prov.enabled = en.checked;
|
|
const url = document.getElementById(name+'-url');
|
|
if (url) prov.base_url = url.value;
|
|
const to = document.getElementById(name+'-timeout');
|
|
if (to) prov.timeout = parseInt(to.value) || 120;
|
|
const key = document.getElementById(name+'-key');
|
|
if (key && key.value) prov.api_key = key.value;
|
|
const body = {providers: {}};
|
|
body.providers[name] = prov;
|
|
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
toast('Saved');
|
|
}
|
|
|
|
async function testProvider(name) {
|
|
const key = document.getElementById(name+'-key');
|
|
const body = {provider: name};
|
|
if (key && key.value) body.api_key = key.value;
|
|
const r = await fetch('/api/admin/test-provider', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
const data = await r.json();
|
|
toast(data.message, data.ok);
|
|
}
|
|
|
|
async function toggleOllama(name, enabled) {
|
|
config.disabled_models = config.disabled_models || [];
|
|
if (enabled) {
|
|
config.disabled_models = config.disabled_models.filter(m => m !== name);
|
|
} else {
|
|
if (!config.disabled_models.includes(name)) config.disabled_models.push(name);
|
|
}
|
|
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({disabled_models: config.disabled_models})});
|
|
toast('Model ' + (enabled ? 'enabled' : 'disabled'));
|
|
}
|
|
|
|
function toggleCloud(idx, enabled) {
|
|
config.cloud_models[idx].enabled = enabled;
|
|
saveCloudModels();
|
|
}
|
|
|
|
function removeCloud(idx) {
|
|
config.cloud_models.splice(idx, 1);
|
|
saveCloudModels();
|
|
renderCloudModels();
|
|
}
|
|
|
|
async function saveCloudModels() {
|
|
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({cloud_models: config.cloud_models})});
|
|
toast('Saved');
|
|
}
|
|
|
|
function showAddCloud() { document.getElementById('add-cloud-modal').style.display = ''; }
|
|
function hideAddCloud() { document.getElementById('add-cloud-modal').style.display = 'none'; }
|
|
|
|
async function addCloudModel() {
|
|
const prov = document.getElementById('add-cloud-prov').value;
|
|
const id = document.getElementById('add-cloud-id').value.trim();
|
|
const name = document.getElementById('add-cloud-name').value.trim();
|
|
if (!id) return toast('Model ID required', false);
|
|
config.cloud_models = config.cloud_models || [];
|
|
config.cloud_models.push({id: prov+'::'+id, display_name: name || id, enabled: true});
|
|
await saveCloudModels();
|
|
renderCloudModels();
|
|
hideAddCloud();
|
|
document.getElementById('add-cloud-id').value = '';
|
|
document.getElementById('add-cloud-name').value = '';
|
|
}
|
|
|
|
async function fetchORModels() {
|
|
const el = document.getElementById('or-model-list');
|
|
el.innerHTML = '<div class="empty">Fetching...</div>';
|
|
const r = await fetch('/api/admin/openrouter/models');
|
|
const data = await r.json();
|
|
orModels = data.models || [];
|
|
if (data.error) { el.innerHTML = '<div class="empty" style="color:var(--red)">Error: '+data.error+'</div>'; return; }
|
|
renderORModels();
|
|
}
|
|
|
|
function renderORModels() {
|
|
const q = (document.getElementById('or-search').value || '').toLowerCase();
|
|
const filtered = q ? orModels.filter(m => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) : orModels;
|
|
const el = document.getElementById('or-model-list');
|
|
if (!filtered.length) { el.innerHTML = '<div class="empty">No models found.</div>'; return; }
|
|
const existing = new Set((config.cloud_models||[]).map(m=>m.id));
|
|
el.innerHTML = filtered.map(m => {
|
|
const added = existing.has('openrouter::'+m.id);
|
|
const ctx = m.context_length ? (m.context_length/1000).toFixed(0)+'K' : '?';
|
|
return `<div class="model-row">
|
|
<span class="name">${m.name}</span>
|
|
<span class="meta">${ctx} ctx</span>
|
|
${added
|
|
? '<button class="btn btn-sm" disabled style="opacity:0.4">Added</button>'
|
|
: `<button class="btn btn-sm btn-green" onclick="addOR('${m.id}','${m.name.replace(/'/g,"\\'")}')">Add</button>`}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function filterOR() { renderORModels(); }
|
|
|
|
async function addOR(id, name) {
|
|
config.cloud_models = config.cloud_models || [];
|
|
config.cloud_models.push({id: 'openrouter::'+id, display_name: name, enabled: true});
|
|
await saveCloudModels();
|
|
renderORModels();
|
|
toast('Added: ' + name);
|
|
}
|
|
|
|
async function saveTimeouts() {
|
|
const g = parseInt(document.getElementById('global-timeout').value) || 300;
|
|
config.timeouts = config.timeouts || {};
|
|
config.timeouts.global = g;
|
|
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({timeouts: config.timeouts})});
|
|
toast('Saved');
|
|
}
|
|
|
|
function setModelTimeout(name, val) {
|
|
config.timeouts = config.timeouts || {};
|
|
config.timeouts.per_model = config.timeouts.per_model || {};
|
|
if (val && parseInt(val)) {
|
|
config.timeouts.per_model[name] = parseInt(val);
|
|
} else {
|
|
delete config.timeouts.per_model[name];
|
|
}
|
|
saveTimeouts();
|
|
}
|
|
|
|
function toggleVis(id) {
|
|
const el = document.getElementById(id);
|
|
el.type = el.type === 'password' ? 'text' : 'password';
|
|
}
|
|
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', t.textContent.toLowerCase().includes(name.substring(0,4))));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-'+name));
|
|
if (name === 'timeouts') renderTimeouts();
|
|
if (name === 'models') { loadOllamaModels(); renderCloudModels(); }
|
|
if (name === 'security') { loadDemoStatus(); loadAllowlist(); }
|
|
}
|
|
|
|
async function loadDemoStatus() {
|
|
const r = await fetch('/api/demo/status');
|
|
const d = await r.json();
|
|
const btn = document.getElementById('admin-demo-btn');
|
|
const st = document.getElementById('demo-status-admin');
|
|
if (d.active) {
|
|
btn.textContent = 'Disable Demo';
|
|
btn.className = 'btn btn-r';
|
|
st.innerHTML = 'Status: <strong style="color:var(--green)">ON</strong>' + (d.started_by ? ' (by ' + d.started_by + ')' : '');
|
|
} else {
|
|
btn.textContent = 'Enable Demo';
|
|
btn.className = 'btn btn-g';
|
|
st.innerHTML = 'Status: <strong style="color:var(--text2)">Off</strong>';
|
|
}
|
|
}
|
|
|
|
async function adminToggleDemo() {
|
|
await fetch('/api/demo/toggle', {method:'POST'});
|
|
loadDemoStatus();
|
|
toast('Demo mode toggled');
|
|
}
|
|
|
|
async function loadAllowlist() {
|
|
const r = await fetch('/api/demo/allowlist');
|
|
const d = await r.json();
|
|
const el = document.getElementById('allowlist');
|
|
if (!d.ips || !d.ips.length) { el.innerHTML = '<div class="empty">No IPs in allowlist.</div>'; return; }
|
|
el.innerHTML = d.ips.map(ip =>
|
|
`<div class="model-row"><span class="name">${ip}</span>${ip.startsWith('192.168.1.') ? '<span class="meta">LAN</span>' : ''}<button class="btn btn-sm btn-r" onclick="removeAllowIP('${ip}')">Remove</button></div>`
|
|
).join('');
|
|
}
|
|
|
|
async function addAllowlistIP() {
|
|
const ip = prompt('Enter IP address to allowlist:');
|
|
if (!ip) return;
|
|
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'add'})});
|
|
loadAllowlist();
|
|
toast('Added ' + ip);
|
|
}
|
|
|
|
async function removeAllowIP(ip) {
|
|
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'remove'})});
|
|
loadAllowlist();
|
|
toast('Removed ' + ip);
|
|
}
|
|
|
|
function toast(msg, ok=true) {
|
|
const t = document.createElement('div');
|
|
t.className = 'toast ' + (ok ? 'ok' : 'err');
|
|
t.textContent = msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 3000);
|
|
}
|
|
|
|
loadConfig();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
LAB_HTML = r"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LLM Team - Lab</title>
|
|
<style>
|
|
:root { --bg:#0a0c10;--surface:#151820;--surface2:#1c2030;--border:#272d3f;--text:#e4e4e7;--text2:#a1a1aa;
|
|
--accent:#6366f1;--accent2:#818cf8;--green:#22c55e;--orange:#f59e0b;--red:#ef4444;--blue:#3b82f6;--glow:rgba(99,102,241,0.12); }
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
.c{max-width:1200px;margin:0 auto;padding:16px 24px}
|
|
header{display:flex;align-items:center;gap:14px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:20px}
|
|
header h1{font-size:22px;font-weight:700;letter-spacing:-0.5px}
|
|
header h1 span{background:linear-gradient(135deg,var(--green),#4ade80);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
header nav{margin-left:auto;display:flex;gap:6px}
|
|
header nav a{color:var(--text2);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px}
|
|
header nav a:hover{border-color:var(--accent);color:var(--accent2)}
|
|
.tabs{display:flex;gap:4px;margin-bottom:20px}
|
|
.tab{padding:8px 16px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text2);cursor:pointer;font-size:13px;font-weight:500;transition:all .15s}
|
|
.tab:hover{border-color:var(--accent);color:var(--text)}
|
|
.tab.active{border-color:var(--accent);background:var(--glow);color:var(--accent2)}
|
|
.tc{display:none}.tc.active{display:block}
|
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:18px;margin-bottom:12px}
|
|
.card h3{font-size:15px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
|
.row{display:flex;gap:10px;align-items:center;margin-bottom:10px;font-size:13px}
|
|
.row label{width:100px;color:var(--text2);flex-shrink:0;font-weight:500}
|
|
.row input,.row select,.row textarea{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:7px 10px;font-size:13px;font-family:inherit}
|
|
.row input:focus,.row select:focus,.row textarea:focus{outline:none;border-color:var(--accent)}
|
|
.btn{padding:7px 14px;border:1px solid var(--border);border-radius:6px;background:var(--surface2);color:var(--text);cursor:pointer;font-size:12px;font-weight:500;transition:all .15s}
|
|
.btn:hover{border-color:var(--accent);color:var(--accent2)}
|
|
.btn-p{background:var(--accent);border-color:var(--accent);color:white}
|
|
.btn-p:hover{filter:brightness(1.15)}
|
|
.btn-g{background:rgba(34,197,94,.15);border-color:var(--green);color:var(--green)}
|
|
.btn-r{background:rgba(239,68,68,.1);border-color:var(--red);color:var(--red)}
|
|
.btn-o{background:rgba(245,158,11,.1);border-color:var(--orange);color:var(--orange)}
|
|
.exp-item{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:8px;cursor:pointer;transition:border-color .15s}
|
|
.exp-item:hover{border-color:var(--accent)}
|
|
.exp-item .name{font-weight:600;font-size:14px}
|
|
.exp-item .meta{font-size:11px;color:var(--text2);display:flex;gap:12px;margin-top:4px}
|
|
.status-pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;text-transform:uppercase}
|
|
.status-pill.idle{background:var(--surface);color:var(--text2)}
|
|
.status-pill.running{background:rgba(34,197,94,.15);color:var(--green);animation:pulse 2s infinite}
|
|
.status-pill.paused{background:rgba(245,158,11,.15);color:var(--orange)}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
|
.eval-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:6px;align-items:start}
|
|
.eval-row textarea{min-height:50px;font-size:12px;resize:vertical}
|
|
.eval-row .btn{margin-top:0;flex-shrink:0;align-self:center}
|
|
.model-chip{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;margin:2px;cursor:pointer;border:1px solid var(--border);transition:all .15s}
|
|
.model-chip:hover{border-color:var(--accent)}
|
|
.model-chip.sel{background:var(--glow);border-color:var(--accent);color:var(--accent2)}
|
|
.chart-wrap{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:14px;overflow:hidden}
|
|
.chart-wrap svg{width:100%;height:200px}
|
|
.trial-log{max-height:400px;overflow-y:auto}
|
|
.trial-log::-webkit-scrollbar{width:4px}
|
|
.trial-log::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
|
.trial-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:12px;border-bottom:1px solid var(--border)}
|
|
.trial-item:last-child{border:none}
|
|
.trial-item .num{width:30px;color:var(--text2);font-weight:600}
|
|
.trial-item .diff{flex:1;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.trial-item .score{font-weight:600;width:50px;text-align:right}
|
|
.trial-item .ind{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
|
.best-box{background:var(--surface2);border:1px solid var(--green);border-radius:8px;padding:12px;font-size:12px;white-space:pre-wrap;max-height:200px;overflow-y:auto}
|
|
.toast{position:fixed;top:20px;right:20px;padding:10px 16px;border-radius:8px;font-size:13px;z-index:100;animation:fi .2s}
|
|
.toast.ok{background:rgba(34,197,94,.15);border:1px solid var(--green);color:var(--green)}
|
|
.toast.err{background:rgba(239,68,68,.1);border:1px solid var(--red);color:var(--red)}
|
|
@keyframes fi{from{opacity:0;transform:translateY(-10px)}to{opacity:1}}
|
|
.empty{text-align:center;padding:40px;color:var(--text2);font-size:13px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="c">
|
|
<header>
|
|
<h1><span>Lab</span> AutoResearch</h1>
|
|
<nav style="margin-left:auto;display:flex;gap:8px;align-items:center"><a href="/">Team UI</a><a href="/admin">Admin</a><span style="opacity:0.3">|</span><a href="/logout" style="opacity:0.5;font-size:11px">Logout</a></nav>
|
|
</header>
|
|
<div class="tabs">
|
|
<div class="tab active" onclick="labTab('experiments')">Experiments</div>
|
|
<div class="tab" onclick="labTab('config')">Mutable Config</div>
|
|
<div class="tab" onclick="labTab('monitor')">Live Monitor</div>
|
|
<div class="tab" onclick="labTab('results')">Results</div>
|
|
</div>
|
|
|
|
<!-- EXPERIMENTS TAB -->
|
|
<div id="lt-experiments" class="tc active">
|
|
<div class="card">
|
|
<h3>Create Experiment <button class="btn btn-p" style="margin-left:auto" onclick="showCreate()">+ New</button></h3>
|
|
<div id="exp-list"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
<div id="create-form" class="card" style="display:none;border-color:var(--green)">
|
|
<h3>New Experiment</h3>
|
|
<div class="row"><label>Name</label><input id="cr-name" placeholder="e.g. Prompt Optimization v1"></div>
|
|
<div class="row"><label>Objective</label><input id="cr-obj" placeholder="e.g. Improve answer quality for technical questions"></div>
|
|
<div class="row"><label>Metric</label><select id="cr-metric"><option value="quality">Quality (LLM Judge)</option><option value="accuracy">Accuracy (Match)</option><option value="speed">Speed</option></select></div>
|
|
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin:12px 0 8px;font-weight:600">Model Pool</div>
|
|
<div id="cr-models"></div>
|
|
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin:12px 0 8px;font-weight:600">Eval Cases</div>
|
|
<div id="cr-evals"></div>
|
|
<button class="btn" onclick="addEvalRow()" style="margin-bottom:12px">+ Add Eval Case</button>
|
|
<div class="row" style="justify-content:flex-end;gap:6px">
|
|
<button class="btn" onclick="hideCreate()">Cancel</button>
|
|
<button class="btn btn-p" onclick="createExp()">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MUTABLE CONFIG TAB -->
|
|
<div id="lt-config" class="tc">
|
|
<div class="card" id="config-panel">
|
|
<h3>Mutable Config <span style="font-size:11px;color:var(--text2)" id="cfg-exp-name"></span></h3>
|
|
<div id="no-exp-cfg" class="empty">Select an experiment from the Experiments tab first.</div>
|
|
<div id="cfg-editor" style="display:none">
|
|
<div class="row"><label>System Prompt</label></div>
|
|
<textarea id="cfg-sysprompt" style="width:100%;min-height:100px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:10px;font-size:12px;margin-bottom:10px;font-family:inherit" placeholder="You are a helpful assistant."></textarea>
|
|
<div class="row"><label>Temperature</label><input id="cfg-temp" type="range" min="0" max="1.5" step="0.05" value="0.7" oninput="document.getElementById('cfg-temp-val').textContent=this.value"><span id="cfg-temp-val" style="width:30px;text-align:center;font-size:12px">0.7</span></div>
|
|
<div class="row"><label>Model</label><select id="cfg-model"></select></div>
|
|
<div class="row" style="justify-content:flex-end"><button class="btn btn-p" onclick="saveConfig()">Save Config</button></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LIVE MONITOR TAB -->
|
|
<div id="lt-monitor" class="tc">
|
|
<div class="card">
|
|
<h3><span id="mon-name">No Experiment Selected</span>
|
|
<div style="margin-left:auto;display:flex;gap:6px">
|
|
<button class="btn btn-g" onclick="startExp()">Start</button>
|
|
<button class="btn btn-o" onclick="pauseExp()">Pause</button>
|
|
<button class="btn btn-r" onclick="resetExp()">Reset</button>
|
|
</div>
|
|
</h3>
|
|
<div style="display:flex;gap:16px;margin-bottom:14px;font-size:13px">
|
|
<div>Status: <span class="status-pill" id="mon-status">idle</span></div>
|
|
<div>Trials: <strong id="mon-trials">0</strong></div>
|
|
<div>Best: <strong id="mon-best" style="color:var(--green)">0.0</strong>/10</div>
|
|
<div>Improvements: <strong id="mon-impr">0</strong></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Score Progression</h3>
|
|
<div class="chart-wrap"><svg id="score-chart" viewBox="0 0 800 200"></svg></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Trial Log</h3>
|
|
<div class="trial-log" id="trial-log"><div class="empty">Start an experiment to see trials here.</div></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Best Config</h3>
|
|
<div class="best-box" id="best-config-display">No best config yet.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RESULTS TAB -->
|
|
<div id="lt-results" class="tc">
|
|
<div class="card">
|
|
<h3>All Experiments</h3>
|
|
<div id="results-list"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
<div class="card" id="result-detail" style="display:none">
|
|
<h3 id="res-name">Experiment</h3>
|
|
<div class="chart-wrap"><svg id="res-chart" viewBox="0 0 800 200"></svg></div>
|
|
<div class="trial-log" id="res-trials"></div>
|
|
<div style="margin-top:12px"><button class="btn btn-p" onclick="exportBest()">Export Best Config</button></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let experiments = [], activeExp = null, activeStream = null, allModels = [], trialData = [];
|
|
|
|
async function init() {
|
|
const r = await fetch('/api/models');
|
|
const d = await r.json();
|
|
allModels = d.models || [];
|
|
await loadExperiments();
|
|
}
|
|
|
|
async function loadExperiments() {
|
|
const r = await fetch('/api/lab/experiments');
|
|
const d = await r.json();
|
|
experiments = d.experiments || [];
|
|
renderExpList();
|
|
renderResults();
|
|
}
|
|
|
|
function renderExpList() {
|
|
const el = document.getElementById('exp-list');
|
|
if (!experiments.length) { el.innerHTML = '<div class="empty">No experiments yet. Create one to get started.</div>'; return; }
|
|
el.innerHTML = experiments.map(e => {
|
|
const rate = e.total_trials > 0 ? ((e.improvements / e.total_trials) * 100).toFixed(0) : 0;
|
|
return `<div class="exp-item" onclick="selectExp(${e.id})">
|
|
<div class="name">${e.name} <span class="status-pill ${e.status}">${e.status}</span></div>
|
|
<div class="meta"><span>Trials: ${e.total_trials}</span><span>Best: ${(e.best_score||0).toFixed(1)}/10</span><span>Improvements: ${e.improvements} (${rate}%)</span><span>${e.metric}</span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function selectExp(id) {
|
|
const r = await fetch('/api/lab/experiments/' + id);
|
|
activeExp = await r.json();
|
|
trialData = activeExp.trials || [];
|
|
updateMonitor();
|
|
updateConfigEditor();
|
|
toast('Loaded: ' + activeExp.name);
|
|
}
|
|
|
|
function updateMonitor() {
|
|
if (!activeExp) return;
|
|
document.getElementById('mon-name').textContent = activeExp.name;
|
|
document.getElementById('mon-status').textContent = activeExp.status;
|
|
document.getElementById('mon-status').className = 'status-pill ' + activeExp.status;
|
|
document.getElementById('mon-trials').textContent = activeExp.total_trials;
|
|
document.getElementById('mon-best').textContent = (activeExp.best_score || 0).toFixed(1);
|
|
document.getElementById('mon-impr').textContent = activeExp.improvements;
|
|
if (activeExp.best_config) {
|
|
document.getElementById('best-config-display').textContent = JSON.stringify(activeExp.best_config, null, 2);
|
|
}
|
|
renderTrialLog();
|
|
renderChart('score-chart', trialData);
|
|
}
|
|
|
|
function updateConfigEditor() {
|
|
if (!activeExp) return;
|
|
document.getElementById('no-exp-cfg').style.display = 'none';
|
|
document.getElementById('cfg-editor').style.display = '';
|
|
document.getElementById('cfg-exp-name').textContent = '(' + activeExp.name + ')';
|
|
const mc = activeExp.mutable_config || {};
|
|
document.getElementById('cfg-sysprompt').value = mc.system_prompt || '';
|
|
document.getElementById('cfg-temp').value = mc.temperature || 0.7;
|
|
document.getElementById('cfg-temp-val').textContent = mc.temperature || 0.7;
|
|
const sel = document.getElementById('cfg-model');
|
|
sel.innerHTML = (activeExp.models_pool || []).map(m => `<option value="${m}" ${m===mc.model?'selected':''}>${m}</option>`).join('');
|
|
}
|
|
|
|
async function saveConfig() {
|
|
if (!activeExp) return;
|
|
const mc = {
|
|
system_prompt: document.getElementById('cfg-sysprompt').value,
|
|
temperature: parseFloat(document.getElementById('cfg-temp').value),
|
|
model: document.getElementById('cfg-model').value,
|
|
};
|
|
await fetch('/api/lab/experiments/' + activeExp.id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({mutable_config:mc})});
|
|
activeExp.mutable_config = mc;
|
|
toast('Config saved');
|
|
}
|
|
|
|
async function startExp() {
|
|
if (!activeExp) return toast('Select an experiment first', false);
|
|
await fetch('/api/lab/experiments/' + activeExp.id + '/start', {method:'POST'});
|
|
activeExp.status = 'running';
|
|
updateMonitor();
|
|
startStream();
|
|
toast('Experiment started');
|
|
}
|
|
|
|
async function pauseExp() {
|
|
if (!activeExp) return;
|
|
await fetch('/api/lab/experiments/' + activeExp.id + '/pause', {method:'POST'});
|
|
activeExp.status = 'paused';
|
|
updateMonitor();
|
|
toast('Experiment paused');
|
|
}
|
|
|
|
async function resetExp() {
|
|
if (!activeExp) return;
|
|
if (!confirm('Reset all trials for this experiment?')) return;
|
|
await fetch('/api/lab/experiments/' + activeExp.id + '/reset', {method:'POST'});
|
|
trialData = [];
|
|
activeExp.total_trials = 0;
|
|
activeExp.improvements = 0;
|
|
activeExp.best_score = 0;
|
|
activeExp.status = 'idle';
|
|
updateMonitor();
|
|
toast('Experiment reset');
|
|
}
|
|
|
|
function startStream() {
|
|
if (activeStream) activeStream.close();
|
|
if (!activeExp) return;
|
|
const es = new EventSource('/api/lab/experiments/' + activeExp.id + '/stream');
|
|
activeStream = es;
|
|
es.onmessage = function(e) {
|
|
const d = JSON.parse(e.data);
|
|
if (d.type === 'trial') {
|
|
trialData.push(d);
|
|
activeExp.total_trials = d.trial;
|
|
activeExp.best_score = d.best;
|
|
if (d.improved) activeExp.improvements = (activeExp.improvements||0) + 1;
|
|
updateMonitor();
|
|
} else if (d.type === 'done') {
|
|
activeExp.status = 'paused';
|
|
updateMonitor();
|
|
es.close();
|
|
} else if (d.type === 'error') {
|
|
toast(d.message, false);
|
|
}
|
|
};
|
|
es.onerror = function() { es.close(); };
|
|
}
|
|
|
|
function renderTrialLog() {
|
|
const el = document.getElementById('trial-log');
|
|
if (!trialData.length) { el.innerHTML = '<div class="empty">No trials yet.</div>'; return; }
|
|
el.innerHTML = trialData.slice(-50).reverse().map(t =>
|
|
`<div class="trial-item">
|
|
<div class="ind" style="background:${t.improved?'var(--green)':'var(--red)'}"></div>
|
|
<div class="num">#${t.trial}</div>
|
|
<div class="diff">${t.diff || 'no change'}</div>
|
|
<div class="score" style="color:${t.improved?'var(--green)':'var(--text2)'}">${t.score.toFixed(1)}</div>
|
|
</div>`
|
|
).join('');
|
|
el.scrollTop = 0;
|
|
}
|
|
|
|
function renderChart(svgId, trials) {
|
|
const svg = document.getElementById(svgId);
|
|
if (!trials.length) { svg.innerHTML = '<text x="400" y="100" text-anchor="middle" fill="#a1a1aa" font-size="14">No data yet</text>'; return; }
|
|
const w = 800, h = 200, pad = 30;
|
|
const maxScore = 10, minScore = 0;
|
|
const pts = trials.map((t, i) => {
|
|
const x = pad + (i / Math.max(trials.length - 1, 1)) * (w - pad * 2);
|
|
const y = h - pad - ((t.score - minScore) / (maxScore - minScore)) * (h - pad * 2);
|
|
return {x, y, score: t.score, improved: t.improved, trial: t.trial};
|
|
});
|
|
// Best score line
|
|
const bestY = h - pad - ((Math.max(...trials.map(t=>t.best||t.score)) - minScore) / (maxScore - minScore)) * (h - pad * 2);
|
|
let html = `<line x1="${pad}" y1="${bestY}" x2="${w-pad}" y2="${bestY}" stroke="#22c55e" stroke-width="1" stroke-dasharray="4,4" opacity="0.4"/>`;
|
|
// Score line
|
|
const line = pts.map(p => `${p.x},${p.y}`).join(' ');
|
|
html += `<polyline points="${line}" fill="none" stroke="var(--accent)" stroke-width="2" opacity="0.7"/>`;
|
|
// Dots
|
|
pts.forEach(p => {
|
|
html += `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${p.improved?'#22c55e':'#ef4444'}" opacity="0.8"/>`;
|
|
});
|
|
// Axes
|
|
html += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${h-pad}" stroke="var(--border)" stroke-width="1"/>`;
|
|
html += `<line x1="${pad}" y1="${h-pad}" x2="${w-pad}" y2="${h-pad}" stroke="var(--border)" stroke-width="1"/>`;
|
|
// Labels
|
|
for (let s = 0; s <= 10; s += 2) {
|
|
const y = h - pad - (s / 10) * (h - pad * 2);
|
|
html += `<text x="${pad-5}" y="${y+4}" text-anchor="end" fill="var(--text2)" font-size="10">${s}</text>`;
|
|
}
|
|
svg.innerHTML = html;
|
|
}
|
|
|
|
// Create experiment
|
|
function showCreate() { document.getElementById('create-form').style.display = ''; renderModelChips(); addEvalRow(); }
|
|
function hideCreate() { document.getElementById('create-form').style.display = 'none'; }
|
|
|
|
let selectedModels = new Set();
|
|
function renderModelChips() {
|
|
document.getElementById('cr-models').innerHTML = allModels.map(m => {
|
|
const s = selectedModels.has(m.name);
|
|
return `<span class="model-chip ${s?'sel':''}" onclick="toggleChip('${m.name}')">${m.display_name || m.name}</span>`;
|
|
}).join('');
|
|
}
|
|
function toggleChip(name) { selectedModels.has(name) ? selectedModels.delete(name) : selectedModels.add(name); renderModelChips(); }
|
|
|
|
let evalRows = [];
|
|
function addEvalRow() {
|
|
evalRows.push({input:'', expected:''});
|
|
renderEvalRows();
|
|
}
|
|
function renderEvalRows() {
|
|
document.getElementById('cr-evals').innerHTML = evalRows.map((r, i) =>
|
|
`<div class="eval-row">
|
|
<textarea placeholder="Input prompt..." oninput="evalRows[${i}].input=this.value">${r.input}</textarea>
|
|
<textarea placeholder="Expected output (optional)..." oninput="evalRows[${i}].expected=this.value">${r.expected}</textarea>
|
|
<button class="btn btn-r" onclick="evalRows.splice(${i},1);renderEvalRows()">x</button>
|
|
</div>`
|
|
).join('');
|
|
}
|
|
|
|
async function createExp() {
|
|
const name = document.getElementById('cr-name').value.trim();
|
|
if (!name) return toast('Name required', false);
|
|
if (!selectedModels.size) return toast('Select at least one model', false);
|
|
if (!evalRows.filter(r=>r.input).length) return toast('Add at least one eval case', false);
|
|
const models = [...selectedModels];
|
|
const body = {
|
|
name,
|
|
objective: document.getElementById('cr-obj').value,
|
|
metric: document.getElementById('cr-metric').value,
|
|
models_pool: models,
|
|
eval_cases: evalRows.filter(r => r.input),
|
|
mutable_config: { system_prompt: 'You are a helpful assistant.', temperature: 0.7, model: models[0] }
|
|
};
|
|
await fetch('/api/lab/experiments', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
hideCreate();
|
|
evalRows = [];
|
|
selectedModels.clear();
|
|
document.getElementById('cr-name').value = '';
|
|
document.getElementById('cr-obj').value = '';
|
|
await loadExperiments();
|
|
toast('Experiment created');
|
|
}
|
|
|
|
// Results tab
|
|
function renderResults() {
|
|
const el = document.getElementById('results-list');
|
|
if (!experiments.length) { el.innerHTML = '<div class="empty">No experiments yet.</div>'; return; }
|
|
el.innerHTML = experiments.map(e => {
|
|
const rate = e.total_trials > 0 ? ((e.improvements / e.total_trials) * 100).toFixed(0) : 0;
|
|
return `<div class="exp-item" onclick="viewResult(${e.id})">
|
|
<div class="name">${e.name} <span class="status-pill ${e.status}">${e.status}</span></div>
|
|
<div class="meta"><span>Trials: ${e.total_trials}</span><span>Best: ${(e.best_score||0).toFixed(1)}/10</span><span>Hit rate: ${rate}%</span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function viewResult(id) {
|
|
const r = await fetch('/api/lab/experiments/' + id);
|
|
const exp = await r.json();
|
|
document.getElementById('result-detail').style.display = '';
|
|
document.getElementById('res-name').textContent = exp.name;
|
|
const trials = (exp.trials || []).map(t => ({trial: t.trial_num, score: t.avg_score, improved: t.improved, best: exp.best_score, diff: t.config_diff}));
|
|
renderChart('res-chart', trials);
|
|
document.getElementById('res-trials').innerHTML = trials.slice(-50).reverse().map(t =>
|
|
`<div class="trial-item">
|
|
<div class="ind" style="background:${t.improved?'var(--green)':'var(--red)'}"></div>
|
|
<div class="num">#${t.trial}</div>
|
|
<div class="diff">${t.diff || ''}</div>
|
|
<div class="score" style="color:${t.improved?'var(--green)':'var(--text2)'}">${(t.score||0).toFixed(1)}</div>
|
|
</div>`
|
|
).join('');
|
|
activeExp = exp;
|
|
}
|
|
|
|
function exportBest() {
|
|
if (!activeExp || !activeExp.best_config) return toast('No best config', false);
|
|
const blob = new Blob([JSON.stringify(activeExp.best_config, null, 2)], {type:'application/json'});
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = (activeExp.name||'config').replace(/\s+/g,'_') + '_best.json';
|
|
a.click();
|
|
}
|
|
|
|
function labTab(name) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.textContent.toLowerCase().includes(name.substring(0,4))));
|
|
document.querySelectorAll('.tc').forEach(c => c.classList.toggle('active', c.id === 'lt-'+name));
|
|
if (name === 'results') loadExperiments();
|
|
}
|
|
|
|
function toast(msg, ok=true) {
|
|
const t = document.createElement('div');
|
|
t.className = 'toast ' + (ok ? 'ok' : 'err');
|
|
t.textContent = msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 3000);
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
# ─── HELPERS ───────────────────────────────────────────────────
|
|
|
|
def _get_timeout(model_id):
|
|
cfg = load_config()
|
|
t = cfg["timeouts"]["per_model"].get(model_id)
|
|
if t:
|
|
return t
|
|
if "::" in model_id:
|
|
prov = model_id.split("::")[0]
|
|
return cfg["providers"].get(prov, {}).get("timeout", cfg["timeouts"]["global"])
|
|
return cfg["providers"].get("ollama", {}).get("timeout", cfg["timeouts"]["global"])
|
|
|
|
|
|
def query_ollama(model, prompt, timeout):
|
|
cfg = load_config()
|
|
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
|
|
resp = requests.post(f"{base}/api/generate", json={
|
|
"model": model, "prompt": prompt, "stream": False,
|
|
}, timeout=timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()["response"]
|
|
|
|
|
|
def query_openai_compatible(model, prompt, provider_name, timeout):
|
|
cfg = load_config()
|
|
prov = cfg["providers"].get(provider_name, {})
|
|
base = prov.get("base_url", "https://openrouter.ai/api/v1")
|
|
api_key = get_api_key(provider_name)
|
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
if provider_name == "openrouter":
|
|
headers["HTTP-Referer"] = "http://localhost:5000"
|
|
headers["X-Title"] = "LLM Team UI"
|
|
resp = requests.post(f"{base}/chat/completions", headers=headers, json={
|
|
"model": model, "messages": [{"role": "user", "content": prompt}], "stream": False,
|
|
}, timeout=timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()["choices"][0]["message"]["content"]
|
|
|
|
|
|
def query_anthropic(model, prompt, timeout):
|
|
cfg = load_config()
|
|
prov = cfg["providers"].get("anthropic", {})
|
|
base = prov.get("base_url", "https://api.anthropic.com/v1")
|
|
api_key = get_api_key("anthropic")
|
|
resp = requests.post(f"{base}/messages", headers={
|
|
"x-api-key": api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json",
|
|
}, json={
|
|
"model": model, "max_tokens": 4096,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
}, timeout=timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()["content"][0]["text"]
|
|
|
|
|
|
def query_model(model_id, prompt):
|
|
timeout = _get_timeout(model_id)
|
|
if "::" in model_id:
|
|
provider_name, model_name = model_id.split("::", 1)
|
|
if provider_name == "anthropic":
|
|
return query_anthropic(model_name, prompt, timeout)
|
|
return query_openai_compatible(model_name, prompt, provider_name, timeout)
|
|
return query_ollama(model_id, prompt, timeout)
|
|
|
|
|
|
# ─── CONTEXT MANAGEMENT ───────────────────────────────────────
|
|
|
|
# Context window sizes (tokens) — conservative estimates for safe prompting
|
|
MODEL_CONTEXT = {
|
|
"llama3.2": 4096, "mistral": 8192, "gemma2": 8192, "qwen2.5": 8192,
|
|
"gpt-oss": 4096, "gpt-4o": 128000, "gpt-4o-mini": 128000,
|
|
"claude-3": 200000, "claude-sonnet": 200000, "claude-haiku": 200000,
|
|
}
|
|
DEFAULT_CONTEXT = 4096 # safe fallback for unknown models
|
|
MAX_RESPONSE_CHARS = 12000 # cap individual responses (~3K tokens)
|
|
|
|
|
|
def estimate_tokens(text):
|
|
"""Rough token estimate: ~4 chars per token for English."""
|
|
return len(text) // 4 + 1
|
|
|
|
|
|
def get_context_limit(model_id):
|
|
"""Get context window size for a model."""
|
|
name = model_id.split("::")[-1].split(":")[0].lower()
|
|
for key, limit in MODEL_CONTEXT.items():
|
|
if key in name:
|
|
return limit
|
|
# OpenRouter models generally have larger contexts
|
|
if "::" in model_id:
|
|
return 16000
|
|
return DEFAULT_CONTEXT
|
|
|
|
|
|
def smart_truncate(text, max_tokens, preserve_end=200):
|
|
"""Truncate text preserving start and end, with a clear marker."""
|
|
if estimate_tokens(text) <= max_tokens:
|
|
return text
|
|
max_chars = max_tokens * 4
|
|
end_chars = preserve_end * 4
|
|
if max_chars <= end_chars * 2:
|
|
return text[:max_chars]
|
|
start = text[:max_chars - end_chars - 60]
|
|
end = text[-end_chars:]
|
|
return f"{start}\n\n[... truncated {estimate_tokens(text) - max_tokens} tokens ...]\n\n{end}"
|
|
|
|
|
|
def cap_response(text):
|
|
"""Cap a single model response to prevent runaway output."""
|
|
if len(text) <= MAX_RESPONSE_CHARS:
|
|
return text
|
|
return smart_truncate(text, MAX_RESPONSE_CHARS // 4)
|
|
|
|
|
|
def build_context(parts, model_id, reserve_for_response=1024):
|
|
"""Build a prompt from parts, fitting within model's context window.
|
|
|
|
parts: list of (label, text, priority) tuples
|
|
priority: 1=must keep, 2=important, 3=can truncate heavily
|
|
Returns: assembled prompt string that fits in context.
|
|
"""
|
|
limit = get_context_limit(model_id)
|
|
budget = limit - reserve_for_response
|
|
if budget <= 0:
|
|
budget = limit // 2
|
|
|
|
# First pass: measure everything
|
|
total = sum(estimate_tokens(t) for _, t, _ in parts)
|
|
if total <= budget:
|
|
return "\n\n".join(f"{label}\n{text}" if label else text for label, text, _ in parts)
|
|
|
|
# Need to truncate — allocate budget by priority
|
|
p1 = [(l, t, p) for l, t, p in parts if p == 1]
|
|
p2 = [(l, t, p) for l, t, p in parts if p == 2]
|
|
p3 = [(l, t, p) for l, t, p in parts if p == 3]
|
|
|
|
p1_tokens = sum(estimate_tokens(t) for _, t, _ in p1)
|
|
remaining = budget - p1_tokens
|
|
|
|
if remaining <= 0:
|
|
# Even priority 1 doesn't fit — truncate p1
|
|
per_part = budget // max(len(p1), 1)
|
|
result = []
|
|
for label, text, _ in p1:
|
|
result.append(f"{label}\n{smart_truncate(text, per_part)}" if label else smart_truncate(text, per_part))
|
|
return "\n\n".join(result)
|
|
|
|
# Allocate remaining to p2, then p3
|
|
result = [f"{l}\n{t}" if l else t for l, t, _ in p1]
|
|
|
|
for group in [p2, p3]:
|
|
if not group or remaining <= 0:
|
|
continue
|
|
per_part = remaining // max(len(group), 1)
|
|
for label, text, _ in group:
|
|
truncated = smart_truncate(text, max(per_part, 100))
|
|
result.append(f"{label}\n{truncated}" if label else truncated)
|
|
remaining -= estimate_tokens(truncated)
|
|
|
|
return "\n\n".join(result)
|
|
|
|
|
|
def safe_query(model_id, prompt, fallback_summarize=True):
|
|
"""Query with context safety — auto-truncates prompt if too large, retries on overflow errors."""
|
|
limit = get_context_limit(model_id)
|
|
prompt_tokens = estimate_tokens(prompt)
|
|
|
|
# Pre-flight check: truncate if obviously too large
|
|
if prompt_tokens > limit - 500:
|
|
prompt = smart_truncate(prompt, limit - 1000)
|
|
|
|
try:
|
|
response = query_model(model_id, prompt)
|
|
return cap_response(response)
|
|
except Exception as e:
|
|
err = str(e).lower()
|
|
# Detect context overflow errors from various providers
|
|
if any(k in err for k in ["context length", "too many tokens", "maximum context", "token limit",
|
|
"content_too_large", "request too large", "413", "400"]):
|
|
if fallback_summarize:
|
|
# Aggressive truncation and retry
|
|
truncated = smart_truncate(prompt, limit // 2)
|
|
try:
|
|
response = query_model(model_id, truncated)
|
|
return cap_response(response)
|
|
except Exception:
|
|
pass
|
|
return f"[Context overflow: prompt was ~{prompt_tokens} tokens, model limit ~{limit}. Response truncated to fit.]"
|
|
raise
|
|
|
|
|
|
def parallel_safe_query(models, prompt):
|
|
"""Like parallel_query but with context safety on each model."""
|
|
results = {}
|
|
max_timeout = max((_get_timeout(m) for m in models), default=300) + 30
|
|
with ThreadPoolExecutor(max_workers=max(len(models), 1)) as pool:
|
|
futures = {pool.submit(safe_query, m, prompt): m for m in models}
|
|
for future in as_completed(futures, timeout=max_timeout):
|
|
model = futures[future]
|
|
try:
|
|
results[model] = future.result(timeout=10)
|
|
except Exception as e:
|
|
results[model] = f"Error: {e}"
|
|
return results
|
|
|
|
|
|
def sse(data):
|
|
return f"data: {json.dumps(data)}\n\n"
|
|
|
|
|
|
def parallel_query(models, prompt):
|
|
"""Query multiple models in parallel with context safety."""
|
|
return parallel_safe_query(models, prompt)
|
|
|
|
|
|
# ─── ROUTES ────────────────────────────────────────────────────
|
|
|
|
@app.route("/")
|
|
@login_required
|
|
def index():
|
|
return render_template_string(HTML)
|
|
|
|
|
|
@app.route("/api/models")
|
|
@login_required
|
|
def get_models():
|
|
SKIP = {"nomic-embed-text", "mxbai-embed-large", "all-minilm", "snowflake-arctic-embed"}
|
|
cfg = load_config()
|
|
models = []
|
|
# Local Ollama models
|
|
if cfg["providers"]["ollama"].get("enabled", True):
|
|
try:
|
|
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
|
|
resp = requests.get(f"{base}/api/tags", timeout=10)
|
|
seen = set()
|
|
for m in resp.json().get("models", []):
|
|
full = m["name"]
|
|
short = full.split(":")[0]
|
|
size = m.get("size", 0)
|
|
if short in SKIP or size < 1_000_000 or short in seen:
|
|
continue
|
|
if full in cfg.get("disabled_models", []):
|
|
continue
|
|
seen.add(short)
|
|
models.append({"name": full, "size": f"{size/(1024**3):.1f} GB",
|
|
"provider": "ollama", "provider_label": "Local",
|
|
"display_name": short})
|
|
except Exception:
|
|
pass
|
|
# Cloud models
|
|
for cm in cfg.get("cloud_models", []):
|
|
if not cm.get("enabled", True):
|
|
continue
|
|
prov = cm["id"].split("::")[0] if "::" in cm["id"] else "cloud"
|
|
if not cfg["providers"].get(prov, {}).get("enabled", False):
|
|
continue
|
|
models.append({"name": cm["id"], "size": cm.get("context", "cloud"),
|
|
"provider": prov, "provider_label": prov.title(),
|
|
"display_name": cm.get("display_name", cm["id"].split("::")[-1])})
|
|
return jsonify({"models": models})
|
|
|
|
|
|
# ─── ADMIN ROUTES ─────────────────────────────────────────────
|
|
|
|
@app.route("/admin")
|
|
@admin_required
|
|
def admin_page():
|
|
return render_template_string(ADMIN_HTML)
|
|
|
|
|
|
@app.route("/api/admin/config", methods=["GET"])
|
|
@admin_required
|
|
def admin_get_config():
|
|
cfg = load_config()
|
|
safe = json.loads(json.dumps(cfg))
|
|
for name, p in safe["providers"].items():
|
|
if p.get("api_key"):
|
|
p["api_key_set"] = True
|
|
p["api_key"] = ""
|
|
else:
|
|
p["api_key_set"] = bool(get_api_key(name))
|
|
return jsonify(safe)
|
|
|
|
|
|
@app.route("/api/admin/config", methods=["POST"])
|
|
@admin_required
|
|
def admin_save_config():
|
|
data = request.json
|
|
cfg = load_config()
|
|
# update providers (preserve existing keys if not sent)
|
|
for name, prov in data.get("providers", {}).items():
|
|
if name in cfg["providers"]:
|
|
new_key = prov.get("api_key", "")
|
|
if not new_key:
|
|
prov["api_key"] = cfg["providers"][name].get("api_key", "")
|
|
cfg["providers"][name].update(prov)
|
|
if "disabled_models" in data:
|
|
cfg["disabled_models"] = data["disabled_models"]
|
|
if "cloud_models" in data:
|
|
cfg["cloud_models"] = data["cloud_models"]
|
|
if "timeouts" in data:
|
|
cfg["timeouts"] = data["timeouts"]
|
|
save_config(cfg)
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/admin/test-provider", methods=["POST"])
|
|
@admin_required
|
|
def admin_test_provider():
|
|
data = request.json
|
|
name = data.get("provider", "")
|
|
cfg = load_config()
|
|
prov = cfg["providers"].get(name, {})
|
|
try:
|
|
if name == "ollama":
|
|
r = requests.get(f"{prov.get('base_url', 'http://localhost:11434')}/api/tags", timeout=5)
|
|
count = len(r.json().get("models", []))
|
|
return jsonify({"ok": True, "message": f"Connected. {count} models available."})
|
|
elif name == "openrouter":
|
|
key = data.get("api_key") or get_api_key("openrouter")
|
|
r = requests.get(f"{prov.get('base_url', 'https://openrouter.ai/api/v1')}/models",
|
|
headers={"Authorization": f"Bearer {key}"}, timeout=10)
|
|
count = len(r.json().get("data", []))
|
|
return jsonify({"ok": True, "message": f"Connected. {count} models available."})
|
|
elif name == "openai":
|
|
key = data.get("api_key") or get_api_key("openai")
|
|
r = requests.get(f"{prov.get('base_url', 'https://api.openai.com/v1')}/models",
|
|
headers={"Authorization": f"Bearer {key}"}, timeout=10)
|
|
return jsonify({"ok": True, "message": f"Connected. {len(r.json().get('data', []))} models."})
|
|
elif name == "anthropic":
|
|
key = data.get("api_key") or get_api_key("anthropic")
|
|
r = requests.post(f"{prov.get('base_url', 'https://api.anthropic.com/v1')}/messages",
|
|
headers={"x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"},
|
|
json={"model": "claude-haiku-4-5-20251001", "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]},
|
|
timeout=10)
|
|
return jsonify({"ok": True, "message": "Connected to Anthropic."})
|
|
return jsonify({"ok": False, "message": "Unknown provider"})
|
|
except Exception as e:
|
|
return jsonify({"ok": False, "message": str(e)})
|
|
|
|
|
|
_or_models_cache = {"data": None, "ts": 0}
|
|
|
|
@app.route("/api/admin/openrouter/models")
|
|
@admin_required
|
|
def admin_openrouter_models():
|
|
import time
|
|
now = time.time()
|
|
if _or_models_cache["data"] and now - _or_models_cache["ts"] < 300:
|
|
return jsonify({"models": _or_models_cache["data"]})
|
|
key = get_api_key("openrouter")
|
|
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
try:
|
|
r = requests.get("https://openrouter.ai/api/v1/models", headers=headers, timeout=15)
|
|
r.raise_for_status()
|
|
free = []
|
|
for m in r.json().get("data", []):
|
|
pricing = m.get("pricing", {})
|
|
if pricing.get("prompt") == "0" and pricing.get("completion") == "0":
|
|
free.append({"id": m["id"], "name": m.get("name", m["id"]),
|
|
"context_length": m.get("context_length", 0)})
|
|
_or_models_cache["data"] = free
|
|
_or_models_cache["ts"] = now
|
|
return jsonify({"models": free})
|
|
except Exception as e:
|
|
return jsonify({"models": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/api/admin/ollama-models")
|
|
@admin_required
|
|
def admin_ollama_models():
|
|
cfg = load_config()
|
|
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
|
|
SKIP = {"nomic-embed-text", "mxbai-embed-large", "all-minilm", "snowflake-arctic-embed"}
|
|
try:
|
|
resp = requests.get(f"{base}/api/tags", timeout=10)
|
|
models = []
|
|
seen = set()
|
|
for m in resp.json().get("models", []):
|
|
full = m["name"]
|
|
short = full.split(":")[0]
|
|
size = m.get("size", 0)
|
|
if short in SKIP or size < 1_000_000 or short in seen:
|
|
continue
|
|
seen.add(short)
|
|
models.append({"name": full, "size": f"{size/(1024**3):.1f} GB",
|
|
"disabled": full in cfg.get("disabled_models", [])})
|
|
return jsonify({"models": models})
|
|
except Exception as e:
|
|
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"""<!DOCTYPE html>
|
|
<html lang="en"><head>
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>LLM Team — Monitor</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{--bg:#08090c;--surface:rgba(14,16,22,0.85);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;--accent:#e2b55a;--green:#4ade80;--red:#e05252;--blue:#5b9cf5}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 28px}
|
|
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
|
|
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.02) 2px,rgba(0,0,0,0.02) 4px)}
|
|
.wrap{position:relative;z-index:10;max-width:1200px;margin:0 auto}
|
|
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:24px}
|
|
h1{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:700}
|
|
h1 span{color:var(--accent)}
|
|
.back{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:1px;border:2px solid var(--border);padding:5px 12px;border-radius:2px;margin-left:auto}
|
|
.back:hover{border-color:var(--accent);color:var(--accent)}
|
|
.live-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);animation:pulse-dot 2s ease-in-out infinite}
|
|
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.5}}
|
|
.section{margin-bottom:28px}
|
|
.section-title{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:2px;color:var(--accent);margin-bottom:12px;font-weight:700}
|
|
.card{background:var(--surface);border:2px solid var(--border);border-radius:2px;padding:16px;margin-bottom:8px;backdrop-filter:blur(16px)}
|
|
.card.active{border-color:var(--accent);box-shadow:0 0 20px rgba(226,181,90,0.05)}
|
|
.card.error{border-color:var(--red)}
|
|
.card-row{display:flex;align-items:center;gap:12px;margin-bottom:6px;flex-wrap:wrap}
|
|
.tag{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:3px 8px;border:1px solid;border-radius:1px;font-weight:600}
|
|
.tag-mode{color:var(--accent);border-color:rgba(226,181,90,0.3)}
|
|
.tag-user{color:var(--blue);border-color:rgba(91,156,245,0.3)}
|
|
.tag-time{color:var(--text2);border-color:var(--border)}
|
|
.tag-err{color:var(--red);border-color:rgba(224,82,82,0.3)}
|
|
.tag-ok{color:var(--green);border-color:rgba(74,222,128,0.3)}
|
|
.prompt-text{font-size:12px;color:var(--text2);margin:4px 0 8px;font-style:italic;max-width:600px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.mini-progress{height:4px;background:rgba(0,0,0,0.4);border-radius:1px;overflow:hidden;margin:6px 0}
|
|
.mini-fill{height:100%;background:var(--accent);transition:width 0.5s}
|
|
.substep{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2)}
|
|
.error-line{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--red);border-left:2px solid var(--red);padding-left:8px;margin:4px 0}
|
|
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:24px}
|
|
.stat-box{background:var(--surface);border:2px solid var(--border);border-radius:2px;padding:14px;backdrop-filter:blur(16px);text-align:center}
|
|
.stat-val{font-family:'JetBrains Mono',monospace;font-size:22px;font-weight:700;color:var(--accent)}
|
|
.stat-label{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin-top:4px}
|
|
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:20px;text-align:center;opacity:0.5}
|
|
@media(max-width:768px){.stats-grid{grid-template-columns:repeat(2,1fr)}.card-row{gap:6px}}
|
|
</style>
|
|
</head><body>
|
|
<canvas id="bg-grid"></canvas>
|
|
<div class="scanlines"></div>
|
|
<div class="wrap">
|
|
<header>
|
|
<div class="live-dot"></div>
|
|
<h1><span>Monitor</span> // Process View</h1>
|
|
<a class="back" href="/">← Back to Team</a>
|
|
<a class="back" href="/admin">Admin</a>
|
|
</header>
|
|
<div class="stats-grid">
|
|
<div class="stat-box"><div class="stat-val" id="s-active">0</div><div class="stat-label">Active Runs</div></div>
|
|
<div class="stat-box"><div class="stat-val" id="s-total">0</div><div class="stat-label">Completed</div></div>
|
|
<div class="stat-box"><div class="stat-val" id="s-errors">0</div><div class="stat-label">Errors</div></div>
|
|
<div class="stat-box"><div class="stat-val" id="s-avgtime">—</div><div class="stat-label">Avg Duration</div></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-title">Active Runs</div>
|
|
<div id="active-runs"><div class="empty">No active runs</div></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-title">Recent Runs (last 20)</div>
|
|
<div id="recent-runs"><div class="empty">No recent runs</div></div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
!function(){const c=document.getElementById('bg-grid');if(!c)return;const x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);let t=0;function draw(){x.clearRect(0,0,c.width,c.height);const s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(let gx=-s+ox;gx<c.width+s;gx+=s){for(let gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}}t++;requestAnimationFrame(draw)}draw()}();
|
|
|
|
function fmt(s){if(!s&&s!==0)return'—';if(s<60)return Math.round(s)+'s';return Math.floor(s/60)+'m '+Math.round(s%60)+'s'}
|
|
function esc(t){const d=document.createElement('div');d.textContent=t;return d.innerHTML;}
|
|
|
|
function renderActive(runs){
|
|
const el=document.getElementById('active-runs');
|
|
if(!runs.length){el.textContent='';const e=document.createElement('div');e.className='empty';e.textContent='No active runs';el.appendChild(e);return}
|
|
el.textContent='';
|
|
runs.forEach(function(r){
|
|
const c=document.createElement('div');
|
|
c.className='card active';
|
|
const row=document.createElement('div');row.className='card-row';
|
|
row.appendChild(tag(r.mode,'tag-mode'));
|
|
row.appendChild(tag(r.user,'tag-user'));
|
|
row.appendChild(tag(fmt(r.elapsed),'tag-time'));
|
|
row.appendChild(tag(r.events+' events','tag-time'));
|
|
if(r.errors>0)row.appendChild(tag(r.errors+' errors','tag-err'));
|
|
c.appendChild(row);
|
|
const p=document.createElement('div');p.className='prompt-text';p.textContent=r.prompt;c.appendChild(p);
|
|
if(r.total_steps>0){
|
|
const mp=document.createElement('div');mp.className='mini-progress';
|
|
const mf=document.createElement('div');mf.className='mini-fill';
|
|
const pct=r.total_steps>0?Math.round((r.step/r.total_steps)*100):0;
|
|
mf.style.width=Math.max(5,pct)+'%';mp.appendChild(mf);c.appendChild(mp);
|
|
}
|
|
if(r.substep){const s=document.createElement('div');s.className='substep';s.textContent=r.substep;c.appendChild(s)}
|
|
if(r.error_details){r.error_details.forEach(function(e){
|
|
const el2=document.createElement('div');el2.className='error-line';el2.textContent=e.model+': '+e.error;c.appendChild(el2);
|
|
})}
|
|
el.appendChild(c);
|
|
});
|
|
}
|
|
|
|
function renderRecent(runs){
|
|
const el=document.getElementById('recent-runs');
|
|
if(!runs.length){el.textContent='';const e=document.createElement('div');e.className='empty';e.textContent='No recent runs';el.appendChild(e);return}
|
|
el.textContent='';
|
|
runs.forEach(function(r){
|
|
const c=document.createElement('div');
|
|
c.className='card'+(r.errors&&r.errors.length?' error':'');
|
|
const row=document.createElement('div');row.className='card-row';
|
|
row.appendChild(tag(r.mode,'tag-mode'));
|
|
row.appendChild(tag(r.user,'tag-user'));
|
|
row.appendChild(tag(fmt(r.duration),'tag-time'));
|
|
row.appendChild(tag((r.response_count||0)+' responses','tag-time'));
|
|
if(r.errors&&r.errors.length)row.appendChild(tag(r.errors.length+' errors','tag-err'));
|
|
else row.appendChild(tag('ok','tag-ok'));
|
|
c.appendChild(row);
|
|
const p=document.createElement('div');p.className='prompt-text';p.textContent=r.prompt;c.appendChild(p);
|
|
if(r.errors&&r.errors.length){r.errors.slice(-2).forEach(function(e){
|
|
const el2=document.createElement('div');el2.className='error-line';el2.textContent=(e.model||'?')+': '+(e.error||'unknown');c.appendChild(el2);
|
|
})}
|
|
el.appendChild(c);
|
|
});
|
|
}
|
|
|
|
function tag(text,cls){const t=document.createElement('span');t.className='tag '+cls;t.textContent=text;return t}
|
|
|
|
async function poll(){
|
|
try{
|
|
const r=await fetch('/api/admin/monitor');
|
|
const d=await r.json();
|
|
document.getElementById('s-active').textContent=d.active.length;
|
|
document.getElementById('s-total').textContent=d.recent.length;
|
|
const errs=d.recent.reduce(function(a,r){return a+((r.errors&&r.errors.length)||0)},0);
|
|
document.getElementById('s-errors').textContent=errs;
|
|
const durations=d.recent.filter(function(r){return r.duration}).map(function(r){return r.duration});
|
|
document.getElementById('s-avgtime').textContent=durations.length?fmt(durations.reduce(function(a,b){return a+b},0)/durations.length):'—';
|
|
renderActive(d.active);
|
|
renderRecent(d.recent);
|
|
}catch(e){console.error('Monitor poll error:',e)}
|
|
}
|
|
poll();
|
|
setInterval(poll,3000);
|
|
</script>
|
|
</body></html>"""
|
|
|
|
|
|
# ─── HISTORY ROUTES ────────────────────────────────────────────
|
|
|
|
@app.route("/api/runs")
|
|
@login_required
|
|
def get_runs():
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT id, mode, prompt, models_used, created_at FROM team_runs ORDER BY created_at DESC LIMIT 50")
|
|
runs = cur.fetchall()
|
|
for r in runs:
|
|
r["created_at"] = r["created_at"].isoformat()
|
|
return jsonify({"runs": runs})
|
|
except Exception as e:
|
|
return jsonify({"runs": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/api/runs/<int:run_id>")
|
|
@login_required
|
|
def get_run(run_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM team_runs WHERE id = %s", (run_id,))
|
|
run = cur.fetchone()
|
|
if not run:
|
|
return jsonify({"error": "not found"}), 404
|
|
run["created_at"] = run["created_at"].isoformat()
|
|
return jsonify(run)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/runs/<int:run_id>", methods=["DELETE"])
|
|
@login_required
|
|
def delete_run(run_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("DELETE FROM team_runs WHERE id = %s", (run_id,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/pipelines")
|
|
@login_required
|
|
def get_pipelines():
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT id, pipeline, topic, status, models_used, duration_ms, created_at FROM pipeline_runs ORDER BY created_at DESC LIMIT 50")
|
|
runs = cur.fetchall()
|
|
for r in runs:
|
|
r["created_at"] = r["created_at"].isoformat() if r["created_at"] else None
|
|
return jsonify({"pipelines": runs})
|
|
except Exception as e:
|
|
return jsonify({"pipelines": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/api/pipelines/<int:pid>")
|
|
@login_required
|
|
def get_pipeline(pid):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM pipeline_runs WHERE id = %s", (pid,))
|
|
run = cur.fetchone()
|
|
if not run:
|
|
return jsonify({"error": "not found"}), 404
|
|
run["created_at"] = run["created_at"].isoformat() if run["created_at"] else None
|
|
run["completed_at"] = run["completed_at"].isoformat() if run["completed_at"] else None
|
|
return jsonify(run)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
# ─── LAB: RATCHET ENGINE ──────────────────────────────────────
|
|
|
|
_lab_threads = {} # experiment_id -> thread
|
|
_lab_streams = {} # experiment_id -> [queue, ...]
|
|
|
|
def _lab_emit(exp_id, data):
|
|
for q in _lab_streams.get(exp_id, []):
|
|
q.append(data)
|
|
|
|
|
|
def _score_response(response, expected, metric, judge_model=None):
|
|
if metric == "accuracy":
|
|
if not expected:
|
|
return 5.0
|
|
resp_lower = response.lower().strip()
|
|
exp_lower = expected.lower().strip()
|
|
if exp_lower in resp_lower:
|
|
return 10.0
|
|
if any(w in resp_lower for w in exp_lower.split()):
|
|
return 5.0
|
|
return 1.0
|
|
elif metric == "speed":
|
|
return 10.0 # speed scored externally by duration
|
|
elif metric == "quality" and judge_model:
|
|
try:
|
|
judgment = query_model(judge_model,
|
|
f"Rate this response 1-10 for quality, relevance, and completeness.\n\n"
|
|
f"EXPECTED: {expected or 'No expected output specified'}\n\n"
|
|
f"RESPONSE: {response[:1500]}\n\n"
|
|
f"Return ONLY a number 1-10, nothing else.")
|
|
import re
|
|
m = re.search(r'\b(\d+)\b', judgment)
|
|
return min(float(m.group(1)), 10.0) if m else 5.0
|
|
except Exception:
|
|
return 5.0
|
|
return 5.0
|
|
|
|
|
|
def _ratchet_loop(exp_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM lab_experiments WHERE id = %s", (exp_id,))
|
|
exp = cur.fetchone()
|
|
if not exp:
|
|
return
|
|
|
|
eval_cases = exp["eval_cases"] or []
|
|
models_pool = exp["models_pool"] or []
|
|
metric = exp["metric"] or "quality"
|
|
objective = exp["objective"] or "Improve response quality"
|
|
mutable = exp["mutable_config"] or {
|
|
"system_prompt": "You are a helpful assistant.",
|
|
"temperature": 0.7,
|
|
"model": models_pool[0] if models_pool else "llama3.2:latest",
|
|
}
|
|
best_config = exp["best_config"] or json.loads(json.dumps(mutable))
|
|
best_score = exp["best_score"] or 0
|
|
trial_num = exp["total_trials"] or 0
|
|
|
|
# Pick meta-model (largest in pool)
|
|
meta_model = models_pool[-1] if models_pool else "qwen2.5:latest"
|
|
judge_model = models_pool[0] if models_pool else "llama3.2:latest"
|
|
|
|
while True:
|
|
# Check if still running
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT status FROM lab_experiments WHERE id = %s", (exp_id,))
|
|
row = cur.fetchone()
|
|
if not row or row[0] != "running":
|
|
break
|
|
|
|
trial_num += 1
|
|
trial_start = time.time()
|
|
_lab_emit(exp_id, {"type": "status", "trial": trial_num, "message": "Proposing change..."})
|
|
|
|
# Step 1: Meta-model proposes a change
|
|
history_hint = ""
|
|
if trial_num > 1:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT config_diff, avg_score, improved FROM lab_trials WHERE experiment_id = %s ORDER BY id DESC LIMIT 5", (exp_id,))
|
|
recent = cur.fetchall()
|
|
if recent:
|
|
history_hint = "\n\nRecent trials:\n" + "\n".join(
|
|
f" {'KEPT' if r['improved'] else 'DISCARDED'} (score {r['avg_score']:.1f}): {r['config_diff']}" for r in recent)
|
|
|
|
propose_prompt = (
|
|
f"You are optimizing an LLM pipeline. Objective: {objective}\n"
|
|
f"Metric: {metric} (higher is better, max 10)\n"
|
|
f"Current best score: {best_score:.1f}/10\n\n"
|
|
f"Current config:\n{json.dumps(mutable, indent=2)}\n\n"
|
|
f"Available models: {models_pool}\n"
|
|
f"Eval cases: {len(eval_cases)}\n"
|
|
f"{history_hint}\n\n"
|
|
f"Suggest exactly ONE change to improve the score. Return ONLY valid JSON with the FULL updated config. "
|
|
f"Keys: system_prompt (string), temperature (0.0-1.5), model (string from available models). "
|
|
f"Be creative but focused. Change only one thing at a time."
|
|
)
|
|
try:
|
|
proposal_raw = query_model(meta_model, propose_prompt)
|
|
import re
|
|
json_match = re.search(r'\{[\s\S]*\}', proposal_raw)
|
|
if json_match:
|
|
proposed = json.loads(json_match.group())
|
|
# Validate keys
|
|
if "system_prompt" not in proposed:
|
|
proposed["system_prompt"] = mutable.get("system_prompt", "")
|
|
if "temperature" not in proposed:
|
|
proposed["temperature"] = mutable.get("temperature", 0.7)
|
|
if "model" not in proposed:
|
|
proposed["model"] = mutable.get("model", models_pool[0])
|
|
else:
|
|
proposed = mutable
|
|
except Exception:
|
|
proposed = mutable
|
|
|
|
# Describe the diff
|
|
diffs = []
|
|
for k in set(list(mutable.keys()) + list(proposed.keys())):
|
|
old_v = mutable.get(k)
|
|
new_v = proposed.get(k)
|
|
if old_v != new_v:
|
|
if k == "system_prompt":
|
|
diffs.append(f"system_prompt changed ({len(str(old_v))} → {len(str(new_v))} chars)")
|
|
else:
|
|
diffs.append(f"{k}: {old_v} → {new_v}")
|
|
config_diff = "; ".join(diffs) if diffs else "no change"
|
|
_lab_emit(exp_id, {"type": "status", "trial": trial_num, "message": f"Testing: {config_diff[:80]}"})
|
|
|
|
# Step 2: Run eval cases with proposed config
|
|
trial_scores = []
|
|
model_to_use = proposed.get("model", models_pool[0] if models_pool else "llama3.2:latest")
|
|
sys_prompt = proposed.get("system_prompt", "")
|
|
|
|
for ci, case in enumerate(eval_cases):
|
|
inp = case.get("input", "")
|
|
expected = case.get("expected", "")
|
|
full_prompt = f"{sys_prompt}\n\n{inp}" if sys_prompt else inp
|
|
try:
|
|
resp = query_model(model_to_use, full_prompt)
|
|
score = _score_response(resp, expected, metric, judge_model if metric == "quality" else None)
|
|
trial_scores.append({"input": inp[:100], "score": score, "response": resp[:300]})
|
|
except Exception as e:
|
|
trial_scores.append({"input": inp[:100], "score": 0, "error": str(e)})
|
|
|
|
avg_score = sum(s["score"] for s in trial_scores) / max(len(trial_scores), 1)
|
|
duration_ms = int((time.time() - trial_start) * 1000)
|
|
improved = avg_score > best_score
|
|
|
|
# Step 3: Ratchet
|
|
if improved:
|
|
best_score = avg_score
|
|
best_config = json.loads(json.dumps(proposed))
|
|
mutable = json.loads(json.dumps(proposed))
|
|
else:
|
|
mutable = json.loads(json.dumps(best_config))
|
|
|
|
# Save trial
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""INSERT INTO lab_trials (experiment_id, trial_num, config_diff, config_snapshot, scores, avg_score, improved, duration_ms)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
|
(exp_id, trial_num, config_diff, json.dumps(proposed), json.dumps(trial_scores), avg_score, improved, duration_ms)
|
|
)
|
|
cur.execute(
|
|
"""UPDATE lab_experiments SET total_trials = %s, best_score = %s, best_config = %s, mutable_config = %s,
|
|
improvements = improvements + %s WHERE id = %s""",
|
|
(trial_num, best_score, json.dumps(best_config), json.dumps(mutable), 1 if improved else 0, exp_id)
|
|
)
|
|
conn.commit()
|
|
|
|
_lab_emit(exp_id, {
|
|
"type": "trial", "trial": trial_num, "score": round(avg_score, 2),
|
|
"best": round(best_score, 2), "improved": improved, "diff": config_diff[:100],
|
|
"duration_ms": duration_ms
|
|
})
|
|
|
|
# Done
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'paused' WHERE id = %s AND status = 'running'", (exp_id,))
|
|
conn.commit()
|
|
_lab_emit(exp_id, {"type": "done"})
|
|
|
|
except Exception as e:
|
|
_lab_emit(exp_id, {"type": "error", "message": str(e)})
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'error' WHERE id = %s", (exp_id,))
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ─── LAB API ROUTES ───────────────────────────────────────────
|
|
|
|
@app.route("/lab")
|
|
@admin_required
|
|
def lab_page():
|
|
return render_template_string(LAB_HTML)
|
|
|
|
|
|
@app.route("/api/lab/experiments", methods=["GET"])
|
|
@admin_required
|
|
def lab_list():
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT id, name, status, metric, best_score, total_trials, improvements, models_pool, created_at FROM lab_experiments ORDER BY created_at DESC")
|
|
rows = cur.fetchall()
|
|
for r in rows:
|
|
r["created_at"] = r["created_at"].isoformat()
|
|
return jsonify({"experiments": rows})
|
|
|
|
|
|
@app.route("/api/lab/experiments", methods=["POST"])
|
|
@admin_required
|
|
def lab_create():
|
|
d = request.json
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""INSERT INTO lab_experiments (name, objective, metric, eval_cases, mutable_config, best_config, models_pool)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id""",
|
|
(d["name"], d.get("objective", ""), d.get("metric", "quality"),
|
|
json.dumps(d.get("eval_cases", [])),
|
|
json.dumps(d.get("mutable_config", {"system_prompt": "You are a helpful assistant.", "temperature": 0.7, "model": ""})),
|
|
json.dumps(d.get("mutable_config", {"system_prompt": "You are a helpful assistant.", "temperature": 0.7, "model": ""})),
|
|
d.get("models_pool", []))
|
|
)
|
|
eid = cur.fetchone()[0]
|
|
conn.commit()
|
|
return jsonify({"id": eid})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>", methods=["GET"])
|
|
@admin_required
|
|
def lab_get(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM lab_experiments WHERE id = %s", (eid,))
|
|
exp = cur.fetchone()
|
|
if not exp:
|
|
return jsonify({"error": "not found"}), 404
|
|
exp["created_at"] = exp["created_at"].isoformat()
|
|
cur.execute("SELECT * FROM lab_trials WHERE experiment_id = %s ORDER BY trial_num", (eid,))
|
|
exp["trials"] = cur.fetchall()
|
|
for t in exp["trials"]:
|
|
t["created_at"] = t["created_at"].isoformat()
|
|
return jsonify(exp)
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>", methods=["PUT"])
|
|
@admin_required
|
|
def lab_update(eid):
|
|
d = request.json
|
|
sets, vals = [], []
|
|
for k in ["name", "objective", "metric"]:
|
|
if k in d:
|
|
sets.append(f"{k} = %s")
|
|
vals.append(d[k])
|
|
for k in ["eval_cases", "mutable_config"]:
|
|
if k in d:
|
|
sets.append(f"{k} = %s")
|
|
vals.append(json.dumps(d[k]))
|
|
if "models_pool" in d:
|
|
sets.append("models_pool = %s")
|
|
vals.append(d["models_pool"])
|
|
if not sets:
|
|
return jsonify({"ok": True})
|
|
vals.append(eid)
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(f"UPDATE lab_experiments SET {', '.join(sets)} WHERE id = %s", vals)
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/start", methods=["POST"])
|
|
@admin_required
|
|
def lab_start(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'running' WHERE id = %s", (eid,))
|
|
conn.commit()
|
|
if eid in _lab_threads and _lab_threads[eid].is_alive():
|
|
return jsonify({"ok": True, "message": "Already running"})
|
|
t = threading.Thread(target=_ratchet_loop, args=(eid,), daemon=True)
|
|
_lab_threads[eid] = t
|
|
t.start()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/pause", methods=["POST"])
|
|
@admin_required
|
|
def lab_pause(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'paused' WHERE id = %s", (eid,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/reset", methods=["POST"])
|
|
@admin_required
|
|
def lab_reset(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'idle', total_trials = 0, improvements = 0, best_score = 0, best_config = mutable_config WHERE id = %s", (eid,))
|
|
cur.execute("DELETE FROM lab_trials WHERE experiment_id = %s", (eid,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/delete", methods=["DELETE"])
|
|
@admin_required
|
|
def lab_delete(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("DELETE FROM lab_experiments WHERE id = %s", (eid,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/stream")
|
|
@admin_required
|
|
def lab_stream(eid):
|
|
q = []
|
|
_lab_streams.setdefault(eid, []).append(q)
|
|
def generate():
|
|
try:
|
|
while True:
|
|
if q:
|
|
data = q.pop(0)
|
|
yield f"data: {json.dumps(data)}\n\n"
|
|
if data.get("type") == "done":
|
|
break
|
|
else:
|
|
time.sleep(0.5)
|
|
yield ": keepalive\n\n"
|
|
finally:
|
|
_lab_streams.get(eid, []).remove(q) if q in _lab_streams.get(eid, []) else None
|
|
return Response(generate(), mimetype="text/event-stream",
|
|
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"])
|
|
@login_required
|
|
def run_team():
|
|
ip = request.remote_addr
|
|
if rate_limited(ip):
|
|
return jsonify({"error": "Rate limit exceeded. Wait a minute."}), 429
|
|
|
|
config = request.json
|
|
if not config:
|
|
return jsonify({"error": "Request body required"}), 400
|
|
|
|
mode = config.get("mode", "")
|
|
if not mode:
|
|
return jsonify({"error": "Mode is required"}), 400
|
|
|
|
prompt = config.get("prompt", "").strip()
|
|
if not prompt:
|
|
return jsonify({"error": "Prompt cannot be empty"}), 400
|
|
|
|
RUNNERS = {
|
|
"brainstorm": run_brainstorm, "pipeline": run_pipeline, "debate": run_debate,
|
|
"validator": run_validator, "roundrobin": run_roundrobin, "redteam": run_redteam,
|
|
"consensus": run_consensus, "codereview": run_codereview, "ladder": run_ladder,
|
|
"tournament": run_tournament, "evolution": run_evolution, "blindassembly": run_blindassembly,
|
|
"staircase": run_staircase, "drift": run_drift, "mesh": run_mesh,
|
|
"hallucination": run_hallucination, "timeloop": run_timeloop,
|
|
"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 = []
|
|
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)
|
|
|
|
return Response(generate(), mimetype="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"})
|
|
|
|
|
|
# ─── ORIGINAL 10 MODES ────────────────────────────────────────
|
|
|
|
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 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():
|
|
parts.append((f"[{m}]:", cap_response(r), 3))
|
|
sp = build_context(parts, synthesizer)
|
|
try:
|
|
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):
|
|
steps, current = config.get("steps", []), config["prompt"]
|
|
yield sse({"type": "clear"})
|
|
for i, step in enumerate(steps):
|
|
model = step["model"]
|
|
yield sse({"type": "status", "message": f"Step {i+1}/{len(steps)}: {model}..."})
|
|
try:
|
|
prompt = step["instruction"].replace("{input}", cap_response(current))
|
|
current = safe_query(model, prompt)
|
|
yield sse({"type": "response", "model": model, "text": current, "role": f"step {i+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": model, "text": str(e), "role": "error"}); break
|
|
|
|
|
|
def run_debate(config):
|
|
prompt = config.get("prompt", "")
|
|
d1, d2, judge = config.get("debater1"), config.get("debater2"), config.get("judge")
|
|
if not all([d1, d2, judge]):
|
|
yield sse({"type": "response", "model": "system", "text": "Debate mode requires 'debater1', 'debater2', and 'judge' model parameters.", "role": "error"})
|
|
yield sse({"type": "done"})
|
|
return
|
|
rounds = config.get("rounds", 2)
|
|
yield sse({"type": "clear"})
|
|
history = []
|
|
for m in [d1, d2]:
|
|
yield sse({"type": "status", "message": f"{m} opening..."})
|
|
try:
|
|
r = safe_query(m, f"Give your position on: {prompt}")
|
|
history.append((m, r)); yield sse({"type": "response", "model": m, "text": r, "role": "opening"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
for rd in range(rounds):
|
|
for i, m in enumerate([d1, d2]):
|
|
other = [d1, d2][1-i]
|
|
other_last = [h[1] for h in history if h[0] == other][-1]
|
|
yield sse({"type": "status", "message": f"Round {rd+1}: {m}..."})
|
|
try:
|
|
rebuttal_prompt = build_context([
|
|
("Topic:", prompt, 1),
|
|
(f"Opponent ({other}) said:", cap_response(other_last), 2),
|
|
("INSTRUCTION:", "Rebuttal or concede:", 1),
|
|
], m)
|
|
r = safe_query(m, rebuttal_prompt)
|
|
history.append((m, r)); yield sse({"type": "response", "model": m, "text": r, "role": f"round {rd+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
yield sse({"type": "status", "message": f"{judge} judging..."})
|
|
parts = [("Topic:", prompt, 1), ("INSTRUCTION:", "Judge: who won and why?", 1)]
|
|
for m, t in history:
|
|
parts.append((f"[{m}]:", cap_response(t), 3))
|
|
jp = build_context(parts, judge)
|
|
try:
|
|
yield sse({"type": "response", "model": judge, "text": safe_query(judge, jp), "role": "judge"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": judge, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_validator(config):
|
|
prompt, answerer, validators = config["prompt"], config["answerer"], config.get("validators", [])
|
|
yield sse({"type": "clear"})
|
|
yield sse({"type": "status", "message": f"{answerer} answering..."})
|
|
try:
|
|
answer = query_model(answerer, prompt)
|
|
yield sse({"type": "response", "model": answerer, "text": answer, "role": "answer"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": answerer, "text": str(e), "role": "error"}); return
|
|
yield sse({"type": "status", "message": f"Validating with {len(validators)} models..."})
|
|
vp = f"QUESTION: {prompt}\n\nANSWER:\n{answer}\n\nFact-check. Score 1-10 for accuracy, completeness, clarity. Flag errors."
|
|
results = parallel_query(validators, vp)
|
|
for m, r in results.items():
|
|
yield sse({"type": "response", "model": m, "text": r, "role": "validator"})
|
|
|
|
|
|
def run_roundrobin(config):
|
|
prompt, models, cycles = config["prompt"], config.get("models", []), config.get("cycles", 2)
|
|
yield sse({"type": "clear"})
|
|
if not models: return
|
|
yield sse({"type": "status", "message": f"{models[0]} drafting..."})
|
|
try:
|
|
current = query_model(models[0], f"Answer:\n\n{prompt}")
|
|
yield sse({"type": "response", "model": models[0], "text": current, "role": "draft"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": models[0], "text": str(e), "role": "error"}); return
|
|
for cycle in range(cycles):
|
|
start = 1 if cycle == 0 else 0
|
|
for i in range(start, len(models)):
|
|
m = models[i]
|
|
yield sse({"type": "status", "message": f"Cycle {cycle+1}: {m}..."})
|
|
try:
|
|
current = query_model(m, f"Question: {prompt}\n\nCurrent answer:\n{current}\n\nImprove it. Return full improved answer.")
|
|
is_last = (cycle == cycles-1) and (i == len(models)-1)
|
|
yield sse({"type": "response", "model": m, "text": current, "role": "final" if is_last else f"cycle {cycle+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_redteam(config):
|
|
prompt = config.get("prompt", "")
|
|
author, attacker, patcher = config.get("author"), config.get("attacker"), config.get("patcher")
|
|
if not all([author, attacker, patcher]):
|
|
yield sse({"type": "response", "model": "system", "text": "Red team mode requires 'author', 'attacker', and 'patcher' model parameters.", "role": "error"})
|
|
yield sse({"type": "done"})
|
|
return
|
|
rounds = config.get("rounds", 2)
|
|
yield sse({"type": "clear"})
|
|
yield sse({"type": "status", "message": f"{author} writing..."})
|
|
try:
|
|
current = query_model(author, prompt)
|
|
yield sse({"type": "response", "model": author, "text": current, "role": "author"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": author, "text": str(e), "role": "error"}); return
|
|
for r in range(rounds):
|
|
yield sse({"type": "status", "message": f"Round {r+1}: {attacker} attacking..."})
|
|
try:
|
|
attack = query_model(attacker, f"Question: {prompt}\n\nAnswer:\n{current}\n\nRED TEAM: find every flaw, error, weakness, edge case. Be aggressive.")
|
|
yield sse({"type": "response", "model": attacker, "text": attack, "role": f"attack {r+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": attacker, "text": str(e), "role": "error"}); continue
|
|
yield sse({"type": "status", "message": f"Round {r+1}: {patcher} fixing..."})
|
|
try:
|
|
current = query_model(patcher, f"Question: {prompt}\n\nAnswer:\n{current}\n\nFlaws found:\n{attack}\n\nFix ALL issues. Return complete improved answer.")
|
|
yield sse({"type": "response", "model": patcher, "text": current, "role": "patcher" if r == rounds-1 else f"patch {r+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": patcher, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_consensus(config):
|
|
prompt, models, max_rounds = config["prompt"], config.get("models", []), config.get("max_rounds", 3)
|
|
yield sse({"type": "clear"})
|
|
if not models: return
|
|
yield sse({"type": "status", "message": f"Round 1: {len(models)} models answering..."})
|
|
responses = parallel_query(models, prompt)
|
|
for m, r in responses.items():
|
|
yield sse({"type": "response", "model": m, "text": r, "role": "round 1"})
|
|
for rd in range(2, max_rounds + 1):
|
|
yield sse({"type": "status", "message": f"Round {rd}: reviewing each other..."})
|
|
new = {}
|
|
for m in models:
|
|
parts = [("Question:", prompt, 1),
|
|
("Your answer:", cap_response(responses.get(m, "")), 2),
|
|
("INSTRUCTION:", "Revise considering other perspectives. Adopt good points, defend if right.", 1)]
|
|
for o, r in responses.items():
|
|
if o != m:
|
|
parts.append((f"[{o}]:", cap_response(r), 3))
|
|
ctx = build_context(parts, m)
|
|
try:
|
|
new[m] = safe_query(m, ctx)
|
|
yield sse({"type": "response", "model": m, "text": new[m], "role": "consensus" if rd == max_rounds else f"round {rd}"})
|
|
except Exception as e:
|
|
new[m] = responses.get(m, ""); yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
responses = new
|
|
|
|
|
|
def run_codereview(config):
|
|
prompt = config.get("prompt", "")
|
|
coder, reviewer, tester = config.get("coder"), config.get("reviewer"), config.get("tester")
|
|
if not all([coder, reviewer, tester]):
|
|
yield sse({"type": "response", "model": "system", "text": "Code review mode requires 'coder', 'reviewer', and 'tester' model parameters.", "role": "error"})
|
|
yield sse({"type": "done"})
|
|
return
|
|
yield sse({"type": "clear"})
|
|
yield sse({"type": "status", "message": f"{coder} coding..."})
|
|
try:
|
|
code = query_model(coder, f"Write code for this task. Only output code with brief comments.\n\n{prompt}")
|
|
yield sse({"type": "response", "model": coder, "text": code, "role": "coder"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": coder, "text": str(e), "role": "error"}); return
|
|
yield sse({"type": "status", "message": f"{reviewer} reviewing..."})
|
|
try:
|
|
review = query_model(reviewer, f"Task: {prompt}\n\nCode:\n{code}\n\nReview: bugs, security, performance, style, edge cases. Provide corrected code if needed.")
|
|
yield sse({"type": "response", "model": reviewer, "text": review, "role": "reviewer"})
|
|
except Exception as e:
|
|
review = ""; yield sse({"type": "response", "model": reviewer, "text": str(e), "role": "error"})
|
|
yield sse({"type": "status", "message": f"{tester} testing..."})
|
|
try:
|
|
tests = query_model(tester, f"Task: {prompt}\n\nCode:\n{code}\n\nReview:\n{review}\n\nWrite comprehensive unit tests. Cover normal, edge, error cases.")
|
|
yield sse({"type": "response", "model": tester, "text": tests, "role": "tester"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": tester, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_ladder(config):
|
|
prompt, models = config["prompt"], config.get("models", [])
|
|
levels = [
|
|
("Child (5yo)", "Explain to a 5-year-old. Very simple words, short sentences, fun analogies."),
|
|
("Teenager", "Explain to a 15-year-old. Everyday language, relatable examples, some technical terms."),
|
|
("College Student", "College level. Proper terminology, theory, structured explanation."),
|
|
("Professional", "Professional level. Technical language, real-world applications, trade-offs."),
|
|
("PhD Expert", "PhD/expert level. Nuanced details, current research, math if relevant, edge cases."),
|
|
]
|
|
yield sse({"type": "clear"})
|
|
for i, (name, instr) in enumerate(levels):
|
|
m = models[i % len(models)] if models else "qwen2.5"
|
|
yield sse({"type": "status", "message": f"Level {i+1}/5: {name} ({m})..."})
|
|
try:
|
|
yield sse({"type": "response", "model": m, "text": query_model(m, f"{instr}\n\nQuestion: {prompt}"), "role": name})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_tournament(config):
|
|
prompt, models, judge = config["prompt"], config.get("models", []), config.get("judge", "qwen2.5")
|
|
yield sse({"type": "clear"})
|
|
yield sse({"type": "status", "message": f"{len(models)} models competing..."})
|
|
responses = parallel_query(models, prompt)
|
|
for m, r in responses.items():
|
|
yield sse({"type": "response", "model": m, "text": r, "role": "competitor"})
|
|
if len(responses) < 2: return
|
|
yield sse({"type": "status", "message": f"{judge} ranking..."})
|
|
parts = [("Question:", prompt, 1),
|
|
("INSTRUCTION:", "Rank all from best to worst. Score 1-10 each. Then refine the winner into the ultimate answer.", 1)]
|
|
for m, r in responses.items():
|
|
parts.append((f"[{m}]:", cap_response(r), 3))
|
|
jp = build_context(parts, judge)
|
|
try:
|
|
yield sse({"type": "response", "model": judge, "text": safe_query(judge, jp), "role": "verdict"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": judge, "text": str(e), "role": "error"})
|
|
|
|
|
|
# ─── NEW 7 MODES ──────────────────────────────────────────────
|
|
|
|
def run_evolution(config):
|
|
"""Genetic algorithm: generate, score, breed, mutate across generations."""
|
|
prompt, models = config["prompt"], config.get("models", [])
|
|
generations = config.get("generations", 3)
|
|
judge = config.get("judge", models[0] if models else "qwen2.5")
|
|
yield sse({"type": "clear"})
|
|
|
|
if not models: return
|
|
|
|
# Gen 0: each model generates an answer
|
|
yield sse({"type": "status", "message": "Generation 0: spawning initial population..."})
|
|
population = parallel_query(models, prompt)
|
|
for m, r in population.items():
|
|
yield sse({"type": "response", "model": m, "text": r, "role": "gen 0"})
|
|
|
|
for gen in range(1, generations + 1):
|
|
# Fitness scoring
|
|
yield sse({"type": "status", "message": f"Generation {gen}: fitness evaluation..."})
|
|
score_prompt = f"Question: {prompt}\n\nRate each answer 1-100. Return ONLY a JSON object like {{\"model_name\": score}}.\n\n"
|
|
for m, r in population.items():
|
|
score_prompt += f"[{m}]: {r.strip()}\n\n"
|
|
try:
|
|
scores_raw = query_model(judge, score_prompt)
|
|
yield sse({"type": "response", "model": judge, "text": scores_raw, "role": f"fitness gen {gen}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": judge, "text": str(e), "role": "error"}); continue
|
|
|
|
# Breed: take top 2 answers, ask a model to combine them
|
|
pop_list = list(population.items())
|
|
if len(pop_list) < 2: break
|
|
|
|
parent1, parent2 = pop_list[0], pop_list[1]
|
|
yield sse({"type": "status", "message": f"Generation {gen}: breeding + mutating..."})
|
|
|
|
new_population = {}
|
|
for m in models:
|
|
breed_prompt = (
|
|
f"Question: {prompt}\n\n"
|
|
f"Parent A ({parent1[0]}):\n{parent1[1].strip()}\n\n"
|
|
f"Parent B ({parent2[0]}):\n{parent2[1].strip()}\n\n"
|
|
f"You are {m}. Breed these two answers: take the best parts of each parent, "
|
|
f"combine them, then MUTATE by adding one novel insight or improvement. "
|
|
f"Return your evolved answer."
|
|
)
|
|
try:
|
|
offspring = query_model(m, breed_prompt)
|
|
new_population[m] = offspring
|
|
is_last = gen == generations
|
|
yield sse({"type": "response", "model": m, "text": offspring, "role": "final" if is_last else f"gen {gen}"})
|
|
except Exception as e:
|
|
new_population[m] = population.get(m, "")
|
|
yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
|
|
population = new_population
|
|
|
|
|
|
def run_blindassembly(config):
|
|
"""Split question into parts, each model answers blind, assembler stitches."""
|
|
prompt, models = config["prompt"], config.get("models", [])
|
|
assembler = config.get("assembler", models[0] if models else "qwen2.5")
|
|
yield sse({"type": "clear"})
|
|
|
|
if not models: return
|
|
n = len(models)
|
|
|
|
# Step 1: Decompose the question
|
|
yield sse({"type": "status", "message": "Decomposing question into sub-tasks..."})
|
|
decompose_prompt = (
|
|
f"Split this question into exactly {n} independent sub-parts that together fully answer it. "
|
|
f"Return ONLY a numbered list, one sub-question per line. No other text.\n\n"
|
|
f"Question: {prompt}"
|
|
)
|
|
try:
|
|
parts_raw = query_model(assembler, decompose_prompt)
|
|
yield sse({"type": "response", "model": assembler, "text": parts_raw, "role": "decomposer"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": assembler, "text": str(e), "role": "error"}); return
|
|
|
|
# Parse parts
|
|
parts = [line.strip() for line in parts_raw.strip().split("\n") if line.strip() and any(c.isalpha() for c in line)]
|
|
while len(parts) < n:
|
|
parts.append(f"Additional aspect of: {prompt}")
|
|
parts = parts[:n]
|
|
|
|
# Step 2: Each model answers their part BLIND
|
|
yield sse({"type": "status", "message": f"Sending {n} sub-tasks to models (blind)..."})
|
|
fragments = {}
|
|
with ThreadPoolExecutor(max_workers=n) as pool:
|
|
futures = {}
|
|
for i, m in enumerate(models):
|
|
blind_prompt = (
|
|
f"Answer ONLY this specific sub-question. Do not address anything else.\n\n"
|
|
f"Sub-question: {parts[i]}"
|
|
)
|
|
futures[pool.submit(query_model, m, blind_prompt)] = (m, parts[i])
|
|
|
|
for future in as_completed(futures):
|
|
m, part = futures[future]
|
|
try:
|
|
fragments[m] = {"part": part, "answer": future.result()}
|
|
yield sse({"type": "response", "model": m, "text": f"SUB-TASK: {part}\n\nANSWER:\n{fragments[m]['answer']}", "role": "blind worker"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
|
|
# Step 3: Assemble
|
|
yield sse({"type": "status", "message": f"{assembler} assembling blind fragments..."})
|
|
assemble_prompt = f"Original question: {prompt}\n\nMultiple models each answered a sub-part WITHOUT seeing each other:\n\n"
|
|
for m, data in fragments.items():
|
|
assemble_prompt += f"[{m}] (sub-task: {data['part']}):\n{data['answer'].strip()}\n\n"
|
|
assemble_prompt += "Stitch these fragments into ONE coherent, complete answer. Fill any gaps. Remove contradictions."
|
|
|
|
try:
|
|
yield sse({"type": "response", "model": assembler, "text": query_model(assembler, assemble_prompt), "role": "assembler"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": assembler, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_staircase(config):
|
|
"""Devil's Staircase: each round adds a new constraint."""
|
|
prompt = config["prompt"]
|
|
answerer = config["answerer"]
|
|
challenger = config["challenger"]
|
|
steps = config.get("steps", 4)
|
|
yield sse({"type": "clear"})
|
|
|
|
# Initial answer
|
|
yield sse({"type": "status", "message": f"{answerer} answering..."})
|
|
try:
|
|
current = query_model(answerer, prompt)
|
|
yield sse({"type": "response", "model": answerer, "text": current, "role": "initial answer"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": answerer, "text": str(e), "role": "error"}); return
|
|
|
|
constraints = []
|
|
for s in range(steps):
|
|
# Challenger adds a constraint
|
|
yield sse({"type": "status", "message": f"Step {s+1}: {challenger} adding constraint..."})
|
|
constraint_prompt = (
|
|
f"Original question: {prompt}\n\n"
|
|
f"Current answer:\n{current}\n\n"
|
|
f"Existing constraints: {constraints if constraints else 'None yet'}\n\n"
|
|
f"Add ONE new realistic constraint, complication, or edge case that the current answer doesn't handle. "
|
|
f"Make it specific and challenging but plausible. State ONLY the new constraint, nothing else."
|
|
)
|
|
try:
|
|
new_constraint = query_model(challenger, constraint_prompt)
|
|
constraints.append(new_constraint.strip())
|
|
yield sse({"type": "response", "model": challenger, "text": new_constraint, "role": f"constraint {s+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": challenger, "text": str(e), "role": "error"}); continue
|
|
|
|
# Answerer must adapt
|
|
yield sse({"type": "status", "message": f"Step {s+1}: {answerer} adapting..."})
|
|
adapt_prompt = (
|
|
f"Original question: {prompt}\n\n"
|
|
f"ALL constraints you must satisfy:\n" +
|
|
"\n".join(f" {i+1}. {c}" for i, c in enumerate(constraints)) +
|
|
f"\n\nYour previous answer:\n{current}\n\n"
|
|
f"Rewrite your answer to handle ALL constraints. Return the complete updated answer."
|
|
)
|
|
try:
|
|
current = query_model(answerer, adapt_prompt)
|
|
is_last = s == steps - 1
|
|
yield sse({"type": "response", "model": answerer, "text": current, "role": "final" if is_last else f"adapted {s+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": answerer, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_drift(config):
|
|
"""Same prompt N times to same model, analyze variance."""
|
|
prompt = config["prompt"]
|
|
target = config["target"]
|
|
samples = config.get("samples", 5)
|
|
analyzer = config["analyzer"]
|
|
yield sse({"type": "clear"})
|
|
|
|
yield sse({"type": "status", "message": f"Sampling {target} {samples} times..."})
|
|
results = []
|
|
for i in range(samples):
|
|
yield sse({"type": "status", "message": f"Sample {i+1}/{samples}..."})
|
|
try:
|
|
r = query_model(target, prompt)
|
|
results.append(r)
|
|
yield sse({"type": "response", "model": target, "text": r, "role": f"sample {i+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": target, "text": str(e), "role": "error"})
|
|
|
|
if len(results) < 2: return
|
|
|
|
# Analyze
|
|
yield sse({"type": "status", "message": f"{analyzer} analyzing drift..."})
|
|
analysis_prompt = (
|
|
f"Question asked: {prompt}\n\n"
|
|
f"The model '{target}' was asked this same question {len(results)} times. Here are all responses:\n\n"
|
|
)
|
|
for i, r in enumerate(results):
|
|
analysis_prompt += f"--- Sample {i+1} ---\n{r.strip()}\n\n"
|
|
|
|
analysis_prompt += (
|
|
"DRIFT ANALYSIS:\n"
|
|
"1. What claims/facts are CONSISTENT across all samples? (HIGH CONFIDENCE)\n"
|
|
"2. What claims VARY between samples? (LOW CONFIDENCE - possible hallucination)\n"
|
|
"3. What is completely CONTRADICTED between samples? (UNRELIABLE)\n"
|
|
"4. Give an overall confidence score 1-10 for the model's answer to this question.\n"
|
|
"5. Provide the 'true' answer using only high-confidence claims."
|
|
)
|
|
try:
|
|
yield sse({"type": "response", "model": analyzer, "text": query_model(analyzer, analysis_prompt), "role": "analyzer"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": analyzer, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_mesh(config):
|
|
"""Each model answers as a different stakeholder."""
|
|
prompt, models = config["prompt"], config.get("models", [])
|
|
synthesizer = config.get("synthesizer", models[0] if models else "qwen2.5")
|
|
yield sse({"type": "clear"})
|
|
|
|
perspectives = [
|
|
("CEO / Business Leader", "You are a CEO. Answer from a business strategy perspective: ROI, market impact, competitive advantage, risk."),
|
|
("Software Engineer", "You are a senior engineer. Answer from a technical perspective: architecture, implementation, scalability, tech debt."),
|
|
("End User / Customer", "You are an end user/customer. Answer from a usability perspective: experience, pain points, what you actually need."),
|
|
("Regulator / Legal", "You are a regulator/legal advisor. Answer from a compliance perspective: laws, regulations, liability, ethics, privacy."),
|
|
("Competitor", "You are a competitor analyzing this. What threats/opportunities does this create? What would you do differently?"),
|
|
]
|
|
|
|
if not models: return
|
|
|
|
responses = {}
|
|
for i, (role_name, instruction) in enumerate(perspectives):
|
|
m = models[i % len(models)]
|
|
yield sse({"type": "status", "message": f"{role_name}: {m}..."})
|
|
try:
|
|
r = query_model(m, f"{instruction}\n\nQuestion: {prompt}")
|
|
responses[role_name] = (m, r)
|
|
yield sse({"type": "response", "model": m, "text": r, "role": role_name})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": m, "text": str(e), "role": "error"})
|
|
|
|
# 360 synthesis
|
|
yield sse({"type": "status", "message": f"{synthesizer} weaving 360-degree view..."})
|
|
syn = f"Question: {prompt}\n\nMultiple stakeholders gave their perspective:\n\n"
|
|
for role, (m, r) in responses.items():
|
|
syn += f"[{role} ({m})]: {r.strip()}\n\n"
|
|
syn += "Synthesize a 360-degree view that balances all stakeholder perspectives. Highlight tensions and trade-offs."
|
|
try:
|
|
yield sse({"type": "response", "model": synthesizer, "text": query_model(synthesizer, syn), "role": "mesh-360"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": synthesizer, "text": str(e), "role": "error"})
|
|
|
|
|
|
def run_hallucination(config):
|
|
"""One answers, hunters verify each claim independently."""
|
|
prompt, answerer = config["prompt"], config["answerer"]
|
|
hunters = config.get("hunters", [])
|
|
yield sse({"type": "clear"})
|
|
|
|
# Get answer
|
|
yield sse({"type": "status", "message": f"{answerer} answering..."})
|
|
try:
|
|
answer = query_model(answerer, prompt)
|
|
yield sse({"type": "response", "model": answerer, "text": answer, "role": "answer"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": answerer, "text": str(e), "role": "error"}); return
|
|
|
|
# Extract claims
|
|
yield sse({"type": "status", "message": "Extracting factual claims..."})
|
|
extract_prompt = (
|
|
f"Extract every factual claim from this answer as a numbered list. Include specific facts, numbers, dates, "
|
|
f"names, and cause-effect relationships. One claim per line.\n\nAnswer:\n{answer}"
|
|
)
|
|
try:
|
|
claims = query_model(answerer, extract_prompt)
|
|
yield sse({"type": "response", "model": answerer, "text": claims, "role": "claims extracted"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": answerer, "text": str(e), "role": "error"}); return
|
|
|
|
# Each hunter verifies independently
|
|
yield sse({"type": "status", "message": f"{len(hunters)} hunters verifying claims..."})
|
|
hunt_prompt = (
|
|
f"Original question: {prompt}\n\n"
|
|
f"An AI generated this answer:\n{answer}\n\n"
|
|
f"Here are the extracted claims:\n{claims}\n\n"
|
|
f"For EACH claim, verdict:\n"
|
|
f" VERIFIED - you are confident this is correct\n"
|
|
f" SUSPICIOUS - might be wrong or misleading\n"
|
|
f" HALLUCINATED - this is likely made up or incorrect\n"
|
|
f" UNVERIFIABLE - cannot determine from your knowledge\n"
|
|
f"Explain your reasoning for suspicious/hallucinated claims."
|
|
)
|
|
results = parallel_query(hunters, hunt_prompt)
|
|
for m, r in results.items():
|
|
yield sse({"type": "response", "model": m, "text": r, "role": "hunter"})
|
|
|
|
|
|
def run_timeloop(config):
|
|
"""CHAOS MODE: answer -> catastrophe -> fix -> new catastrophe -> repeat."""
|
|
prompt = config["prompt"]
|
|
answerer = config["answerer"]
|
|
chaos = config["chaos"]
|
|
loops = config.get("loops", 4)
|
|
yield sse({"type": "clear"})
|
|
|
|
# Initial answer
|
|
yield sse({"type": "status", "message": f"{answerer} answering (unaware of impending doom)..."})
|
|
try:
|
|
current = query_model(answerer, prompt)
|
|
yield sse({"type": "response", "model": answerer, "text": current, "role": "initial (doomed)"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": answerer, "text": str(e), "role": "error"}); return
|
|
|
|
catastrophes = []
|
|
for i in range(loops):
|
|
# Chaos agent creates a catastrophe
|
|
yield sse({"type": "status", "message": f"Loop {i+1}: CHAOS AGENT unleashed..."})
|
|
chaos_prompt = (
|
|
f"Original question: {prompt}\n\n"
|
|
f"Someone implemented this answer:\n{current}\n\n"
|
|
f"Previous catastrophes that were already fixed: {catastrophes if catastrophes else 'None yet'}\n\n"
|
|
f"You are a CHAOS AGENT. Describe a SPECIFIC, VIVID catastrophe that happened because of a flaw "
|
|
f"in this answer. Be creative and dramatic but grounded in a real flaw. "
|
|
f"Describe: 1) What went wrong 2) The cascading consequences 3) Who/what was affected. "
|
|
f"Make it different from previous catastrophes. Be theatrical!"
|
|
)
|
|
try:
|
|
catastrophe = query_model(chaos, chaos_prompt)
|
|
catastrophes.append(catastrophe.strip()[:200])
|
|
yield sse({"type": "response", "model": chaos, "text": catastrophe, "role": f"catastrophe {i+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": chaos, "text": str(e), "role": "error"}); continue
|
|
|
|
# Answerer must fix
|
|
yield sse({"type": "status", "message": f"Loop {i+1}: {answerer} desperately fixing..."})
|
|
fix_prompt = (
|
|
f"Original question: {prompt}\n\n"
|
|
f"Your previous answer:\n{current}\n\n"
|
|
f"CATASTROPHE REPORT:\n{catastrophe}\n\n"
|
|
f"ALL previous catastrophes you must also prevent:\n" +
|
|
"\n".join(f" {j+1}. {c}" for j, c in enumerate(catastrophes)) +
|
|
f"\n\nRewrite your answer to prevent THIS catastrophe and ALL previous ones. "
|
|
f"Your answer must be BULLETPROOF. Return the complete fixed answer."
|
|
)
|
|
try:
|
|
current = query_model(answerer, fix_prompt)
|
|
is_last = i == loops - 1
|
|
yield sse({"type": "response", "model": answerer, "text": current, "role": "survivor" if is_last else f"fix {i+1}"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": answerer, "text": str(e), "role": "error"})
|
|
|
|
# Final verdict from chaos agent
|
|
yield sse({"type": "status", "message": f"{chaos} final inspection..."})
|
|
final_prompt = (
|
|
f"Original question: {prompt}\n\n"
|
|
f"After {loops} catastrophes, the final answer is:\n{current}\n\n"
|
|
f"All catastrophes it survived:\n" +
|
|
"\n".join(f" {j+1}. {c}" for j, c in enumerate(catastrophes)) +
|
|
f"\n\nAs the Chaos Agent, give your final verdict: Is this answer now truly bulletproof? "
|
|
f"Rate its resilience 1-10. Can you find ONE MORE flaw? If not, admit defeat."
|
|
)
|
|
try:
|
|
yield sse({"type": "response", "model": chaos, "text": query_model(chaos, final_prompt), "role": "final judgment"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": chaos, "text": str(e), "role": "error"})
|
|
|
|
|
|
# ─── AUTONOMOUS PIPELINES ─────────────────────────────────────
|
|
|
|
def _save_pipeline(pipeline, topic, steps, result, models, start_ms):
|
|
import time
|
|
duration = int((time.time() * 1000) - start_ms)
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""INSERT INTO pipeline_runs (pipeline, topic, status, steps, result, models_used, duration_ms, completed_at)
|
|
VALUES (%s, %s, 'completed', %s, %s, %s, %s, NOW())""",
|
|
(pipeline, topic, json.dumps(steps), json.dumps(result), list(set(models)), duration)
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
print(f"[DB] pipeline save error: {e}")
|
|
|
|
|
|
def run_research(config):
|
|
"""Autonomous research pipeline: scout → parallel research → fact-check → synthesize."""
|
|
import time
|
|
start = time.time() * 1000
|
|
prompt = config["prompt"]
|
|
scout = config.get("scout", "llama3.2:latest")
|
|
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 = 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": "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, "
|
|
f"diverse research questions that would build a comprehensive understanding. "
|
|
f"Return ONLY a numbered list.\n\nTopic: {prompt}"
|
|
)
|
|
questions_raw = query_model(scout, q_prompt)
|
|
yield sse({"type": "response", "model": scout, "text": questions_raw, "role": "scout"})
|
|
steps.append({"step": "scout", "model": scout, "output": questions_raw})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": scout, "text": str(e), "role": "error"})
|
|
return
|
|
|
|
# Parse questions
|
|
questions = [l.strip() for l in questions_raw.strip().split("\n") if l.strip() and any(c.isalpha() for c in l)]
|
|
questions = questions[:num_q]
|
|
if not questions:
|
|
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": "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):
|
|
m = models[i % len(models)] if models else scout
|
|
rp = f"Research this question thoroughly. Provide specific facts, data, and examples.\n\nQuestion: {q}"
|
|
futures[pool.submit(query_model, m, rp)] = (m, q)
|
|
for future in as_completed(futures):
|
|
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 — 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():
|
|
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"
|
|
" UNCERTAIN — may be wrong or outdated\n"
|
|
" FLAGGED — likely inaccurate\n"
|
|
"Be specific about what's wrong with flagged items."
|
|
)
|
|
try:
|
|
check_result = query_model(checker, check_prompt)
|
|
yield sse({"type": "response", "model": checker, "text": check_result, "role": "fact-checker"})
|
|
steps.append({"step": "fact-check", "model": checker, "output": check_result[:1000]})
|
|
except Exception as e:
|
|
check_result = f"Error: {e}"
|
|
yield sse({"type": "response", "model": checker, "text": str(e), "role": "error"})
|
|
|
|
# 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():
|
|
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"
|
|
"1. EXECUTIVE SUMMARY (2-3 sentences)\n"
|
|
"2. KEY FINDINGS (bulleted list)\n"
|
|
"3. DETAILED ANALYSIS (organized by theme)\n"
|
|
"4. UNCERTAINTIES & GAPS (what needs more research)\n"
|
|
"5. RECOMMENDATIONS (actionable next steps)\n"
|
|
"Be comprehensive but concise."
|
|
)
|
|
try:
|
|
brief = query_model(synth, synth_prompt)
|
|
yield sse({"type": "response", "model": synth, "text": brief, "role": "synthesis"})
|
|
steps.append({"step": "synthesis", "model": synth, "output": brief[:2000]})
|
|
except Exception as e:
|
|
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)
|
|
|
|
|
|
def run_eval(config):
|
|
"""Model evaluation pipeline: same prompts → all models → judge scores → leaderboard."""
|
|
import time
|
|
start = time.time() * 1000
|
|
prompt = config["prompt"]
|
|
models = config.get("models", [])
|
|
judge = config.get("judge", models[0] if models else "qwen2.5:latest")
|
|
eval_type = config.get("eval_type", "general")
|
|
rounds = config.get("rounds", 3)
|
|
yield sse({"type": "clear"})
|
|
steps = []
|
|
all_models = models + [judge]
|
|
|
|
# Generate eval prompts based on type
|
|
yield sse({"type": "status", "message": f"Generating {rounds} {eval_type} evaluation prompts..."})
|
|
gen_prompt = (
|
|
f"Generate exactly {rounds} evaluation prompts for testing LLM capability in: {eval_type}.\n"
|
|
f"Context/focus area: {prompt}\n\n"
|
|
f"Each prompt should test a different aspect. Return ONLY a numbered list of prompts, nothing else."
|
|
)
|
|
try:
|
|
prompts_raw = query_model(judge, gen_prompt)
|
|
yield sse({"type": "response", "model": judge, "text": prompts_raw, "role": "prompt generator"})
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": judge, "text": str(e), "role": "error"})
|
|
return
|
|
|
|
eval_prompts = [l.strip() for l in prompts_raw.strip().split("\n") if l.strip() and any(c.isalpha() for c in l)]
|
|
eval_prompts = eval_prompts[:rounds]
|
|
if not eval_prompts:
|
|
yield sse({"type": "response", "model": "system", "text": "Failed to generate eval prompts.", "role": "error"})
|
|
return
|
|
|
|
# Run each prompt against all models
|
|
scores = {m: [] for m in models}
|
|
for ri, ep in enumerate(eval_prompts):
|
|
yield sse({"type": "status", "message": f"Round {ri+1}/{len(eval_prompts)}: Testing {len(models)} models..."})
|
|
|
|
# All models answer in parallel
|
|
responses = parallel_query(models, ep)
|
|
for m, r in responses.items():
|
|
yield sse({"type": "response", "model": m, "text": f"[Round {ri+1}] {ep[:80]}...\n\n{r}", "role": f"round {ri+1}"})
|
|
|
|
# Judge scores all responses
|
|
yield sse({"type": "status", "message": f"Round {ri+1}: Judging..."})
|
|
judge_prompt = (
|
|
f"Evaluation prompt: {ep}\n\n"
|
|
f"Score each model's response 1-10 on: accuracy, completeness, clarity, reasoning.\n"
|
|
f"Return a JSON object: {{\"model_name\": {{\"score\": N, \"notes\": \"brief note\"}}}}.\n\n"
|
|
)
|
|
for m, r in responses.items():
|
|
judge_prompt += f"[{m}]:\n{r[:500]}\n\n"
|
|
try:
|
|
judgment = query_model(judge, judge_prompt)
|
|
yield sse({"type": "response", "model": judge, "text": judgment, "role": f"judge round {ri+1}"})
|
|
# Try to parse scores
|
|
try:
|
|
import re
|
|
# Find numbers after model names
|
|
for m in models:
|
|
# Look for score patterns near model name
|
|
pattern = re.escape(m) + r'.*?["\s:]+(\d+)'
|
|
match = re.search(pattern, judgment, re.IGNORECASE | re.DOTALL)
|
|
if match:
|
|
scores[m].append(int(match.group(1)))
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": judge, "text": str(e), "role": "error"})
|
|
|
|
steps.append({"round": ri+1, "prompt": ep, "responses": {m: r[:300] for m, r in responses.items()}})
|
|
|
|
# Final leaderboard
|
|
yield sse({"type": "status", "message": "Generating leaderboard..."})
|
|
leaderboard = []
|
|
for m in models:
|
|
avg = sum(scores[m]) / len(scores[m]) if scores[m] else 0
|
|
leaderboard.append({"model": m, "avg_score": round(avg, 1), "rounds": len(scores[m]), "scores": scores[m]})
|
|
leaderboard.sort(key=lambda x: x["avg_score"], reverse=True)
|
|
|
|
board_text = f"LEADERBOARD — {eval_type.upper()} ({len(eval_prompts)} rounds)\n{'='*50}\n\n"
|
|
for i, entry in enumerate(leaderboard):
|
|
medal = ["1st", "2nd", "3rd"][i] if i < 3 else f"{i+1}th"
|
|
bar = "#" * int(entry["avg_score"])
|
|
board_text += f" {medal} {entry['model']:<30} {entry['avg_score']:>4}/10 {bar}\n"
|
|
if entry["scores"]:
|
|
board_text += f" Round scores: {entry['scores']}\n\n"
|
|
|
|
yield sse({"type": "response", "model": judge, "text": board_text, "role": "final"})
|
|
|
|
_save_pipeline("eval", prompt, steps, {"leaderboard": leaderboard, "eval_type": eval_type}, all_models, start)
|
|
|
|
|
|
def run_extract(config):
|
|
"""Knowledge extraction pipeline: chunk text → extract facts → verify → structured output."""
|
|
import time
|
|
start = time.time() * 1000
|
|
prompt = config["prompt"]
|
|
extractor = config.get("extractor", "qwen2.5:latest")
|
|
verifier = config.get("verifier", "gemma2:latest")
|
|
source = config.get("source", "prompt")
|
|
yield sse({"type": "clear"})
|
|
steps = []
|
|
all_models = [extractor, verifier]
|
|
|
|
# Get source text
|
|
source_text = prompt
|
|
if source != "prompt":
|
|
file_map = {
|
|
"ontology": "/home/profit/ONTOLOGY.md",
|
|
"index": "/home/profit/INDEX.md",
|
|
"summaries": "/home/profit/SUMMARIES.md",
|
|
"guides": "/home/profit/GUIDES.md",
|
|
}
|
|
fpath = file_map.get(source)
|
|
if fpath and os.path.exists(fpath):
|
|
yield sse({"type": "status", "message": f"Reading {source}..."})
|
|
with open(fpath) as f:
|
|
source_text = f.read()[:15000] # limit to ~15K chars
|
|
yield sse({"type": "response", "model": "system", "text": f"Loaded {source} ({len(source_text)} chars)", "role": "source"})
|
|
else:
|
|
yield sse({"type": "response", "model": "system", "text": f"File not found: {source}", "role": "error"})
|
|
return
|
|
|
|
# Chunk if too long
|
|
chunks = []
|
|
chunk_size = 4000
|
|
for i in range(0, len(source_text), chunk_size):
|
|
chunks.append(source_text[i:i+chunk_size])
|
|
|
|
yield sse({"type": "status", "message": f"Processing {len(chunks)} chunk(s) with {extractor}..."})
|
|
|
|
all_facts = []
|
|
all_entities = []
|
|
all_relations = []
|
|
|
|
for ci, chunk in enumerate(chunks):
|
|
yield sse({"type": "status", "message": f"Extracting from chunk {ci+1}/{len(chunks)}..."})
|
|
extract_prompt = (
|
|
f"Extract structured knowledge from this text. Return a JSON object with:\n"
|
|
f" \"facts\": [\"fact 1\", \"fact 2\", ...],\n"
|
|
f" \"entities\": [{{\"name\": \"...\", \"type\": \"...\", \"description\": \"...\"}}, ...],\n"
|
|
f" \"relationships\": [{{\"from\": \"...\", \"to\": \"...\", \"type\": \"...\"}}, ...]\n\n"
|
|
f"Be thorough. Extract EVERY factual claim, named entity, and relationship.\n\n"
|
|
f"Text:\n{chunk}"
|
|
)
|
|
try:
|
|
result = query_model(extractor, extract_prompt)
|
|
yield sse({"type": "response", "model": extractor, "text": result, "role": f"extraction {ci+1}"})
|
|
# Try to parse JSON from response
|
|
try:
|
|
import re
|
|
json_match = re.search(r'\{[\s\S]*\}', result)
|
|
if json_match:
|
|
parsed = json.loads(json_match.group())
|
|
all_facts.extend(parsed.get("facts", []))
|
|
all_entities.extend(parsed.get("entities", []))
|
|
all_relations.extend(parsed.get("relationships", []))
|
|
except Exception:
|
|
all_facts.append(result[:500])
|
|
except Exception as e:
|
|
yield sse({"type": "response", "model": extractor, "text": str(e), "role": "error"})
|
|
|
|
steps.append({"step": "extraction", "facts": len(all_facts), "entities": len(all_entities), "relations": len(all_relations)})
|
|
|
|
# Verify key facts
|
|
yield sse({"type": "status", "message": f"{verifier} verifying {len(all_facts)} facts..."})
|
|
facts_sample = all_facts[:20] # verify up to 20
|
|
verify_prompt = (
|
|
f"Verify these extracted facts. For each, mark CORRECT, INCORRECT, or UNVERIFIABLE.\n"
|
|
f"If incorrect, provide the correction.\n\n"
|
|
)
|
|
for i, f in enumerate(facts_sample):
|
|
fact_str = f if isinstance(f, str) else json.dumps(f)
|
|
verify_prompt += f"{i+1}. {fact_str}\n"
|
|
try:
|
|
verification = query_model(verifier, verify_prompt)
|
|
yield sse({"type": "response", "model": verifier, "text": verification, "role": "verifier"})
|
|
steps.append({"step": "verification", "model": verifier, "output": verification[:1000]})
|
|
except Exception as e:
|
|
verification = str(e)
|
|
yield sse({"type": "response", "model": verifier, "text": str(e), "role": "error"})
|
|
|
|
# Summary
|
|
summary = (
|
|
f"KNOWLEDGE EXTRACTION SUMMARY\n{'='*40}\n\n"
|
|
f"Source: {source}\n"
|
|
f"Facts extracted: {len(all_facts)}\n"
|
|
f"Entities found: {len(all_entities)}\n"
|
|
f"Relationships mapped: {len(all_relations)}\n\n"
|
|
f"TOP ENTITIES:\n"
|
|
)
|
|
for e in all_entities[:15]:
|
|
if isinstance(e, dict):
|
|
summary += f" [{e.get('type','?')}] {e.get('name','?')} — {e.get('description','')[:60]}\n"
|
|
summary += f"\nTOP RELATIONSHIPS:\n"
|
|
for r in all_relations[:15]:
|
|
if isinstance(r, dict):
|
|
summary += f" {r.get('from','?')} --[{r.get('type','?')}]--> {r.get('to','?')}\n"
|
|
|
|
yield sse({"type": "response", "model": "system", "text": summary, "role": "final"})
|
|
|
|
result_data = {
|
|
"facts": all_facts[:100],
|
|
"entities": all_entities[:50],
|
|
"relationships": all_relations[:50],
|
|
"verification": verification[:1000],
|
|
"source": source,
|
|
}
|
|
_save_pipeline("extract", prompt or source, steps, result_data, all_models, start)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("\n LLM Team UI running at http://localhost:5000\n")
|
|
app.run(host="127.0.0.1", port=5000, debug=False)
|