Full history page with tags, notes, vector API, and bulk ops
New /history page (replaces slide-out panel): - Full-page data table: ID, Mode, Prompt, Models, Tags, Date - Active/Archived/All view toggle - Filter by mode, tag, or search text - Checkbox select for bulk archive/restore - Click any row → detail panel with full responses Per-run detail: - Inline tag editor: add tags (Enter), remove tags (click ✕) - Notes textarea with auto-save (1s debounce) - Archive/Restore/Delete buttons - Collapsible response cards (click header to expand) Database: - tags TEXT[] column with GIN index for fast tag queries - notes TEXT column for freeform annotations APIs: - POST /api/runs/:id/tags — update tags and/or notes - GET /api/runs/tags — list all unique tags in use - GET /api/runs/vectors — structured text documents for AI/embedding Returns: mode, prompt, models, date, tags, notes + all response text Filters: ?mode=, ?tag=, ?limit= Each doc includes token estimate for embedding planning Main UI: History button now links to /history page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aeab1f0194
commit
efa547bb68
400
llm_team_ui.py
400
llm_team_ui.py
@ -1698,7 +1698,7 @@ HTML = r"""
|
|||||||
<h1><span>LLM</span> Team</h1>
|
<h1><span>LLM</span> Team</h1>
|
||||||
<div class="badge" id="model-count"><span class="dot"></span>0 models</div>
|
<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">
|
<nav style="margin-left:auto;display:flex;align-items:center;gap:4px">
|
||||||
<button onclick="toggleHistory()" style="color:var(--text2);background:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;cursor:pointer;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">History</button>
|
<a href="/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="/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>
|
<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>
|
<span style="width:2px;height:16px;background:var(--border);margin:0 4px"></span>
|
||||||
@ -4992,6 +4992,404 @@ def bulk_archive_runs():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/runs/<int:run_id>/tags", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def update_run_tags(run_id):
|
||||||
|
data = request.json or {}
|
||||||
|
tags = data.get("tags", [])
|
||||||
|
notes = data.get("notes")
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if notes is not None:
|
||||||
|
cur.execute("UPDATE team_runs SET tags = %s, notes = %s WHERE id = %s", (tags, notes, run_id))
|
||||||
|
else:
|
||||||
|
cur.execute("UPDATE team_runs SET tags = %s WHERE id = %s", (tags, run_id))
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/runs/tags")
|
||||||
|
@login_required
|
||||||
|
def get_all_tags():
|
||||||
|
"""Get all unique tags in use."""
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT DISTINCT unnest(tags) as tag FROM team_runs ORDER BY tag")
|
||||||
|
tags = [r[0] for r in cur.fetchall()]
|
||||||
|
return jsonify({"tags": tags})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"tags": [], "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/runs/vectors")
|
||||||
|
@login_required
|
||||||
|
def get_run_vectors():
|
||||||
|
"""Return runs as structured text documents for AI/embedding consumption."""
|
||||||
|
limit = min(int(request.args.get("limit", 50)), 500)
|
||||||
|
mode_filter = request.args.get("mode", "")
|
||||||
|
tag_filter = request.args.get("tag", "")
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
conditions = ["archived = false"]
|
||||||
|
params = []
|
||||||
|
if mode_filter:
|
||||||
|
conditions.append("mode = %s")
|
||||||
|
params.append(mode_filter)
|
||||||
|
if tag_filter:
|
||||||
|
conditions.append("%s = ANY(tags)")
|
||||||
|
params.append(tag_filter)
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
params.append(limit)
|
||||||
|
cur.execute(f"SELECT * FROM team_runs WHERE {where} ORDER BY created_at DESC LIMIT %s", params)
|
||||||
|
runs = cur.fetchall()
|
||||||
|
|
||||||
|
vectors = []
|
||||||
|
for run in runs:
|
||||||
|
responses = run.get("responses") or []
|
||||||
|
# Build structured document
|
||||||
|
doc_parts = [
|
||||||
|
f"MODE: {run['mode']}",
|
||||||
|
f"PROMPT: {run['prompt']}",
|
||||||
|
f"MODELS: {', '.join(run.get('models_used') or [])}",
|
||||||
|
f"DATE: {run['created_at'].isoformat()}",
|
||||||
|
f"TAGS: {', '.join(run.get('tags') or [])}",
|
||||||
|
]
|
||||||
|
if run.get("notes"):
|
||||||
|
doc_parts.append(f"NOTES: {run['notes']}")
|
||||||
|
for i, resp in enumerate(responses):
|
||||||
|
doc_parts.append(f"\n--- RESPONSE {i+1} [{resp.get('role','?')}] by {resp.get('model','?')} ---")
|
||||||
|
doc_parts.append(resp.get("text", "")[:2000])
|
||||||
|
document = "\n".join(doc_parts)
|
||||||
|
vectors.append({
|
||||||
|
"id": run["id"],
|
||||||
|
"mode": run["mode"],
|
||||||
|
"prompt": run["prompt"],
|
||||||
|
"tags": run.get("tags") or [],
|
||||||
|
"document": document,
|
||||||
|
"char_count": len(document),
|
||||||
|
"token_estimate": len(document) // 4
|
||||||
|
})
|
||||||
|
return jsonify({"vectors": vectors, "total": len(vectors)})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"vectors": [], "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/history")
|
||||||
|
@login_required
|
||||||
|
def history_page():
|
||||||
|
return HISTORY_HTML
|
||||||
|
|
||||||
|
|
||||||
|
HISTORY_HTML = r"""<!DOCTYPE html>
|
||||||
|
<html lang="en"><head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>LLM Team — History</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{--bg:#08090c;--surface:rgba(14,16,22,0.85);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;--accent:#e2b55a;--green:#4ade80;--red:#e05252;--blue:#5b9cf5}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 28px}
|
||||||
|
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
|
||||||
|
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.02) 2px,rgba(0,0,0,0.02) 4px)}
|
||||||
|
.wrap{position:relative;z-index:10;max-width:1400px;margin:0 auto}
|
||||||
|
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:20px}
|
||||||
|
h1{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:700}
|
||||||
|
h1 span{color:var(--accent)}
|
||||||
|
.back{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:1px;border:2px solid var(--border);padding:5px 12px;border-radius:2px}
|
||||||
|
.back:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.toolbar{display:flex;gap:6px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||||
|
.tool-btn{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:5px 12px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text2);cursor:pointer}
|
||||||
|
.tool-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.tool-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(226,181,90,0.06)}
|
||||||
|
.tool-btn.mag{border-color:rgba(217,70,239,0.3);color:#d946ef}
|
||||||
|
.tool-btn.mag:hover{background:rgba(217,70,239,0.06)}
|
||||||
|
.tool-btn.grn{border-color:rgba(74,222,128,0.3);color:var(--green)}
|
||||||
|
.tool-btn.red{border-color:rgba(224,82,82,0.3);color:var(--red)}
|
||||||
|
.tool-select{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px}
|
||||||
|
.tool-input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 10px;flex:1;min-width:120px}
|
||||||
|
.spacer{flex:1}
|
||||||
|
.count-badge{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2)}
|
||||||
|
.run-table{width:100%}
|
||||||
|
.run-row{display:grid;grid-template-columns:30px 50px 90px 1fr 80px 100px 80px;gap:8px;padding:8px 10px;border-bottom:1px solid rgba(42,45,53,0.3);align-items:center;font-size:11px;cursor:pointer;transition:background 0.1s}
|
||||||
|
.run-row:hover{background:rgba(226,181,90,0.03)}
|
||||||
|
.run-row.archived{opacity:0.4}
|
||||||
|
.run-hdr{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);cursor:default;border-bottom:2px solid var(--border)}
|
||||||
|
.run-hdr:hover{background:transparent}
|
||||||
|
.run-id{color:var(--text2);font-family:'JetBrains Mono',monospace}
|
||||||
|
.run-mode{font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--accent);font-size:10px;text-transform:uppercase}
|
||||||
|
.run-prompt{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.run-models{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:10px}
|
||||||
|
.run-date{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:9px}
|
||||||
|
.run-tags{display:flex;gap:3px;flex-wrap:wrap}
|
||||||
|
.tag-pill{font-family:'JetBrains Mono',monospace;font-size:8px;padding:1px 6px;border:1px solid rgba(192,132,252,0.3);border-radius:1px;color:#c084fc}
|
||||||
|
.detail-panel{display:none;background:var(--surface);border:2px solid var(--accent);border-radius:2px;padding:20px;margin-bottom:20px;backdrop-filter:blur(16px)}
|
||||||
|
.detail-panel.open{display:block}
|
||||||
|
.detail-header{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
|
||||||
|
.detail-prompt{font-size:13px;line-height:1.6;margin-bottom:12px}
|
||||||
|
.detail-actions{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap}
|
||||||
|
.tag-editor{display:flex;gap:4px;align-items:center;margin-bottom:12px}
|
||||||
|
.tag-editor input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px;width:120px}
|
||||||
|
.notes-area{width:100%;min-height:40px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px;font-size:12px;font-family:'JetBrains Mono',monospace;resize:vertical;margin-bottom:12px}
|
||||||
|
.notes-area:focus{border-color:var(--accent);outline:none}
|
||||||
|
.resp-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;margin-bottom:6px;overflow:hidden}
|
||||||
|
.resp-head{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer}
|
||||||
|
.resp-head:hover{background:rgba(226,181,90,0.03)}
|
||||||
|
.resp-role{font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#c084fc;margin-left:auto}
|
||||||
|
.resp-body{display:none;padding:12px;font-size:12px;line-height:1.6;white-space:pre-wrap;max-height:400px;overflow-y:auto}
|
||||||
|
.resp-body.open{display:block}
|
||||||
|
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
|
||||||
|
.toast{position:fixed;top:20px;right:20px;padding:8px 14px;border-radius:2px;font-size:10px;z-index:100;font-family:'JetBrains Mono',monospace;border:2px solid;backdrop-filter:blur(16px)}
|
||||||
|
.toast.ok{background:rgba(74,222,128,0.1);border-color:var(--green);color:var(--green)}
|
||||||
|
.toast.err{background:rgba(224,82,82,0.1);border-color:var(--red);color:var(--red)}
|
||||||
|
@media(max-width:768px){.run-row{grid-template-columns:30px 60px 1fr 60px}.run-models,.run-date,.run-tags{display:none}}
|
||||||
|
</style></head><body>
|
||||||
|
<canvas id="bg-grid"></canvas>
|
||||||
|
<div class="scanlines"></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1><span>History</span> // Run Archive</h1>
|
||||||
|
<nav style="margin-left:auto;display:flex;gap:6px">
|
||||||
|
<a class="back" href="/">Team</a>
|
||||||
|
<a class="back" href="/admin/monitor">Monitor</a>
|
||||||
|
<a class="back" href="/logs">Logs</a>
|
||||||
|
<a class="back" href="/admin">Admin</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="tool-btn active" id="tb-active" onclick="setView('active')">Active</button>
|
||||||
|
<button class="tool-btn" id="tb-archived" onclick="setView('archived')">Archived</button>
|
||||||
|
<button class="tool-btn" id="tb-all" onclick="setView('all')">All</button>
|
||||||
|
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
|
||||||
|
<select class="tool-select" id="filter-mode" onchange="loadRuns()"><option value="">All Modes</option></select>
|
||||||
|
<select class="tool-select" id="filter-tag" onchange="loadRuns()"><option value="">All Tags</option></select>
|
||||||
|
<input class="tool-input" id="filter-search" placeholder="Search prompts..." oninput="filterLocal()">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="count-badge" id="run-count"></span>
|
||||||
|
<button class="tool-btn mag" onclick="archiveSelected()">Archive Sel.</button>
|
||||||
|
<button class="tool-btn grn" onclick="restoreSelected()">Restore Sel.</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail panel -->
|
||||||
|
<div class="detail-panel" id="detail-panel"></div>
|
||||||
|
|
||||||
|
<!-- Run table -->
|
||||||
|
<div class="run-table" id="run-table">
|
||||||
|
<div class="run-row run-hdr">
|
||||||
|
<span></span><span>ID</span><span>Mode</span><span>Prompt</span><span>Models</span><span>Tags</span><span>Date</span>
|
||||||
|
</div>
|
||||||
|
<div id="run-list"><div class="empty">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
|
||||||
|
|
||||||
|
var currentView = 'active';
|
||||||
|
var allRuns = [];
|
||||||
|
|
||||||
|
function setView(v) {
|
||||||
|
currentView = v;
|
||||||
|
document.querySelectorAll('.toolbar .tool-btn').forEach(function(b){b.classList.remove('active')});
|
||||||
|
document.getElementById('tb-'+v).classList.add('active');
|
||||||
|
loadRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, ok) {
|
||||||
|
var t = document.createElement('div'); t.className = 'toast ' + (ok?'ok':'err');
|
||||||
|
t.textContent = msg; document.body.appendChild(t);
|
||||||
|
setTimeout(function(){t.remove()},2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRuns() {
|
||||||
|
var mode = document.getElementById('filter-mode').value;
|
||||||
|
var tag = document.getElementById('filter-tag').value;
|
||||||
|
var url = '/api/runs?show=' + currentView;
|
||||||
|
var r = await fetch(url);
|
||||||
|
var d = await r.json();
|
||||||
|
allRuns = d.runs || [];
|
||||||
|
// Client-side filter by mode/tag
|
||||||
|
if (mode) allRuns = allRuns.filter(function(r){return r.mode === mode});
|
||||||
|
if (tag) allRuns = allRuns.filter(function(r){return (r.tags||[]).indexOf(tag)>=0});
|
||||||
|
filterLocal();
|
||||||
|
document.getElementById('run-count').textContent = allRuns.length + ' runs';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterLocal() {
|
||||||
|
var q = (document.getElementById('filter-search').value||'').toLowerCase();
|
||||||
|
var filtered = q ? allRuns.filter(function(r){return (r.prompt||'').toLowerCase().indexOf(q)>=0 || r.mode.toLowerCase().indexOf(q)>=0}) : allRuns;
|
||||||
|
renderTable(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(runs) {
|
||||||
|
var el = document.getElementById('run-list');
|
||||||
|
if (!runs.length) { el.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No runs found'; el.appendChild(e); return; }
|
||||||
|
el.textContent = '';
|
||||||
|
runs.forEach(function(r) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'run-row' + (r.archived ? ' archived' : '');
|
||||||
|
// Checkbox
|
||||||
|
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.dataset.id = r.id;
|
||||||
|
cb.style.cssText = 'width:14px;height:14px;accent-color:#e2b55a;cursor:pointer';
|
||||||
|
cb.onclick = function(e){e.stopPropagation()};
|
||||||
|
row.appendChild(cb);
|
||||||
|
// ID
|
||||||
|
var idEl = document.createElement('span'); idEl.className = 'run-id'; idEl.textContent = '#'+r.id; row.appendChild(idEl);
|
||||||
|
// Mode
|
||||||
|
var modeEl = document.createElement('span'); modeEl.className = 'run-mode'; modeEl.textContent = r.mode; row.appendChild(modeEl);
|
||||||
|
// Prompt
|
||||||
|
var promptEl = document.createElement('span'); promptEl.className = 'run-prompt'; promptEl.textContent = (r.prompt||'').substring(0,100); promptEl.title = r.prompt||''; row.appendChild(promptEl);
|
||||||
|
// Models
|
||||||
|
var modelsEl = document.createElement('span'); modelsEl.className = 'run-models'; modelsEl.textContent = (r.models_used||[]).length + ' models'; row.appendChild(modelsEl);
|
||||||
|
// Tags
|
||||||
|
var tagsEl = document.createElement('span'); tagsEl.className = 'run-tags';
|
||||||
|
(r.tags||[]).forEach(function(t){ var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.textContent = t; tagsEl.appendChild(pill); });
|
||||||
|
row.appendChild(tagsEl);
|
||||||
|
// Date
|
||||||
|
var dateEl = document.createElement('span'); dateEl.className = 'run-date';
|
||||||
|
var dt = new Date(r.created_at); dateEl.textContent = dt.toLocaleDateString()+' '+dt.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
|
||||||
|
row.appendChild(dateEl);
|
||||||
|
row.onclick = function(){openDetail(r.id)};
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id) {
|
||||||
|
var panel = document.getElementById('detail-panel');
|
||||||
|
panel.className = 'detail-panel open';
|
||||||
|
panel.textContent = 'Loading...';
|
||||||
|
var r = await fetch('/api/runs/'+id);
|
||||||
|
var run = await r.json();
|
||||||
|
if (run.error) { panel.textContent = 'Error: '+run.error; return; }
|
||||||
|
panel.textContent = '';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var hdr = document.createElement('div'); hdr.className = 'detail-header';
|
||||||
|
var closeBtn = document.createElement('button'); closeBtn.className = 'tool-btn';
|
||||||
|
closeBtn.textContent = '✕ Close'; closeBtn.onclick = function(){panel.className='detail-panel';};
|
||||||
|
hdr.appendChild(closeBtn);
|
||||||
|
var modeTag = document.createElement('span'); modeTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700';
|
||||||
|
modeTag.textContent = run.mode + ' #' + id; hdr.appendChild(modeTag);
|
||||||
|
var dateTag = document.createElement('span'); dateTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2)';
|
||||||
|
dateTag.textContent = new Date(run.created_at).toLocaleString(); hdr.appendChild(dateTag);
|
||||||
|
panel.appendChild(hdr);
|
||||||
|
|
||||||
|
// Prompt
|
||||||
|
var prompt = document.createElement('div'); prompt.className = 'detail-prompt'; prompt.textContent = run.prompt; panel.appendChild(prompt);
|
||||||
|
|
||||||
|
// Tags editor
|
||||||
|
var tagEd = document.createElement('div'); tagEd.className = 'tag-editor';
|
||||||
|
var tagLabel = document.createElement('span'); tagLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2)';
|
||||||
|
tagLabel.textContent = 'Tags: '; tagEd.appendChild(tagLabel);
|
||||||
|
var currentTags = run.tags || [];
|
||||||
|
currentTags.forEach(function(t){
|
||||||
|
var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.style.cursor = 'pointer';
|
||||||
|
pill.textContent = t + ' ✕'; pill.title = 'Remove tag';
|
||||||
|
pill.onclick = function(){ currentTags = currentTags.filter(function(x){return x!==t}); saveTags(id,currentTags,null); };
|
||||||
|
tagEd.appendChild(pill);
|
||||||
|
});
|
||||||
|
var tagInput = document.createElement('input'); tagInput.placeholder = 'Add tag...';
|
||||||
|
tagInput.onkeydown = function(e){ if(e.key==='Enter'&&tagInput.value.trim()){ currentTags.push(tagInput.value.trim()); saveTags(id,currentTags,null); tagInput.value=''; }};
|
||||||
|
tagEd.appendChild(tagInput);
|
||||||
|
panel.appendChild(tagEd);
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
var notesArea = document.createElement('textarea'); notesArea.className = 'notes-area';
|
||||||
|
notesArea.placeholder = 'Add notes...'; notesArea.value = run.notes || '';
|
||||||
|
var saveTimer;
|
||||||
|
notesArea.oninput = function(){ clearTimeout(saveTimer); saveTimer = setTimeout(function(){ saveTags(id, null, notesArea.value); }, 1000); };
|
||||||
|
panel.appendChild(notesArea);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
var actions = document.createElement('div'); actions.className = 'detail-actions';
|
||||||
|
if (run.archived) {
|
||||||
|
var restBtn = document.createElement('button'); restBtn.className = 'tool-btn grn'; restBtn.textContent = 'Restore';
|
||||||
|
restBtn.onclick = function(){ fetch('/api/runs/'+id+'/restore',{method:'POST'}).then(function(){toast('Restored',true);loadRuns()}); };
|
||||||
|
actions.appendChild(restBtn);
|
||||||
|
} else {
|
||||||
|
var archBtn = document.createElement('button'); archBtn.className = 'tool-btn mag'; archBtn.textContent = 'Archive';
|
||||||
|
archBtn.onclick = function(){ fetch('/api/runs/'+id+'/archive',{method:'POST'}).then(function(){toast('Archived',true);loadRuns();panel.className='detail-panel'}); };
|
||||||
|
actions.appendChild(archBtn);
|
||||||
|
}
|
||||||
|
var delBtn = document.createElement('button'); delBtn.className = 'tool-btn red'; delBtn.textContent = 'Delete';
|
||||||
|
delBtn.onclick = function(){ if(confirm('Delete permanently?')){fetch('/api/runs/'+id,{method:'DELETE'}).then(function(){toast('Deleted',true);loadRuns();panel.className='detail-panel'})} };
|
||||||
|
actions.appendChild(delBtn);
|
||||||
|
panel.appendChild(actions);
|
||||||
|
|
||||||
|
// Responses
|
||||||
|
var responses = run.responses || [];
|
||||||
|
var respTitle = document.createElement('div');
|
||||||
|
respTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:2px;color:var(--accent);margin-bottom:8px';
|
||||||
|
respTitle.textContent = responses.length + ' Responses'; panel.appendChild(respTitle);
|
||||||
|
responses.forEach(function(resp,i){
|
||||||
|
var card = document.createElement('div'); card.className = 'resp-card';
|
||||||
|
var head = document.createElement('div'); head.className = 'resp-head';
|
||||||
|
head.textContent = resp.model || '?';
|
||||||
|
var role = document.createElement('span'); role.className = 'resp-role'; role.textContent = resp.role||'';
|
||||||
|
head.appendChild(role);
|
||||||
|
var body = document.createElement('div'); body.className = 'resp-body';
|
||||||
|
body.textContent = resp.text || '';
|
||||||
|
head.onclick = function(){ body.classList.toggle('open'); };
|
||||||
|
card.appendChild(head); card.appendChild(body); panel.appendChild(card);
|
||||||
|
});
|
||||||
|
panel.scrollIntoView({behavior:'smooth',block:'start'});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTags(id, tags, notes) {
|
||||||
|
var body = {};
|
||||||
|
if (tags !== null) body.tags = tags;
|
||||||
|
if (notes !== null) body.notes = notes;
|
||||||
|
await fetch('/api/runs/'+id+'/tags', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||||
|
toast('Saved', true);
|
||||||
|
if (tags !== null) loadRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedIds() {
|
||||||
|
var ids = [];
|
||||||
|
document.querySelectorAll('#run-list input[type=checkbox]:checked').forEach(function(cb){ids.push(parseInt(cb.dataset.id))});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveSelected() {
|
||||||
|
var ids = getSelectedIds();
|
||||||
|
if (!ids.length) return toast('Select runs first', false);
|
||||||
|
if (!confirm('Archive '+ids.length+' runs?')) return;
|
||||||
|
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'archive',ids:ids})});
|
||||||
|
toast('Archived '+ids.length, true); loadRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSelected() {
|
||||||
|
var ids = getSelectedIds();
|
||||||
|
if (!ids.length) return toast('Select runs first', false);
|
||||||
|
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'restore',ids:ids})});
|
||||||
|
toast('Restored '+ids.length, true); loadRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilters() {
|
||||||
|
var modeSelect = document.getElementById('filter-mode');
|
||||||
|
var tagSelect = document.getElementById('filter-tag');
|
||||||
|
// Get unique modes from runs
|
||||||
|
var r = await fetch('/api/runs?show=all');
|
||||||
|
var d = await r.json();
|
||||||
|
var modes = new Set(); (d.runs||[]).forEach(function(r){modes.add(r.mode)});
|
||||||
|
modes.forEach(function(m){ var o = document.createElement('option'); o.value = m; o.textContent = m; modeSelect.appendChild(o); });
|
||||||
|
// Tags
|
||||||
|
var tr = await fetch('/api/runs/tags');
|
||||||
|
var td = await tr.json();
|
||||||
|
(td.tags||[]).forEach(function(t){ var o = document.createElement('option'); o.value = t; o.textContent = t; tagSelect.appendChild(o); });
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFilters();
|
||||||
|
loadRuns();
|
||||||
|
</script>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/pipelines")
|
@app.route("/api/pipelines")
|
||||||
@login_required
|
@login_required
|
||||||
def get_pipelines():
|
def get_pipelines():
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user