feat(ui): latest VoiceLog UI update set
This commit is contained in:
BIN
__pycache__/app.cpython-312.pyc
Normal file
BIN
__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
708
app.py
708
app.py
@@ -5,7 +5,7 @@ import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
import markdown as md
|
||||
import requests
|
||||
@@ -127,68 +127,51 @@ def layout(title: str, body: str) -> str:
|
||||
<meta name='theme-color' content='#0f172a'>
|
||||
<link rel='manifest' href='/manifest.webmanifest'>
|
||||
<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>
|
||||
<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}}
|
||||
body{{font-family:Inter,system-ui,Arial;margin:0;background:#f1f5f9;color:var(--txt);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}}
|
||||
.brand{{font-weight:800;letter-spacing:.2px;margin:4px 8px 12px 8px;color:#c7d2fe}}
|
||||
.navsection{{margin:12px 8px 6px 8px;color:#94a3b8;font-size:12px;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:hover{{background:#0f1b36;border-color:#1e2a4a}}
|
||||
body{{font-family:Inter,system-ui,Arial,sans-serif;background:var(--page);color:var(--text);margin:0;display:flex;min-height:100vh}}
|
||||
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:6px 10px 14px;color:#dbeafe;font-size:1.05rem}}
|
||||
.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:#cbd5e1;text-decoration:none;padding:10px 12px;border-radius:12px;margin:4px 6px;border:1px solid transparent;font-weight:500}}
|
||||
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}}
|
||||
.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 h1{{font-size:18px;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}}
|
||||
.nav-backdrop{{display:none;position:fixed;inset:0;background:rgba(2,6,23,.35);z-index:55}}
|
||||
.topbar .right{{margin-left:auto;display:flex;gap:8px;color:#64748b}}
|
||||
main{{padding:14px;max-width:1400px}}
|
||||
.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)}}
|
||||
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}}
|
||||
.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:1.02rem;margin:0;font-weight:700}}
|
||||
.menu-btn{{display:none;border:1px solid #dbe3ef;background:#fff;color:#0f172a;border-radius:10px;padding:.35rem .55rem;font-size:1rem;line-height:1}}
|
||||
main{{padding:18px;max-width:1400px;width:100%;margin:0 auto}}
|
||||
.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)}}
|
||||
pre{{white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;border:1px solid #1e293b}}
|
||||
.mdview{{line-height:1.6}}
|
||||
.mdview blockquote{{border-left:3px solid #94a3b8;padding-left:10px;color:#334155}}
|
||||
.row{{display:flex;gap:8px;flex-wrap:wrap;align-items:center}}
|
||||
small{{color:var(--muted)}}
|
||||
.hint{{color:var(--muted);font-size:13px}}
|
||||
@media (max-width:1100px){{
|
||||
nav{{width:84px;padding:8px}}
|
||||
nav a span{{display:none}}
|
||||
.brand{{font-size:11px}}
|
||||
.topbar h1{{font-size:15px}}
|
||||
main{{padding:10px}}
|
||||
.hint,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%}}
|
||||
.btn-group>.btn{{width:auto}}
|
||||
.oc-modal-backdrop{{position:fixed;inset:0;background:rgba(2,6,23,.55);display:none;align-items:center;justify-content:center;z-index:2000}}
|
||||
.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)}}
|
||||
.oc-modal .actions{{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}}
|
||||
.iconbtn{{text-decoration:none}}
|
||||
@media (max-width:980px){{
|
||||
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){{
|
||||
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)}}
|
||||
.nav-backdrop{{display:none;position:fixed;inset:0;background:rgba(2,6,23,.45);z-index:55}}
|
||||
body.nav-open .nav-backdrop{{display:block}}
|
||||
.brand,.navsection{{display:block}}
|
||||
nav a{{margin:4px 6px;padding:10px 10px}}
|
||||
nav a span{{display:inline}}
|
||||
.menu-btn{{display:inline-block;position:absolute;left:8px;top:8px;transform:none;margin:0}}
|
||||
.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}}
|
||||
.menu-btn{{display:inline-flex;align-items:center;justify-content:center}}
|
||||
.topbar{{padding:0 12px}}
|
||||
main{{padding:12px}}
|
||||
nav a span:last-child,.navsection{{display:block}}
|
||||
}}
|
||||
.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>
|
||||
<script>
|
||||
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('');
|
||||
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>
|
||||
</head>
|
||||
<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>
|
||||
<nav>
|
||||
<div class='brand'>🎙️ VoiceLog</div>
|
||||
<div class='navsection'>Navigation</div>
|
||||
<a href='/' onclick='closeNav()'><span>🏠</span><span>Home / Upload</span></a>
|
||||
<a href='/library' onclick='closeNav()'><span>📁</span><span>Projekte</span></a>
|
||||
<a href='/prompts' onclick='closeNav()'><span>🧩</span><span>Vorlagen</span></a>
|
||||
<div class='navsection'>Automatisierung</div>
|
||||
<a href='/run' onclick='closeNav()'><span>🤖</span><span>Prompt ausführen</span></a>
|
||||
<a href='/jobs' onclick='closeNav()'><span>⏳</span><span>Hintergrundjobs</span></a>
|
||||
<div class='brand'>VoiceLog</div>
|
||||
<div class='navsection'>Workspace</div>
|
||||
<a href='/' onclick='closeNav()'><i class='bi bi-cloud-arrow-up'></i><span>Upload</span></a>
|
||||
<a href='/library' onclick='closeNav()'><i class='bi bi-folder2-open'></i><span>Library</span></a>
|
||||
<a href='/prompts' onclick='closeNav()'><i class='bi bi-sliders2'></i><span>Prompts & Projekte</span></a>
|
||||
<div class='navsection'>Automation</div>
|
||||
<a href='/run' onclick='closeNav()'><i class='bi bi-play-circle'></i><span>Prompt ausführen</span></a>
|
||||
<a href='/jobs' onclick='closeNav()'><i class='bi bi-cpu'></i><span>Hintergrundjobs</span></a>
|
||||
<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>
|
||||
<div class='app'>
|
||||
<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>
|
||||
</div>
|
||||
<main>{body}</main>
|
||||
<main class='container-fluid py-2'>{body}</main>
|
||||
</div>
|
||||
<script src='https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js'></script>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
@@ -455,18 +462,28 @@ def upload_page(msg: str = ""):
|
||||
projects = get_projects()
|
||||
opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects])
|
||||
body = f"""
|
||||
<h2>Audio Upload</h2>
|
||||
<p>Audio wird transkribiert + mit Sprechern angereichert und als Dokument gespeichert.</p>
|
||||
{f"<p><b>{msg}</b></p>" if msg else ""}
|
||||
<div class='d-flex justify-content-between align-items-center mb-3'>
|
||||
<h2 class='h4 mb-0'>Audio Upload</h2>
|
||||
</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'>
|
||||
<div class='row'>
|
||||
<label>Projekt:</label>
|
||||
<select name='project_id'>{opts}</select>
|
||||
<input name='title' placeholder='Titel (optional)'>
|
||||
<div class='row g-2'>
|
||||
<div class='col-12 col-lg-4'>
|
||||
<label class='form-label'>Projekt</label>
|
||||
<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 class='row' style='margin-top:8px'>
|
||||
<input type='file' name='file' accept='audio/*' required>
|
||||
<button type='submit'>Verarbeiten & speichern</button>
|
||||
<div class='mt-3'>
|
||||
<button class='btn btn-primary' type='submit'>Verarbeiten & speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
"""
|
||||
@@ -561,66 +578,54 @@ def library(project_id: Optional[str] = None, q_title: str = "", q_content: str
|
||||
rows = "".join(
|
||||
[
|
||||
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['title']}</td>"
|
||||
f"<td>{d['kind']}</td>"
|
||||
f"<td>{d['project']}</td>"
|
||||
f"<td><small>{d['created_at']}</small></td>"
|
||||
f"<td class='row'>"
|
||||
f"<a href='/document/{d['id']}' class='iconbtn' title='Ansehen'>👁️</a>"
|
||||
f"<a href='/document/{d['id']}/download.md' class='iconbtn' 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='iconbtn' 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"</td>"
|
||||
f"<td><div class='btn-group btn-group-sm' role='group'>"
|
||||
f"<a href='/document/{d['id']}' class='btn btn-outline-secondary' title='Ansehen'>👁️</a>"
|
||||
f"<a href='/document/{d['id']}/download.md' class='btn btn-outline-secondary' title='Download'>⬇️</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='btn btn-outline-secondary' title='Verschieben' onclick='libMove({d['id']});return false;'>📁</a>"
|
||||
f"<a href='#' class='btn btn-outline-danger' title='Löschen' onclick='libDelete({d['id']});return false;'>🗑️</a>"
|
||||
f"</div></td>"
|
||||
f"</tr>"
|
||||
for d in docs
|
||||
]
|
||||
)
|
||||
project_js = json.dumps([{"value": p["id"], "label": p["name"]} for p in projects], ensure_ascii=False)
|
||||
body = f"""
|
||||
<style>
|
||||
.toolbar{{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}}
|
||||
.toolbar .spacer{{flex:1}}
|
||||
.gridcard{{padding:0;overflow:auto}}
|
||||
.grid{{width:100%;border-collapse:collapse;min-width:860px}}
|
||||
.grid th,.grid td{{padding:10px 12px;border-bottom:1px solid #1f2937;text-align:left}}
|
||||
.grid th{{position:sticky;top:0;background:#0b1222;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.04em}}
|
||||
.grid tr:hover{{background:rgba(56,189,248,.06)}}
|
||||
.grid tr.selected{{background:rgba(56,189,248,.12)}}
|
||||
.cb-cell{{width:32px;padding:10px 4px 10px 12px}}
|
||||
.bulk-bar{{display:none;gap:8px;align-items:center;padding:8px 12px;background:rgba(56,189,248,.08);border-radius:6px;margin-bottom:8px}}
|
||||
.bulk-bar.active{{display:flex}}
|
||||
</style>
|
||||
<h2 style='margin-bottom:6px'>Projekte · Dokumente</h2>
|
||||
<div class='hint' style='margin-bottom:10px'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div>
|
||||
<form method='get' class='card toolbar'>
|
||||
<select name='project_id'>{p_opts}</select>
|
||||
<input name='q_title' placeholder='Titel enthält …' value='{title_q.replace("'", "'")}' style='min-width:220px'>
|
||||
<input name='q_content' placeholder='Inhalt enthält …' value='{content_q.replace("'", "'")}' style='min-width:260px'>
|
||||
<button type='submit'>Filtern</button>
|
||||
<a class='iconbtn' href='/library' title='Filter zurücksetzen'>↺</a>
|
||||
<div class='spacer'></div>
|
||||
<small>{len(docs)} Treffer</small>
|
||||
<div class='d-flex justify-content-between align-items-center mb-2'>
|
||||
<h2 class='h4 mb-0'>Projekte · Dokumente</h2>
|
||||
<span class='badge text-bg-secondary'>{len(docs)} Treffer</span>
|
||||
</div>
|
||||
<div class='text-secondary small mb-2'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div>
|
||||
<form method='get' class='card'>
|
||||
<div class='row g-2 align-items-end'>
|
||||
<div class='col-12 col-md-3'>
|
||||
<label class='form-label'>Projekt</label>
|
||||
<select class='form-select' name='project_id'>{p_opts}</select>
|
||||
</div>
|
||||
<div class='col-12 col-md-3'>
|
||||
<label class='form-label'>Titel enthält</label>
|
||||
<input class='form-control' name='q_title' placeholder='Titel enthält …' value='{title_q.replace("'", "'")}'>
|
||||
</div>
|
||||
<div class='col-12 col-md-4'>
|
||||
<label class='form-label'>Inhalt enthält</label>
|
||||
<input class='form-control' name='q_content' placeholder='Inhalt enthält …' value='{content_q.replace("'", "'")}'>
|
||||
</div>
|
||||
<div class='col-6 col-md-1 d-grid'><button class='btn btn-primary' type='submit'>Filtern</button></div>
|
||||
<div class='col-6 col-md-1 d-grid'><a class='btn btn-outline-secondary' href='/library'>Reset</a></div>
|
||||
</div>
|
||||
</form>
|
||||
<div class='bulk-bar' id='bulkBar'>
|
||||
<strong id='bulkCount'>0 ausgewählt</strong>
|
||||
<button type='button' onclick='bulkMove()'>📁 Verschieben</button>
|
||||
<button type='button' onclick='bulkDelete()' style='color:#f87171'>🗑️ Löschen</button>
|
||||
</div>
|
||||
<div class='card gridcard'>
|
||||
<table class='grid'>
|
||||
<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 class='card'>
|
||||
<div class='table-responsive'>
|
||||
<table class='table table-sm table-striped align-middle mb-0'>
|
||||
<thead><tr><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='6' class='text-secondary'>Keine Einträge.</td></tr>"}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function libPost(url, data) {{
|
||||
@@ -628,48 +633,6 @@ async function libPost(url, data) {{
|
||||
if(!r.ok) {{ alert('Fehler '+r.status); return; }}
|
||||
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) {{
|
||||
const v = await window.uiPrompt('Dokument umbenennen', current || '');
|
||||
if(v===null) return;
|
||||
@@ -686,21 +649,6 @@ window.libDelete = async function(id) {{
|
||||
if(!ok) return;
|
||||
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>
|
||||
"""
|
||||
return layout("Library", body)
|
||||
@@ -726,15 +674,18 @@ def view_document(doc_id: int):
|
||||
projects = get_projects()
|
||||
|
||||
body = f"""
|
||||
<h2>Dokument #{d['id']} – {d['title']}</h2>
|
||||
<p>
|
||||
<small>Projekt: {d['project']} · Typ: {d['kind']} · {d['created_at']}</small>
|
||||
|
||||
<a title='Download .md' href='/document/{doc_id}/download.md' style='text-decoration:none'>⬇️</a>
|
||||
<a title='Umbenennen' href='#' onclick='renameDoc();return false;' style='text-decoration:none'>✏️</a>
|
||||
<a title='Verschieben' href='#' onclick='moveDoc();return false;' style='text-decoration:none'>📁</a>
|
||||
<a title='Löschen' href='#' onclick='deleteDoc();return false;' style='text-decoration:none'>🗑️</a>
|
||||
</p>
|
||||
<div class='d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2'>
|
||||
<div>
|
||||
<h2 class='h4 mb-1'>Dokument #{d['id']} – {d['title']}</h2>
|
||||
<div class='text-secondary small'>Projekt: {d['project']} · Typ: {d['kind']} · {d['created_at']}</div>
|
||||
</div>
|
||||
<div class='btn-group btn-group-sm'>
|
||||
<a class='btn btn-outline-secondary' title='Download .md' href='/document/{doc_id}/download.md'>⬇️</a>
|
||||
<a class='btn btn-outline-secondary' title='Umbenennen' href='#' onclick='renameDoc();return false;'>✏️</a>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
@@ -804,69 +755,182 @@ def delete_document(doc_id: int):
|
||||
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)
|
||||
def prompts_page():
|
||||
with db() as c:
|
||||
prompts = c.execute("SELECT * FROM prompts 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('<','<')}</pre>"
|
||||
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"<form method='post' action='/prompts/{p['id']}/delete' onsubmit=\"return confirm('Prompt löschen?')\" style='margin-top:6px'><button>Löschen</button></form>"
|
||||
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'>#{p['id']}</span></div>"
|
||||
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>"
|
||||
for p in prompts
|
||||
]
|
||||
)
|
||||
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
|
||||
])
|
||||
) or "<p class='text-secondary'>Keine Prompts vorhanden.</p>"
|
||||
|
||||
body = f"""
|
||||
<h2>Prompt-Konfiguration</h2>
|
||||
<div class='card'>
|
||||
<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 class='d-flex justify-content-between align-items-center mb-3'>
|
||||
<span class='badge text-bg-secondary'>{len(prompts)} Prompts · {len(projects)} Projekte</span>
|
||||
</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)
|
||||
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)
|
||||
def jobs_page(queued: Optional[int] = None):
|
||||
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('<','<')}</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"""
|
||||
<h2>Hintergrundverarbeitung</h2>
|
||||
<p class='hint'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p>
|
||||
<h2 class='h4 mb-2'>Hintergrundverarbeitung</h2>
|
||||
<p class='text-secondary small'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p>
|
||||
{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>
|
||||
<script>
|
||||
function parseUtcish(ts) {{
|
||||
if(!ts) return NaN;
|
||||
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) {{
|
||||
if(!ts) return '-';
|
||||
@@ -1035,43 +1122,42 @@ function tickElapsed() {{
|
||||
}}
|
||||
async function post(url) {{
|
||||
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() {{
|
||||
try {{
|
||||
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('<','<')+"</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;
|
||||
}}
|
||||
function selectedJobIds() {{
|
||||
return Array.from(document.querySelectorAll('.job-select:checked')).map(el => Number(el.value));
|
||||
}}
|
||||
async function cancelJob(id) {{ if(!confirm('Job abbrechen?')) return; await post('/jobs/'+id+'/cancel'); renderJobs(); }}
|
||||
async function deleteJob(id) {{ if(!confirm('Job löschen?')) return; await post('/jobs/'+id+'/delete'); renderJobs(); }}
|
||||
renderJobs();
|
||||
tickElapsed();
|
||||
setInterval(tickElapsed, 1000);
|
||||
setInterval(renderJobs, 5000);
|
||||
function updateSelectionCount() {{
|
||||
const el = document.getElementById('jobs-selected-count');
|
||||
if(!el) return;
|
||||
el.textContent = `${{selectedJobIds().length}} ausgewählt`;
|
||||
}}
|
||||
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>
|
||||
"""
|
||||
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])
|
||||
|
||||
body = f"""
|
||||
<h2>Prompt ausführen</h2>
|
||||
<h2 class='h4 mb-3'>Prompt ausführen</h2>
|
||||
<form method='post' action='/run' class='card'>
|
||||
<label>Dokument:</label><br>
|
||||
<select name='document_id' style='width:100%'>{d_opts}</select><br><br>
|
||||
<label>Prompt:</label><br>
|
||||
<select name='prompt_id' style='width:100%'>{p_opts}</select><br><br>
|
||||
<button type='submit'>Ausführen (Qwen)</button>
|
||||
<div class='mb-2'>
|
||||
<label class='form-label'>Dokument</label>
|
||||
<select class='form-select' name='document_id'>{d_opts}</select>
|
||||
</div>
|
||||
<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>
|
||||
"""
|
||||
return layout("Run", body)
|
||||
|
||||
Reference in New Issue
Block a user