feat(ui): latest VoiceLog UI update set

This commit is contained in:
2026-03-27 09:46:24 +01:00
parent 239875c7f6
commit 769fb4715f
2 changed files with 399 additions and 309 deletions

Binary file not shown.

708
app.py
View File

@@ -5,7 +5,7 @@ import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import Optional
import markdown as md import markdown as md
import requests import requests
@@ -127,68 +127,51 @@ def layout(title: str, body: str) -> str:
<meta name='theme-color' content='#0f172a'> <meta name='theme-color' content='#0f172a'>
<link rel='manifest' href='/manifest.webmanifest'> <link rel='manifest' href='/manifest.webmanifest'>
<link rel='icon' href='/icon.svg' type='image/svg+xml'> <link rel='icon' href='/icon.svg' type='image/svg+xml'>
<link href='https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css' rel='stylesheet'>
<link href='https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css' rel='stylesheet'>
<title>{title}</title> <title>{title}</title>
<style> <style>
:root{{--bg:#0b1020;--panel:#070c1b;--panel2:#0b1222;--card:#ffffff;--txt:#0f172a;--muted:#64748b;--acc:#0ea5e9;--acc2:#2563eb;--ok:#14b8a6;--border:#e2e8f0}} :root{{--sidebar:#0b1220;--sidebar-border:#1e293b;--page:#f3f6fb;--card:#ffffff;--text:#0f172a;--muted:#64748b;--accent:#2563eb}}
*{{box-sizing:border-box}} *{{box-sizing:border-box}}
body{{font-family:Inter,system-ui,Arial;margin:0;background:#f1f5f9;color:var(--txt);display:flex;min-height:100vh}} body{{font-family:Inter,system-ui,Arial,sans-serif;background:var(--page);color:var(--text);margin:0;display:flex;min-height:100vh}}
nav{{width:236px;background:linear-gradient(180deg,#050a17,#0a1329);color:#e2e8f0;border-right:1px solid #0f172a;padding:14px 10px;position:sticky;top:0;height:100vh;overflow:auto;z-index:60}} nav{{width:260px;background:linear-gradient(180deg,#0b1220,#0f172a);color:#e2e8f0;border-right:1px solid var(--sidebar-border);padding:14px 10px;position:sticky;top:0;height:100vh;overflow:auto;z-index:60}}
.brand{{font-weight:800;letter-spacing:.2px;margin:4px 8px 12px 8px;color:#c7d2fe}} .brand{{font-weight:800;letter-spacing:.2px;margin:6px 10px 14px;color:#dbeafe;font-size:1.05rem}}
.navsection{{margin:12px 8px 6px 8px;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.08em}} .navsection{{margin:14px 10px 8px;color:#94a3b8;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}}
nav a{{display:flex;gap:10px;align-items:center;color:#e2e8f0;text-decoration:none;padding:10px 10px;border-radius:10px;margin:4px 6px;border:1px solid transparent}} nav a{{display:flex;gap:10px;align-items:center;color:#cbd5e1;text-decoration:none;padding:10px 12px;border-radius:12px;margin:4px 6px;border:1px solid transparent;font-weight:500}}
nav a:hover{{background:#0f1b36;border-color:#1e2a4a}} nav a i{{font-size:1rem;opacity:.95;width:18px;text-align:center}}
nav a:hover{{background:#16243d;color:#fff;border-color:#2c3d63}}
.app{{flex:1;display:flex;flex-direction:column;min-width:0}} .app{{flex:1;display:flex;flex-direction:column;min-width:0}}
.topbar{{height:56px;background:#fff;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:10px;position:sticky;top:0;z-index:10}} .topbar{{height:62px;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border-bottom:1px solid #e2e8f0;display:flex;align-items:center;padding:0 18px;gap:12px;position:sticky;top:0;z-index:20}}
.topbar h1{{font-size:18px;margin:0;font-weight:700}} .topbar h1{{font-size:1.02rem;margin:0;font-weight:700}}
.menu-btn{{display:none;border:none;background:transparent;color:#0f172a;border-radius:0;padding:0;font-size:22px;line-height:1;width:auto;height:auto;min-width:0}} .menu-btn{{display:none;border:1px solid #dbe3ef;background:#fff;color:#0f172a;border-radius:10px;padding:.35rem .55rem;font-size:1rem;line-height:1}}
.nav-backdrop{{display:none;position:fixed;inset:0;background:rgba(2,6,23,.35);z-index:55}} main{{padding:18px;max-width:1400px;width:100%;margin:0 auto}}
.topbar .right{{margin-left:auto;display:flex;gap:8px;color:#64748b}} .card{{background:var(--card);border:1px solid #e2e8f0;border-radius:14px;padding:14px;margin:10px 0;box-shadow:0 4px 16px rgba(15,23,42,.05)}}
main{{padding:14px;max-width:1400px}} pre{{white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;border:1px solid #1e293b}}
.card{{background:#fff;border:1px solid var(--border);border-radius:12px;padding:14px;margin:10px 0;box-shadow:0 2px 10px rgba(15,23,42,.04)}} .mdview{{line-height:1.6}}
input,select,textarea,button{{padding:9px 11px;font-size:14px;border-radius:10px;border:1px solid #cbd5e1;background:#fff;color:#0f172a}}
button{{background:linear-gradient(90deg,var(--acc),var(--acc2));color:#fff;border:none;font-weight:700}}
button:hover{{filter:brightness(1.02)}}
textarea{{width:100%;min-height:150px}}
pre{{white-space:pre-wrap;background:#0f172a;color:#a7f3d0;padding:12px;border-radius:10px;border:1px solid #1e293b}}
.mdview{{line-height:1.55}}
.mdview h1,.mdview h2,.mdview h3{{margin:16px 0 8px}}
.mdview p{{margin:10px 0}}
.mdview ul,.mdview ol{{padding-left:22px}}
.mdview code{{background:#e2e8f0;padding:2px 5px;border-radius:6px}}
.mdview pre code{{display:block;padding:10px;background:transparent}}
.mdview blockquote{{border-left:3px solid #94a3b8;padding-left:10px;color:#334155}} .mdview blockquote{{border-left:3px solid #94a3b8;padding-left:10px;color:#334155}}
.row{{display:flex;gap:8px;flex-wrap:wrap;align-items:center}} .hint,small{{color:var(--muted)}}
small{{color:var(--muted)}} .btn{{--bs-btn-padding-y:.34rem;--bs-btn-padding-x:.72rem;--bs-btn-font-size:.9rem;white-space:nowrap;width:auto;max-width:100%}}
.hint{{color:var(--muted);font-size:13px}} .btn-group>.btn{{width:auto}}
@media (max-width:1100px){{ .oc-modal-backdrop{{position:fixed;inset:0;background:rgba(2,6,23,.55);display:none;align-items:center;justify-content:center;z-index:2000}}
nav{{width:84px;padding:8px}} .oc-modal{{width:min(92vw,540px);background:#fff;border:1px solid #dbe3ef;border-radius:16px;padding:16px;box-shadow:0 24px 60px rgba(2,6,23,.3)}}
nav a span{{display:none}} .oc-modal .actions{{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}}
.brand{{font-size:11px}} .iconbtn{{text-decoration:none}}
.topbar h1{{font-size:15px}} @media (max-width:980px){{
main{{padding:10px}} nav{{width:84px;padding:10px 6px}}
nav a span:last-child,.navsection{{display:none}}
.brand{{font-size:.78rem;margin:8px 8px 12px}}
}} }}
@media (max-width:760px){{ @media (max-width:760px){{
body{{display:block}} body{{display:block}}
nav{{position:fixed;left:0;top:0;bottom:0;height:100vh;width:236px;transform:translateX(-105%);transition:transform .2s ease;box-shadow:0 18px 40px rgba(0,0,0,.35)}} nav{{position:fixed;left:0;top:0;bottom:0;width:270px;height:100vh;transform:translateX(-105%);transition:transform .22s ease;box-shadow:0 22px 42px rgba(2,6,23,.45)}}
body.nav-open nav{{transform:translateX(0)}} body.nav-open nav{{transform:translateX(0)}}
.nav-backdrop{{display:none;position:fixed;inset:0;background:rgba(2,6,23,.45);z-index:55}}
body.nav-open .nav-backdrop{{display:block}} body.nav-open .nav-backdrop{{display:block}}
.brand,.navsection{{display:block}} .menu-btn{{display:inline-flex;align-items:center;justify-content:center}}
nav a{{margin:4px 6px;padding:10px 10px}} .topbar{{padding:0 12px}}
nav a span{{display:inline}} main{{padding:12px}}
.menu-btn{{display:inline-block;position:absolute;left:8px;top:8px;transform:none;margin:0}} nav a span:last-child,.navsection{{display:block}}
.app{{padding-bottom:0}}
.topbar{{position:sticky;top:0;padding-left:34px}}
main{{padding:10px 8px}}
.row{{flex-direction:column;align-items:stretch}}
input,select,textarea,button{{width:100%}}
.grid{{min-width:640px}}
}} }}
.modal-backdrop{{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:2000}}
.modal{{width:min(92vw,520px);background:#fff;border:1px solid #cbd5e1;border-radius:14px;padding:14px;box-shadow:0 24px 60px rgba(0,0,0,.25)}}
.modal h4{{margin:0 0 8px 0}}
.modal .actions{{display:flex;gap:8px;justify-content:flex-end;margin-top:10px}}
.modal input,.modal select{{width:100%}}
.iconbtn{{text-decoration:none;font-size:18px;padding:2px 6px;border-radius:8px;border:1px solid #cbd5e1;background:#fff;color:#0f172a}}
</style> </style>
<script> <script>
if ('serviceWorker' in navigator) {{ window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{{}})); }} if ('serviceWorker' in navigator) {{ window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{{}})); }}
@@ -221,30 +204,54 @@ window.uiSelect = async function(title, options, placeholder='') {{
const opts = options.map(o=>`<option value="${{o.value}}">${{o.label}}</option>`).join(''); const opts = options.map(o=>`<option value="${{o.value}}">${{o.label}}</option>`).join('');
return await window.showModal({{title, html:`<select data-modal-input><option value="">${{placeholder}}</option>${{opts}}</select>`}}); return await window.showModal({{title, html:`<select data-modal-input><option value="">${{placeholder}}</option>${{opts}}</select>`}});
}}; }};
window.applyBootstrap53 = function() {{
document.querySelectorAll('button').forEach(el => {{ if (!el.className.includes('btn') && !el.classList.contains('menu-btn')) el.classList.add('btn','btn-primary'); }});
document.querySelectorAll('input,select,textarea').forEach(el => {{
if (!el.classList.contains('form-control') && !el.classList.contains('form-select')) {{
if (el.tagName === 'SELECT') el.classList.add('form-select');
else el.classList.add('form-control');
}}
}});
document.querySelectorAll('table').forEach(el => {{ if (!el.className.includes('table')) el.classList.add('table','table-sm','table-striped','align-middle'); }});
document.querySelectorAll('.row').forEach(el => {{ if (!el.className.includes('g-2')) el.classList.add('g-2'); }});
}};
window.addEventListener('DOMContentLoaded', () => {{
window.applyBootstrap53();
const p = location.pathname;
document.querySelectorAll('nav a').forEach(a => {{
if (a.getAttribute('href') === p) {{
a.style.background = '#1d2d4a';
a.style.color = '#fff';
a.style.borderColor = '#34507c';
}}
}});
}});
</script> </script>
</head> </head>
<body> <body>
<div id='modal-backdrop' class='modal-backdrop'><div id='modal-box' class='modal'></div></div> <div id='modal-backdrop' class='oc-modal-backdrop'><div id='modal-box' class='oc-modal'></div></div>
<div class='nav-backdrop' onclick='closeNav()'></div> <div class='nav-backdrop' onclick='closeNav()'></div>
<nav> <nav>
<div class='brand'>🎙️ VoiceLog</div> <div class='brand'>VoiceLog</div>
<div class='navsection'>Navigation</div> <div class='navsection'>Workspace</div>
<a href='/' onclick='closeNav()'><span>🏠</span><span>Home / Upload</span></a> <a href='/' onclick='closeNav()'><i class='bi bi-cloud-arrow-up'></i><span>Upload</span></a>
<a href='/library' onclick='closeNav()'><span>📁</span><span>Projekte</span></a> <a href='/library' onclick='closeNav()'><i class='bi bi-folder2-open'></i><span>Library</span></a>
<a href='/prompts' onclick='closeNav()'><span>🧩</span><span>Vorlagen</span></a> <a href='/prompts' onclick='closeNav()'><i class='bi bi-sliders2'></i><span>Prompts & Projekte</span></a>
<div class='navsection'>Automatisierung</div> <div class='navsection'>Automation</div>
<a href='/run' onclick='closeNav()'><span>🤖</span><span>Prompt ausführen</span></a> <a href='/run' onclick='closeNav()'><i class='bi bi-play-circle'></i><span>Prompt ausführen</span></a>
<a href='/jobs' onclick='closeNav()'><span>⏳</span><span>Hintergrundjobs</span></a> <a href='/jobs' onclick='closeNav()'><i class='bi bi-cpu'></i><span>Hintergrundjobs</span></a>
<div class='navsection'>System</div> <div class='navsection'>System</div>
<a href='/healthz' onclick='closeNav()'><span>💚</span><span>Health</span></a> <a href='/healthz' onclick='closeNav()'><i class='bi bi-heart-pulse'></i><span>Health</span></a>
</nav> </nav>
<div class='app'> <div class='app'>
<div class='topbar'> <div class='topbar'>
<button class='menu-btn' type='button' onclick='toggleNav()'></button> <button class='menu-btn' type='button' onclick='toggleNav()'><i class='bi bi-list'></i></button>
<h1>{title}</h1> <h1>{title}</h1>
</div> </div>
<main>{body}</main> <main class='container-fluid py-2'>{body}</main>
</div> </div>
<script src='https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js'></script>
</body></html> </body></html>
""" """
@@ -455,18 +462,28 @@ def upload_page(msg: str = ""):
projects = get_projects() projects = get_projects()
opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects]) opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects])
body = f""" body = f"""
<h2>Audio Upload</h2> <div class='d-flex justify-content-between align-items-center mb-3'>
<p>Audio wird transkribiert + mit Sprechern angereichert und als Dokument gespeichert.</p> <h2 class='h4 mb-0'>Audio Upload</h2>
{f"<p><b>{msg}</b></p>" if msg else ""} </div>
<p class='text-secondary'>Audio wird transkribiert + mit Sprechern angereichert und als Dokument gespeichert.</p>
{f"<div class='alert alert-info py-2'>{msg}</div>" if msg else ""}
<form action='/upload' method='post' enctype='multipart/form-data' class='card'> <form action='/upload' method='post' enctype='multipart/form-data' class='card'>
<div class='row'> <div class='row g-2'>
<label>Projekt:</label> <div class='col-12 col-lg-4'>
<select name='project_id'>{opts}</select> <label class='form-label'>Projekt</label>
<input name='title' placeholder='Titel (optional)'> <select class='form-select' name='project_id'>{opts}</select>
</div>
<div class='col-12 col-lg-4'>
<label class='form-label'>Titel (optional)</label>
<input class='form-control' name='title' placeholder='z. B. Team-Call 23.03'>
</div>
<div class='col-12 col-lg-4'>
<label class='form-label'>Audio-Datei</label>
<input class='form-control' type='file' name='file' accept='audio/*' required>
</div>
</div> </div>
<div class='row' style='margin-top:8px'> <div class='mt-3'>
<input type='file' name='file' accept='audio/*' required> <button class='btn btn-primary' type='submit'>Verarbeiten & speichern</button>
<button type='submit'>Verarbeiten & speichern</button>
</div> </div>
</form> </form>
""" """
@@ -561,66 +578,54 @@ def library(project_id: Optional[str] = None, q_title: str = "", q_content: str
rows = "".join( rows = "".join(
[ [
f"<tr>" f"<tr>"
f"<td class='cb-cell'><input type='checkbox' class='row-cb' value='{d['id']}'></td>"
f"<td>#{d['id']}</td>" f"<td>#{d['id']}</td>"
f"<td>{d['title']}</td>" f"<td>{d['title']}</td>"
f"<td>{d['kind']}</td>" f"<td>{d['kind']}</td>"
f"<td>{d['project']}</td>" f"<td>{d['project']}</td>"
f"<td><small>{d['created_at']}</small></td>" f"<td><small>{d['created_at']}</small></td>"
f"<td class='row'>" f"<td><div class='btn-group btn-group-sm' role='group'>"
f"<a href='/document/{d['id']}' class='iconbtn' title='Ansehen'>👁️</a>" f"<a href='/document/{d['id']}' class='btn btn-outline-secondary' title='Ansehen'>👁️</a>"
f"<a href='/document/{d['id']}/download.md' class='iconbtn' title='Download'>⬇️</a>" f"<a href='/document/{d['id']}/download.md' class='btn btn-outline-secondary' title='Download'>⬇️</a>"
f"<a href='#' class='iconbtn' title='Umbenennen' onclick='libRename({d['id']}, {json.dumps(d['title'])});return false;'>✏️</a>" f"<a href='#' class='btn btn-outline-secondary' title='Umbenennen' onclick='libRename({d['id']}, {json.dumps(d['title'])});return false;'>✏️</a>"
f"<a href='#' class='iconbtn' title='Verschieben' onclick='libMove({d['id']});return false;'>📁</a>" f"<a href='#' class='btn btn-outline-secondary' title='Verschieben' onclick='libMove({d['id']});return false;'>📁</a>"
f"<a href='#' class='iconbtn' title='Löschen' onclick='libDelete({d['id']});return false;'>🗑️</a>" f"<a href='#' class='btn btn-outline-danger' title='Löschen' onclick='libDelete({d['id']});return false;'>🗑️</a>"
f"</td>" f"</div></td>"
f"</tr>" f"</tr>"
for d in docs for d in docs
] ]
) )
project_js = json.dumps([{"value": p["id"], "label": p["name"]} for p in projects], ensure_ascii=False) project_js = json.dumps([{"value": p["id"], "label": p["name"]} for p in projects], ensure_ascii=False)
body = f""" body = f"""
<style> <div class='d-flex justify-content-between align-items-center mb-2'>
.toolbar{{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}} <h2 class='h4 mb-0'>Projekte · Dokumente</h2>
.toolbar .spacer{{flex:1}} <span class='badge text-bg-secondary'>{len(docs)} Treffer</span>
.gridcard{{padding:0;overflow:auto}} </div>
.grid{{width:100%;border-collapse:collapse;min-width:860px}} <div class='text-secondary small mb-2'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div>
.grid th,.grid td{{padding:10px 12px;border-bottom:1px solid #1f2937;text-align:left}} <form method='get' class='card'>
.grid th{{position:sticky;top:0;background:#0b1222;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.04em}} <div class='row g-2 align-items-end'>
.grid tr:hover{{background:rgba(56,189,248,.06)}} <div class='col-12 col-md-3'>
.grid tr.selected{{background:rgba(56,189,248,.12)}} <label class='form-label'>Projekt</label>
.cb-cell{{width:32px;padding:10px 4px 10px 12px}} <select class='form-select' name='project_id'>{p_opts}</select>
.bulk-bar{{display:none;gap:8px;align-items:center;padding:8px 12px;background:rgba(56,189,248,.08);border-radius:6px;margin-bottom:8px}} </div>
.bulk-bar.active{{display:flex}} <div class='col-12 col-md-3'>
</style> <label class='form-label'>Titel enthält</label>
<h2 style='margin-bottom:6px'>Projekte · Dokumente</h2> <input class='form-control' name='q_title' placeholder='Titel enthält …' value='{title_q.replace("'", "&#39;")}'>
<div class='hint' style='margin-bottom:10px'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div> </div>
<form method='get' class='card toolbar'> <div class='col-12 col-md-4'>
<select name='project_id'>{p_opts}</select> <label class='form-label'>Inhalt enthält</label>
<input name='q_title' placeholder='Titel enthält …' value='{title_q.replace("'", "&#39;")}' style='min-width:220px'> <input class='form-control' name='q_content' placeholder='Inhalt enthält …' value='{content_q.replace("'", "&#39;")}'>
<input name='q_content' placeholder='Inhalt enthält …' value='{content_q.replace("'", "&#39;")}' style='min-width:260px'> </div>
<button type='submit'>Filtern</button> <div class='col-6 col-md-1 d-grid'><button class='btn btn-primary' type='submit'>Filtern</button></div>
<a class='iconbtn' href='/library' title='Filter zurücksetzen'>↺</a> <div class='col-6 col-md-1 d-grid'><a class='btn btn-outline-secondary' href='/library'>Reset</a></div>
<div class='spacer'></div> </div>
<small>{len(docs)} Treffer</small>
</form> </form>
<div class='bulk-bar' id='bulkBar'> <div class='card'>
<strong id='bulkCount'>0 ausgewählt</strong> <div class='table-responsive'>
<button type='button' onclick='bulkMove()'>📁 Verschieben</button> <table class='table table-sm table-striped align-middle mb-0'>
<button type='button' onclick='bulkDelete()' style='color:#f87171'>🗑️ Löschen</button> <thead><tr><th>ID</th><th>Titel</th><th>Typ</th><th>Projekt</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
</div> <tbody>{rows or "<tr><td colspan='6' class='text-secondary'>Keine Einträge.</td></tr>"}</tbody>
<div class='card gridcard'> </table>
<table class='grid'> </div>
<thead><tr>
<th class='cb-cell'><input type='checkbox' id='cbAll' title='Alle wählen'></th>
<th>ID</th><th>Titel</th><th>Typ</th><th>Projekt</th><th>Erstellt</th><th>Aktionen</th>
</tr></thead>
<tbody>{rows or "<tr><td colspan='7'>Keine Einträge.</td></tr>"}</tbody>
</table>
</div>
<div style='display:flex;gap:8px;margin-top:8px'>
<button type='button' onclick='selectAll()'>Alle wählen</button>
<button type='button' onclick='selectNone()'>Alle abwählen</button>
</div> </div>
<script> <script>
async function libPost(url, data) {{ async function libPost(url, data) {{
@@ -628,48 +633,6 @@ async function libPost(url, data) {{
if(!r.ok) {{ alert('Fehler '+r.status); return; }} if(!r.ok) {{ alert('Fehler '+r.status); return; }}
location.reload(); location.reload();
}} }}
async function libPostMulti(url, params) {{
const body = new URLSearchParams();
for(const [k,v] of Object.entries(params)) {{
if(Array.isArray(v)) v.forEach(x => body.append(k, x));
else body.append(k, v);
}}
const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body}});
if(!r.ok) {{ alert('Fehler '+r.status); return; }}
location.reload();
}}
function getSelected() {{
return [...document.querySelectorAll('.row-cb:checked')].map(cb => cb.value);
}}
function updateBulkBar() {{
const ids = getSelected();
const bar = document.getElementById('bulkBar');
document.getElementById('bulkCount').textContent = ids.length + ' ausgewählt';
bar.classList.toggle('active', ids.length > 0);
document.getElementById('cbAll').indeterminate = ids.length > 0 && ids.length < document.querySelectorAll('.row-cb').length;
document.getElementById('cbAll').checked = ids.length > 0 && ids.length === document.querySelectorAll('.row-cb').length;
}}
document.querySelectorAll('.row-cb').forEach(cb => {{
cb.addEventListener('change', function() {{
this.closest('tr').classList.toggle('selected', this.checked);
updateBulkBar();
}});
}});
document.getElementById('cbAll').addEventListener('change', function() {{
document.querySelectorAll('.row-cb').forEach(cb => {{
cb.checked = this.checked;
cb.closest('tr').classList.toggle('selected', this.checked);
}});
updateBulkBar();
}});
function selectAll() {{
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=true; cb.closest('tr').classList.add('selected'); }});
updateBulkBar();
}}
function selectNone() {{
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=false; cb.closest('tr').classList.remove('selected'); }});
updateBulkBar();
}}
window.libRename = async function(id, current) {{ window.libRename = async function(id, current) {{
const v = await window.uiPrompt('Dokument umbenennen', current || ''); const v = await window.uiPrompt('Dokument umbenennen', current || '');
if(v===null) return; if(v===null) return;
@@ -686,21 +649,6 @@ window.libDelete = async function(id) {{
if(!ok) return; if(!ok) return;
await libPost(`/document/${{id}}/delete`, {{}}); await libPost(`/document/${{id}}/delete`, {{}});
}}; }};
window.bulkMove = async function() {{
const ids = getSelected();
if(!ids.length) return;
const options = {project_js};
const v = await window.uiSelect(`${{ids.length}} Dokumente verschieben`, options, 'Projekt wählen');
if(v===null || v==='') return;
await libPostMulti('/documents/bulk-move', {{ids, project_id:v}});
}};
window.bulkDelete = async function() {{
const ids = getSelected();
if(!ids.length) return;
const ok = await window.uiConfirm(`${{ids.length}} Dokumente löschen?`);
if(!ok) return;
await libPostMulti('/documents/bulk-delete', {{ids}});
}};
</script> </script>
""" """
return layout("Library", body) return layout("Library", body)
@@ -726,15 +674,18 @@ def view_document(doc_id: int):
projects = get_projects() projects = get_projects()
body = f""" body = f"""
<h2>Dokument #{d['id']} {d['title']}</h2> <div class='d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2'>
<p> <div>
<small>Projekt: {d['project']} · Typ: {d['kind']} · {d['created_at']}</small> <h2 class='h4 mb-1'>Dokument #{d['id']} {d['title']}</h2>
&nbsp;&nbsp; <div class='text-secondary small'>Projekt: {d['project']} · Typ: {d['kind']} · {d['created_at']}</div>
<a title='Download .md' href='/document/{doc_id}/download.md' style='text-decoration:none'>⬇️</a> </div>
<a title='Umbenennen' href='#' onclick='renameDoc();return false;' style='text-decoration:none'>✏️</a> <div class='btn-group btn-group-sm'>
<a title='Verschieben' href='#' onclick='moveDoc();return false;' style='text-decoration:none'>📁</a> <a class='btn btn-outline-secondary' title='Download .md' href='/document/{doc_id}/download.md'>⬇️</a>
<a title='Löschen' href='#' onclick='deleteDoc();return false;' style='text-decoration:none'>🗑</a> <a class='btn btn-outline-secondary' title='Umbenennen' href='#' onclick='renameDoc();return false;'>✏</a>
</p> <a class='btn btn-outline-secondary' title='Verschieben' href='#' onclick='moveDoc();return false;'>📁</a>
<a class='btn btn-outline-danger' title='Löschen' href='#' onclick='deleteDoc();return false;'>🗑️</a>
</div>
</div>
<div class='card mdview'>{rendered}</div> <div class='card mdview'>{rendered}</div>
<script> <script>
@@ -804,69 +755,182 @@ def delete_document(doc_id: int):
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>") return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/documents/bulk-move", response_class=HTMLResponse)
def bulk_move_documents(ids: List[int] = Form(...), project_id: int = Form(...)):
with db() as c:
c.executemany("UPDATE documents SET project_id=? WHERE id=?", [(project_id, i) for i in ids])
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/documents/bulk-delete", response_class=HTMLResponse)
def bulk_delete_documents(ids: List[int] = Form(...)):
with db() as c:
c.executemany("DELETE FROM documents WHERE id=?", [(i,) for i in ids])
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.get("/prompts", response_class=HTMLResponse) @app.get("/prompts", response_class=HTMLResponse)
def prompts_page(): def prompts_page():
with db() as c: with db() as c:
prompts = c.execute("SELECT * FROM prompts ORDER BY name").fetchall() prompts = c.execute("SELECT * FROM prompts ORDER BY name").fetchall()
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall() projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
p_list = "".join( project_opts = "".join([f"<option value='{p['name']}'>{p['name']}</option>" for p in projects])
project_list = "".join([
f"<div class='card'>"
f"<div class='d-flex justify-content-between align-items-center mb-2'><div class='fw-semibold'>{p['name']}</div><span class='badge rounded-pill text-bg-light'>Projekt</span></div>"
f"<form method='post' action='/projects/update'>"
f"<input type='hidden' name='id' value='{p['id']}'>"
f"<div class='row g-2 align-items-end'>"
f"<div class='col-12 col-md-8'><label class='form-label small text-secondary mb-1'>Name</label><input class='form-control' name='name' value='{p['name']}'></div>"
f"<div class='col-12 col-md-4'><button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-pencil-square'></i> Umbenennen</button></div>"
f"</div>"
f"</form>"
f"<form method='post' action='/projects/{p['id']}/delete' onsubmit='return confirm(\"Projekt löschen? Dokumente werden auf Default verschoben.\")' class='mt-2'>"
f"<button class='btn btn-outline-danger btn-sm' type='submit'><i class='bi bi-trash'></i> Löschen</button>"
f"</form>"
f"</div>" for p in projects
]) or "<p class='text-secondary'>Keine Projekte vorhanden.</p>"
prompt_list = "".join(
[ [
f"<div class='card'><b>{p['name']}</b><pre>{(p['prompt'] or '').replace('<','&lt;')}</pre>" f"<div class='card'>"
f"<form method='post' action='/prompts/update'><input type='hidden' name='id' value='{p['id']}'><input name='name' value='{p['name']}'><br><textarea name='prompt'>{p['prompt']}</textarea><br><button>Speichern</button></form>" f"<div class='d-flex justify-content-between align-items-center mb-2'><div class='fw-semibold'>{p['name']}</div><span class='badge rounded-pill text-bg-light'>#{p['id']}</span></div>"
f"<form method='post' action='/prompts/{p['id']}/delete' onsubmit=\"return confirm('Prompt löschen?')\" style='margin-top:6px'><button>Löschen</button></form>" f"<form method='post' action='/prompts/update'>"
f"<input type='hidden' name='id' value='{p['id']}'>"
f"<div class='row g-2'>"
f"<div class='col-12 col-lg-4'><label class='form-label small text-secondary mb-1'>Name</label><input class='form-control' name='name' value='{p['name']}'></div>"
f"<div class='col-12 col-lg-8'>"
f"<div class='d-flex justify-content-between align-items-center'>"
f"<label class='form-label small text-secondary mb-1'>Prompttext</label>"
f"<button type='button' class='btn btn-outline-secondary btn-sm py-0 px-2' onclick='openPromptEditor({p['id']})' title='Vollbild-Editor'><i class='bi bi-arrows-fullscreen'></i></button>"
f"</div>"
f"<textarea id='prompt_text_{p['id']}' class='form-control' name='prompt' style='min-height:110px; resize:vertical'>{p['prompt']}</textarea>"
f"</div>"
f"</div>"
f"<div class='mt-3 d-flex flex-wrap gap-2'>"
f"<button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-check2-circle'></i> Speichern</button>"
f"<button class='btn btn-outline-secondary btn-sm' type='button' onclick='previewPrompt({p['id']})'><i class='bi bi-eye'></i> Anzeigen</button>"
f"</div>"
f"</form>"
f"<form method='post' action='/prompts/{p['id']}/delete' onsubmit='return confirm(\"Prompt löschen?\")' class='mt-2'>"
f"<button class='btn btn-outline-danger btn-sm' type='submit'><i class='bi bi-trash'></i> Löschen</button>"
f"</form>"
f"</div>" f"</div>"
for p in prompts for p in prompts
] ]
) ) or "<p class='text-secondary'>Keine Prompts vorhanden.</p>"
project_opts = "".join([f"<option value='{p['name']}'>{p['name']}</option>" for p in projects])
project_list = "".join([
f"<div class='card'><b>{p['name']}</b>"
f"<form method='post' action='/projects/update' class='row' style='margin-top:6px'>"
f"<input type='hidden' name='id' value='{p['id']}'><input name='name' value='{p['name']}'><button>Umbenennen</button></form>"
f"<form method='post' action='/projects/{p['id']}/delete' onsubmit=\"return confirm('Projekt löschen? Dokumente werden auf Default verschoben.')\" style='margin-top:6px'><button>Löschen</button></form>"
f"</div>" for p in projects
])
body = f""" body = f"""
<h2>Prompt-Konfiguration</h2> <div class='d-flex justify-content-between align-items-center mb-3'>
<div class='card'> <span class='badge text-bg-secondary'>{len(prompts)} Prompts · {len(projects)} Projekte</span>
<form method='post' action='/prompts/add'>
<h4>Neuer Prompt</h4>
<input name='name' placeholder='Name' required>
<br><textarea name='prompt' placeholder='Prompttext' required></textarea>
<br><button type='submit'>Anlegen</button>
</form>
</div> </div>
<div class='card'>
<form method='post' action='/projects'>
<h4>Neues Projekt</h4>
<input name='name' list='projectNames' placeholder='Projektname' required>
<datalist id='projectNames'>{project_opts}</datalist>
<button type='submit'>Anlegen</button>
</form>
</div>
<h3>Projekte</h3>
{project_list}
<h3>Prompts</h3>
{p_list}
"""
return layout("Prompts", body)
<ul class='nav nav-tabs mb-3' id='cfgTabs' role='tablist'>
<li class='nav-item' role='presentation'>
<button class='nav-link active' data-bs-toggle='tab' data-bs-target='#pane-projects' type='button' role='tab'>Projekte</button>
</li>
<li class='nav-item' role='presentation'>
<button class='nav-link' data-bs-toggle='tab' data-bs-target='#pane-prompts' type='button' role='tab'>Prompts</button>
</li>
</ul>
<div class='tab-content'>
<div class='tab-pane fade show active' id='pane-projects' role='tabpanel'>
<div class='card'>
<div class='d-flex align-items-center gap-2 mb-2'><i class='bi bi-folder-plus text-primary'></i><h4 class='h6 mb-0'>Neues Projekt anlegen</h4></div>
<form method='post' action='/projects' class='row g-2 align-items-end'>
<div class='col-12 col-md-8'><label class='form-label small text-secondary mb-1'>Projektname</label><input class='form-control' name='name' list='projectNames' placeholder='Projektname' required></div>
<datalist id='projectNames'>{project_opts}</datalist>
<div class='col-12 col-md-4'><button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-plus-lg'></i> Anlegen</button></div>
</form>
</div>
{project_list}
</div>
<div class='tab-pane fade' id='pane-prompts' role='tabpanel'>
<div class='card'>
<div class='d-flex align-items-center gap-2 mb-2'><i class='bi bi-plus-circle text-primary'></i><h4 class='h6 mb-0'>Neuen Prompt anlegen</h4></div>
<form method='post' action='/prompts/add'>
<div class='mb-2'><label class='form-label small text-secondary mb-1'>Name</label><input class='form-control' name='name' placeholder='z. B. Executive Summary' required></div>
<div class='mb-2'><label class='form-label small text-secondary mb-1'>Prompttext</label><textarea class='form-control' name='prompt' placeholder='Prompttext' required></textarea></div>
<button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-plus-lg'></i> Anlegen</button>
</form>
</div>
{prompt_list}
</div>
</div>
<div class='modal fade' id='promptPreviewModal' tabindex='-1' aria-hidden='true'>
<div class='modal-dialog modal-lg modal-dialog-scrollable'>
<div class='modal-content'>
<div class='modal-header'>
<h5 class='modal-title'>Prompt Vorschau</h5>
<button type='button' class='btn-close' data-bs-dismiss='modal' aria-label='Close'></button>
</div>
<div class='modal-body'>
<div id='promptPreviewBody' class='mdview'>Lade …</div>
</div>
</div>
</div>
</div>
<div class='modal fade' id='promptEditorModal' tabindex='-1' aria-hidden='true'>
<div class='modal-dialog modal-fullscreen'>
<div class='modal-content'>
<div class='modal-header'>
<h5 class='modal-title'>Prompt bearbeiten (Vollbild)</h5>
<button type='button' class='btn btn-outline-secondary btn-sm' onclick='closePromptEditor()'><i class='bi bi-fullscreen-exit'></i> Minimize</button>
</div>
<div class='modal-body'>
<textarea id='promptEditorTextarea' class='form-control' style='height:100%; min-height:70vh; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;'></textarea>
</div>
</div>
</div>
</div>
<script>
let currentPromptEditorId = null;
async function previewPrompt(id) {{
const body = document.getElementById('promptPreviewBody');
body.innerHTML = 'Lade …';
const modalEl = document.getElementById('promptPreviewModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
const r = await fetch('/prompts/' + id + '/preview');
const j = await r.json();
body.innerHTML = j.html || '<p class="text-secondary">Keine Vorschau.</p>';
}}
function openPromptEditor(id) {{
currentPromptEditorId = id;
const src = document.getElementById('prompt_text_' + id);
if(!src) return;
const ta = document.getElementById('promptEditorTextarea');
ta.value = src.value;
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('promptEditorModal'));
modal.show();
}}
function syncPromptEditorBack() {{
const ta = document.getElementById('promptEditorTextarea');
if(currentPromptEditorId !== null) {{
const dst = document.getElementById('prompt_text_' + currentPromptEditorId);
if(dst) dst.value = ta.value;
}}
}}
function closePromptEditor() {{
syncPromptEditorBack();
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('promptEditorModal'));
modal.hide();
}}
document.addEventListener('DOMContentLoaded', () => {{
const el = document.getElementById('promptEditorModal');
if(el) el.addEventListener('hide.bs.modal', syncPromptEditorBack);
}});
</script>
"""
return layout("Prompts & Projekte", body)
@app.get("/prompts/{prompt_id}/preview")
def prompt_preview(prompt_id: int):
with db() as c:
p = c.execute("SELECT id,name,prompt FROM prompts WHERE id=?", (prompt_id,)).fetchone()
if not p:
raise HTTPException(404, "Prompt nicht gefunden")
html = md.markdown(p["prompt"] or "", extensions=["fenced_code", "tables", "nl2br"])
return {"id": p["id"], "name": p["name"], "html": html}
@app.post("/prompts/add", response_class=HTMLResponse) @app.post("/prompts/add", response_class=HTMLResponse)
def prompt_add(name: str = Form(...), prompt: str = Form(...)): def prompt_add(name: str = Form(...), prompt: str = Form(...)):
@@ -977,45 +1041,68 @@ def jobs_delete_form(job_id: int):
@app.get("/jobs", response_class=HTMLResponse) @app.get("/jobs", response_class=HTMLResponse)
def jobs_page(queued: Optional[int] = None): def jobs_page(queued: Optional[int] = None):
items = _jobs_payload(200) items = _jobs_payload(200)
pre = "".join([
(
f"<div class='card'><b>Job #{it['id']}</b> [{it['kind']}] · <b>{it['status']}</b> · läuft: <span class='elapsed' data-start='{(it.get('started_at') or it.get('created_at') or '')}' data-end='{(it.get('finished_at') or '')}'>{_fmt_elapsed(it.get('started_at') or it.get('created_at'), it.get('finished_at'))}</span><br>"
f"<small>{it['created_at']}</small><br>"
f"<div class='row' style='margin-top:8px'>"
+ (
""
if it["status"] in ("done", "error", "cancelled")
else (
f"<form method='post' action='/jobs/{it['id']}/cancel-form' style='display:inline'>"
f"<button class='iconbtn' title='Abbrechen'>⛔</button></form> "
)
)
+ (
f"<form method='post' action='/jobs/{it['id']}/delete-form' style='display:inline'>"
f"<button class='iconbtn' title='Löschen'>🗑️</button></form> "
)
+ (
f"<a href='/document/{it['result_document_id']}'>Ergebnis öffnen</a>"
if it.get("result_document_id")
else ""
)
+ "</div></div>"
)
for it in items
]) or "<p>Keine Jobs.</p>"
notice = f"<p><b>Job #{queued} wurde eingereiht.</b></p>" if queued else "" def _badge(status: str) -> str:
s = (status or "").lower()
if s == "done":
return "success"
if s in ("running", "queued"):
return "primary"
if s == "cancelled":
return "warning"
return "danger"
cards = []
for it in items:
start_ts = it.get("started_at") or it.get("created_at") or ""
end_ts = it.get("finished_at") or ""
actions = ""
if it["status"] not in ("done", "error", "cancelled"):
actions += f"<button class='btn btn-outline-warning btn-sm' onclick='cancelJob({it['id']})'><i class=\'bi bi-x-circle\'></i> Abbrechen</button> "
actions += f"<button class='btn btn-outline-danger btn-sm' onclick='deleteJob({it['id']})'><i class=\'bi bi-trash\'></i> Löschen</button> "
if it.get("result_document_id"):
actions += f"<a class='btn btn-primary btn-sm' href='/document/{it['result_document_id']}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>"
err = f"<div class='alert alert-danger mt-2 mb-0 py-2 small'>{str(it['error']).replace('<','&lt;')}</div>" if it.get("error") else ""
cards.append(
f"<div class='card job-card' data-job-id='{it['id']}'>"
f"<div class='d-flex justify-content-between align-items-center flex-wrap gap-2'>"
f"<div class='d-flex align-items-center gap-2'><input class='form-check-input job-select' type='checkbox' value='{it['id']}'><div><div class='fw-semibold'>Job #{it['id']} · {it['kind']}</div>"
f"<div class='text-secondary small'>erstellt: {it['created_at']} · läuft: <span class='elapsed' data-start='{start_ts}' data-end='{end_ts}'>{_fmt_elapsed(start_ts, end_ts)}</span></div></div></div>"
f"<span class='badge text-bg-{_badge(it['status'])}'>{it['status']}</span></div>"
f"<div class='small mt-2'>"
f"{('Projekt: '+it['project_name']) if it.get('project_name') else ''}"
f"{('<br>Dokument: '+it['document_title']) if it.get('document_title') else ''}"
f"{('<br>Prompt: '+it['prompt_name']) if it.get('prompt_name') else ''}"
f"</div>"
f"<div class='d-flex flex-wrap gap-2 mt-3'>{actions}</div>"
f"{err}"
f"</div>"
)
pre = "".join(cards) or "<p class='text-secondary'>Keine Jobs.</p>"
notice = f"<div class='alert alert-info py-2'><b>Job #{queued} wurde eingereiht.</b></div>" if queued else ""
body = f""" body = f"""
<h2>Hintergrundverarbeitung</h2> <h2 class='h4 mb-2'>Hintergrundverarbeitung</h2>
<p class='hint'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p> <p class='text-secondary small'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p>
{notice} {notice}
<div id='jobs-status' class='hint'>Live-Update aktiv …</div> <div class='card py-2'>
<div class='d-flex flex-wrap gap-2 align-items-center'>
<button class='btn btn-outline-secondary btn-sm' type='button' onclick='selectAllJobs()'><i class='bi bi-check2-square'></i> Alle wählen</button>
<button class='btn btn-outline-secondary btn-sm' type='button' onclick='clearJobSelection()'><i class='bi bi-square'></i> Auswahl löschen</button>
<button class='btn btn-outline-danger btn-sm' type='button' onclick='deleteSelectedJobs()'><i class='bi bi-trash'></i> Ausgewählte löschen</button>
<button class='btn btn-danger btn-sm' type='button' onclick='deleteAllJobs()'><i class='bi bi-trash3'></i> Alle Jobs löschen</button>
<span id='jobs-selected-count' class='text-secondary small ms-auto'>0 ausgewählt</span>
</div>
</div>
<div id='jobs-status' class='text-secondary small mb-2'>Live-Update aktiv …</div>
<div id='jobs-root'>{pre}</div> <div id='jobs-root'>{pre}</div>
<script> <script>
function parseUtcish(ts) {{ function parseUtcish(ts) {{
if(!ts) return NaN; if(!ts) return NaN;
const hasZone = /Z$|[+-]\d\d:\d\d$/.test(ts); const hasZone = /Z$|[+-]\d\d:\d\d$/.test(ts);
return Date.parse(hasZone ? ts : (ts + 'Z')); // DB stores UTC without suffix return Date.parse(hasZone ? ts : (ts + 'Z'));
}} }}
function since(ts, endTs=null) {{ function since(ts, endTs=null) {{
if(!ts) return '-'; if(!ts) return '-';
@@ -1035,43 +1122,42 @@ function tickElapsed() {{
}} }}
async function post(url) {{ async function post(url) {{
const r = await fetch(url, {{method:'POST'}}); const r = await fetch(url, {{method:'POST'}});
if(!r.ok) alert('Fehler '+r.status); if(!r.ok) throw new Error('Fehler '+r.status);
}} }}
async function renderJobs() {{ function selectedJobIds() {{
try {{ return Array.from(document.querySelectorAll('.job-select:checked')).map(el => Number(el.value));
const r = await fetch('/jobs/data');
const j = await r.json();
const root = document.getElementById('jobs-root');
root.innerHTML = '';
if(!j.items.length) {{ root.innerHTML = '<p>Keine Jobs.</p>'; return; }}
for(const it of j.items) {{
const d = document.createElement('div'); d.className='card';
const actions = [];
if(!['done','error','cancelled'].includes(it.status)) actions.push("<a href='#' class='iconbtn' onclick='cancelJob("+it.id+");return false;'>⛔</a>");
actions.push("<a href='#' class='iconbtn' onclick='deleteJob("+it.id+");return false;'>🗑️</a>");
const result = it.result_document_id ? ("<a href='/document/"+it.result_document_id+"'>Ergebnis öffnen</a>") : '';
const err = it.error ? ("<pre>"+String(it.error).replaceAll('<','&lt;')+"</pre>") : '';
const startTs = (it.started_at || it.created_at || '');
const endTs = (it.finished_at || '');
d.innerHTML = "<b>Job #"+it.id+"</b> ["+it.kind+"] · <b>"+it.status+"</b> · läuft: <span class='elapsed' data-start='"+startTs+"' data-end='"+endTs+"'>"+since(startTs, endTs || null)+"</span><br><small>"+it.created_at+"</small><br>"
+(it.project_name?('Projekt: '+it.project_name+'<br>'):'')
+(it.document_title?('Dokument: '+it.document_title+'<br>'):'')
+(it.prompt_name?('Prompt: '+it.prompt_name+'<br>'):'')
+"<div class='row' style='margin-top:8px'>"+actions.join(' ')+" "+result+"</div>"+err;
root.appendChild(d);
}}
tickElapsed();
document.getElementById('jobs-status').textContent = 'Live-Update aktiv';
}} catch(e) {{
document.getElementById('jobs-status').textContent = 'Live-Update Fehler: '+e;
}}
}} }}
async function cancelJob(id) {{ if(!confirm('Job abbrechen?')) return; await post('/jobs/'+id+'/cancel'); renderJobs(); }} function updateSelectionCount() {{
async function deleteJob(id) {{ if(!confirm('Job löschen?')) return; await post('/jobs/'+id+'/delete'); renderJobs(); }} const el = document.getElementById('jobs-selected-count');
renderJobs(); if(!el) return;
tickElapsed(); el.textContent = `${{selectedJobIds().length}} ausgewählt`;
setInterval(tickElapsed, 1000); }}
setInterval(renderJobs, 5000); function selectAllJobs() {{
document.querySelectorAll('.job-select').forEach(el => el.checked = true);
updateSelectionCount();
}}
function clearJobSelection() {{
document.querySelectorAll('.job-select').forEach(el => el.checked = false);
updateSelectionCount();
}}
async function deleteSelectedJobs() {{
const ids = selectedJobIds();
if(!ids.length) return alert('Keine Jobs ausgewählt');
if(!confirm(`${{ids.length}} Jobs wirklich löschen?`)) return;
for (const id of ids) {{ await post('/jobs/'+id+'/delete'); }}
location.reload();
}}
async function deleteAllJobs() {{
const ids = Array.from(document.querySelectorAll('.job-select')).map(el => Number(el.value));
if(!ids.length) return;
if(!confirm(`Wirklich ALLE ${{ids.length}} Jobs löschen?`)) return;
for (const id of ids) {{ await post('/jobs/'+id+'/delete'); }}
location.reload();
}}
async function cancelJob(id) {{ if(!confirm('Job abbrechen?')) return; await post('/jobs/'+id+'/cancel'); location.reload(); }}
async function deleteJob(id) {{ if(!confirm('Job löschen?')) return; await post('/jobs/'+id+'/delete'); location.reload(); }}
document.addEventListener('change', (e) => {{ if(e.target && e.target.classList && e.target.classList.contains('job-select')) updateSelectionCount(); }});
setInterval(tickElapsed, 1000); tickElapsed(); updateSelectionCount();
</script> </script>
""" """
return layout("Jobs", body) return layout("Jobs", body)
@@ -1087,13 +1173,17 @@ def run_page():
p_opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in prompts]) p_opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in prompts])
body = f""" body = f"""
<h2>Prompt ausführen</h2> <h2 class='h4 mb-3'>Prompt ausführen</h2>
<form method='post' action='/run' class='card'> <form method='post' action='/run' class='card'>
<label>Dokument:</label><br> <div class='mb-2'>
<select name='document_id' style='width:100%'>{d_opts}</select><br><br> <label class='form-label'>Dokument</label>
<label>Prompt:</label><br> <select class='form-select' name='document_id'>{d_opts}</select>
<select name='prompt_id' style='width:100%'>{p_opts}</select><br><br> </div>
<button type='submit'>Ausführen (Qwen)</button> <div class='mb-2'>
<label class='form-label'>Prompt</label>
<select class='form-select' name='prompt_id'>{p_opts}</select>
</div>
<button class='btn btn-primary' type='submit'>Ausführen (Qwen)</button>
</form> </form>
""" """
return layout("Run", body) return layout("Run", body)