Files
diarization-ui/app.py

1547 lines
67 KiB
Python
Raw Normal View History

import json
import os
import sqlite3
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import markdown as md
import requests
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, PlainTextResponse, Response, JSONResponse, RedirectResponse
API_BASE = os.getenv("API_BASE", "http://gx10.aquantico.lan:8093").rstrip("/")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://gx10.aquantico.lan:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:9b")
DB_PATH = os.getenv("DB_PATH", "/data/ui.db")
app = FastAPI(title="Diarization UI")
JOB_DIR = Path(os.getenv("JOB_DIR", "/data/jobs"))
EXECUTOR = ThreadPoolExecutor(max_workers=2)
JOB_LOCK = threading.Lock()
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def now_iso() -> str:
return datetime.utcnow().isoformat()
def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
with db() as c:
c.execute(
"""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
prompt TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
kind TEXT NOT NULL, -- transcript|analysis
title TEXT NOT NULL,
content_md TEXT NOT NULL,
source_document_id INTEGER,
prompt_id INTEGER,
raw_json TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id),
FOREIGN KEY(source_document_id) REFERENCES documents(id),
FOREIGN KEY(prompt_id) REFERENCES prompts(id)
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- upload|analysis
status TEXT NOT NULL, -- queued|running|done|error
project_id INTEGER,
document_id INTEGER,
prompt_id INTEGER,
title TEXT,
file_path TEXT,
error TEXT,
result_document_id INTEGER,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
)
"""
)
# migrations
try:
c.execute("ALTER TABLE jobs ADD COLUMN user_prompt TEXT")
except Exception:
pass
# defaults
c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
c.execute(
"INSERT OR IGNORE INTO prompts(name, prompt, created_at, updated_at) VALUES (?,?,?,?)",
(
"Zusammenfassung",
"Erstelle eine prägnante Zusammenfassung des Gesprächs in Stichpunkten.",
now_iso(),
now_iso(),
),
)
c.execute(
"INSERT OR IGNORE INTO prompts(name, prompt, created_at, updated_at) VALUES (?,?,?,?)",
(
"Aufgaben",
"Extrahiere alle Aufgaben. Gib pro Aufgabe: Verantwortlich, Aufgabe, Deadline (falls vorhanden), Priorität.",
now_iso(),
now_iso(),
),
)
def layout(title: str, body: str) -> str:
return f"""
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover'>
<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{{--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,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: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}}
.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;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}}
.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}}
}}
</style>
<script>
if ('serviceWorker' in navigator) {{ window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{{}})); }}
window.toggleNav = function() {{ document.body.classList.toggle('nav-open'); }};
window.closeNav = function() {{ document.body.classList.remove('nav-open'); }};
window.showModal = function({{title='Eingabe', html='', ok='OK', cancel='Abbrechen'}}) {{
return new Promise((resolve) => {{
const bd=document.getElementById('modal-backdrop');
const box=document.getElementById('modal-box');
box.innerHTML = `<h4>${{title}}</h4><div>${{html}}</div><div class='actions'><button id='m-cancel' type='button' style='background:#334155;color:#fff'>${{cancel}}</button><button id='m-ok' type='button'>${{ok}}</button></div>`;
bd.style.display='flex';
const close=(v)=>{{bd.style.display='none'; resolve(v);}};
box.querySelector('#m-cancel').onclick=()=>close(null);
box.querySelector('#m-ok').onclick=()=>{{
const inp=box.querySelector('[data-modal-input]');
if(inp) close(inp.value); else close(true);
}};
}});
}};
window.uiPrompt = async function(title, value='') {{
const v = await window.showModal({{title, html:`<input data-modal-input value="${{String(value).replace(/"/g,'&quot;')}}">`}});
return v;
}};
window.uiConfirm = async function(title) {{
const v = await window.showModal({{title, html:`<p class='hint'>Bitte bestätigen.</p>`, ok:'Ja', cancel:'Nein'}});
return v !== null;
}};
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='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'>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()'><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()'><i class='bi bi-list'></i></button>
<h1>{title}</h1>
</div>
<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>
"""
def get_projects():
with db() as c:
return c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
def get_project_name(project_id: int) -> str:
with db() as c:
r = c.execute("SELECT name FROM projects WHERE id=?", (project_id,)).fetchone()
return r[0] if r else ""
def get_prompts():
with db() as c:
return c.execute("SELECT id,name,prompt FROM prompts ORDER BY name").fetchall()
def _job_get(job_id: int):
with db() as c:
row = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return dict(row) if row else None
def _job_set(job_id: int, **fields):
if not fields:
return
cols = ", ".join([f"{k}=?" for k in fields.keys()])
vals = list(fields.values()) + [job_id]
with db() as c:
c.execute(f"UPDATE jobs SET {cols} WHERE id=?", vals)
def _process_upload_job(job_id: int):
j = _job_get(job_id)
if not j:
return
if j["status"] == "cancelled":
return
_job_set(job_id, status="running", started_at=now_iso())
try:
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
p = Path(j["file_path"])
data = p.read_bytes()
filename = p.name
files = {"file": (filename, data, "application/octet-stream")}
r = requests.post(f"{API_BASE}/transcribe-diarize", files=files, timeout=1800)
r.raise_for_status()
payload = r.json()
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
content_md = payload.get("formatted_text", "")
doc_title = (j["title"] or "").strip() or filename
with db() as c:
cur = c.execute(
"INSERT INTO documents(project_id, kind, title, content_md, raw_json, created_at) VALUES (?,?,?,?,?,?)",
(j["project_id"], "transcript", doc_title, content_md, json.dumps(payload, ensure_ascii=False), now_iso()),
)
new_doc_id = cur.lastrowid
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso())
except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
def _process_analysis_job(job_id: int):
j = _job_get(job_id)
if not j:
return
if j["status"] == "cancelled":
return
_job_set(job_id, status="running", started_at=now_iso())
try:
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
with db() as c:
doc = c.execute("SELECT * FROM documents WHERE id=?", (j["document_id"],)).fetchone()
prm = c.execute("SELECT * FROM prompts WHERE id=?", (j["prompt_id"],)).fetchone()
if not doc or not prm:
raise RuntimeError("Dokument oder Prompt nicht gefunden")
user_extra = (j.get("user_prompt") or "").strip()
llm_prompt = (
"Du bist ein präziser Assistent. Antworte auf Deutsch.\\n"
f"AUFTRAG:\\n{prm['prompt']}\\n"
+ (f"\\nZUSATZINFOS:\\n{user_extra}\\n" if user_extra else "")
+ f"\\nTEXT:\\n{doc['content_md']}\\n"
)
r = requests.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={"model": OLLAMA_MODEL, "prompt": llm_prompt, "stream": False},
timeout=1200,
)
r.raise_for_status()
answer = r.json().get("response", "")
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
with db() as c:
cur = c.execute(
"""
INSERT INTO documents(project_id, kind, title, content_md, source_document_id, prompt_id, raw_json, created_at)
VALUES (?,?,?,?,?,?,?,?)
""",
(
doc["project_id"],
"analysis",
f"Analyse: {prm['name']} · {doc['title']}",
answer,
doc["id"],
prm["id"],
json.dumps({"ollama_response": r.json()}, ensure_ascii=False),
now_iso(),
),
)
new_doc_id = cur.lastrowid
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso())
except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
def enqueue_job(kind: str, **kwargs) -> int:
with JOB_LOCK:
with db() as c:
cur = c.execute(
"""
INSERT INTO jobs(kind,status,project_id,document_id,prompt_id,title,file_path,user_prompt,created_at)
VALUES (?,?,?,?,?,?,?,?,?)
""",
(
kind,
"queued",
kwargs.get("project_id"),
kwargs.get("document_id"),
kwargs.get("prompt_id"),
kwargs.get("title"),
kwargs.get("file_path"),
kwargs.get("user_prompt") or None,
now_iso(),
),
)
job_id = cur.lastrowid
if kind == "upload":
EXECUTOR.submit(_process_upload_job, job_id)
elif kind == "analysis":
EXECUTOR.submit(_process_analysis_job, job_id)
return job_id
@app.on_event("startup")
def startup():
init_db()
JOB_DIR.mkdir(parents=True, exist_ok=True)
@app.get("/manifest.webmanifest")
def manifest():
return {
"name": "VoiceLog",
"short_name": "VoiceLog",
"start_url": "/",
"display": "standalone",
"background_color": "#020617",
"theme_color": "#0f172a",
"icons": [
{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
],
}
@app.get("/icon.svg")
def icon_svg():
svg = """<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'><rect rx='24' width='128' height='128' fill='#0f172a'/><text x='64' y='82' font-size='72' text-anchor='middle'>🎙️</text></svg>"""
return Response(content=svg, media_type="image/svg+xml")
@app.get("/sw.js")
def sw_js():
js = """
self.addEventListener('install', (event) => { self.skipWaiting(); });
self.addEventListener('activate', (event) => { event.waitUntil(clients.claim()); });
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(fetch(event.request).catch(() => new Response('offline', {status: 503})));
});
"""
return Response(content=js, media_type="application/javascript")
@app.get("/healthz")
def healthz():
return {"ok": True, "api_base": API_BASE, "ollama_base_url": OLLAMA_BASE_URL, "ollama_model": OLLAMA_MODEL, "db_path": DB_PATH}
@app.get("/", response_class=HTMLResponse)
def upload_page(msg: str = ""):
projects = get_projects()
existing_names = json.dumps([p["name"] for p in projects], ensure_ascii=False)
existing_map = json.dumps({p["name"]: p["id"] for p in projects}, ensure_ascii=False)
body = f"""
<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'>Mehrere Audiodateien gleichzeitig hochladen je Datei wird ein Transkriptions-Job erstellt.</p>
{f"<div class='alert alert-info py-2'>{msg}</div>" if msg else ""}
<div class='card'>
<div class='row g-3 mb-3'>
<div class='col-12 col-md-5'>
<label class='form-label'>Projekt <span class='text-secondary fw-normal'>(bestehendes wählen oder neuen Namen eingeben)</span></label>
<input class='form-control' id='projectInput' list='projectList' placeholder='Projekt wählen oder anlegen …' autocomplete='off'>
<datalist id='projectList'></datalist>
</div>
<div class='col-12 col-md-7'>
<label class='form-label'>Audio-Dateien</label>
<input class='form-control' type='file' id='fileInput' accept='audio/*' multiple required>
<div class='form-text'>Mehrere Dateien: <kbd>Strg</kbd> / <kbd>Cmd</kbd> gedrückt halten beim Auswählen</div>
</div>
</div>
<div id='filePreview' class='mb-3 d-none'>
<div class='text-secondary small mb-1'>Warteschlange:</div>
<ul id='fileList' class='list-group list-group-flush'></ul>
</div>
<button class='btn btn-primary' id='uploadBtn' onclick='startUpload()' disabled>
<i class='bi bi-cloud-upload'></i> <span id='uploadBtnLabel'>Hochladen & verarbeiten</span>
</button>
</div>
<div id='uploadProgress' class='d-none'>
<div class='progress mb-2' style='height:8px'>
<div class='progress-bar progress-bar-striped progress-bar-animated' id='progressBar' style='width:0%'></div>
</div>
<div id='progressStatus' class='text-secondary small'></div>
</div>
<script>
const _existingNames = {existing_names};
const _existingMap = {existing_map};
const datalist = document.getElementById('projectList');
_existingNames.forEach(n => {{ const o = document.createElement('option'); o.value = n; datalist.appendChild(o); }});
const fileInput = document.getElementById('fileInput');
const projectInput = document.getElementById('projectInput');
const uploadBtn = document.getElementById('uploadBtn');
function updateBtn() {{
uploadBtn.disabled = !(fileInput.files.length > 0 && projectInput.value.trim());
}}
fileInput.addEventListener('change', () => {{
updateBtn();
const list = document.getElementById('fileList');
list.innerHTML = '';
Array.from(fileInput.files).forEach(f => {{
const stem = f.name.replace(/\\.[^/.]+$/, '');
const li = document.createElement('li');
li.className = 'list-group-item py-1 small d-flex justify-content-between';
li.innerHTML = `<span><i class='bi bi-file-earmark-music'></i> ${{stem}}</span><span class='text-secondary'>${{(f.size/1024/1024).toFixed(1)}} MB</span>`;
list.appendChild(li);
}});
document.getElementById('filePreview').classList.toggle('d-none', fileInput.files.length === 0);
}});
projectInput.addEventListener('input', updateBtn);
async function startUpload() {{
const name = projectInput.value.trim();
if(!name || fileInput.files.length === 0) return;
uploadBtn.disabled = true;
document.getElementById('uploadProgress').classList.remove('d-none');
const files = Array.from(fileInput.files);
const bar = document.getElementById('progressBar');
const status = document.getElementById('progressStatus');
// resolve or create project
let projectId = _existingMap[name];
if(!projectId) {{
status.textContent = `Projekt "${{name}}" wird angelegt `;
const r = await fetch('/projects/create-api', {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body: new URLSearchParams({{name}})}});
if(!r.ok) {{ alert('Fehler beim Anlegen des Projekts'); uploadBtn.disabled=false; return; }}
projectId = (await r.json()).id;
}}
for(let i=0; i<files.length; i++) {{
const f = files[i];
const stem = f.name.replace(/\\.[^/.]+$/, '');
status.textContent = `(${{i+1}}/${{files.length}}) ${{stem}} `;
bar.style.width = (i / files.length * 100) + '%';
const fd = new FormData();
fd.append('project_id', projectId);
fd.append('file', f);
const r = await fetch('/upload', {{method:'POST', body: fd}});
if(!r.ok) {{ status.textContent = `Fehler bei ${{stem}}`; }}
}}
bar.style.width = '100%';
status.textContent = `${{files.length}} Datei(en) eingereicht weiter zur Job-Übersicht `;
setTimeout(() => location.href='/jobs', 800);
}}
</script>
"""
return layout("Upload", body)
@app.post("/projects", response_class=HTMLResponse)
def add_project(name: str = Form(...)):
with db() as c:
c.execute("INSERT INTO projects(name, created_at) VALUES (?,?)", (name.strip(), now_iso()))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/projects/update", response_class=HTMLResponse)
def rename_project(id: int = Form(...), name: str = Form(...)):
with db() as c:
c.execute("UPDATE projects SET name=? WHERE id=?", (name.strip(), id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/projects/{project_id}/delete", response_class=HTMLResponse)
def delete_project(project_id: int):
with db() as c:
default = c.execute("SELECT id FROM projects WHERE name='Default'").fetchone()
if not default:
c.execute("INSERT INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
default_id = c.execute("SELECT id FROM projects WHERE name='Default'").fetchone()[0]
else:
default_id = default[0]
if project_id == default_id:
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
c.execute("UPDATE documents SET project_id=? WHERE project_id=?", (default_id, project_id))
c.execute("DELETE FROM projects WHERE id=?", (project_id,))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/projects/create-api")
def create_project_api(name: str = Form(...)):
name = name.strip()
if not name:
raise HTTPException(400, "Name darf nicht leer sein")
with db() as c:
existing = c.execute("SELECT id FROM projects WHERE name=?", (name,)).fetchone()
if existing:
return {"id": existing["id"]}
cur = c.execute("INSERT INTO projects(name, created_at) VALUES (?,?)", (name, now_iso()))
return {"id": cur.lastrowid}
@app.post("/upload")
async def upload(project_id: int = Form(...), file: UploadFile = File(...)):
data = await file.read()
if not data:
raise HTTPException(400, "Leere Datei")
JOB_DIR.mkdir(parents=True, exist_ok=True)
filename = (file.filename or "audio.bin").replace("/", "_")
stem = filename.rsplit(".", 1)[0] or filename
temp_path = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}"
temp_path.write_bytes(data)
job_id = enqueue_job(
"upload",
project_id=project_id,
title=stem,
file_path=str(temp_path),
)
return {"job_id": job_id}
@app.get("/library", response_class=HTMLResponse)
def library(project_id: Optional[str] = None, q_title: str = "", q_content: str = ""):
title_q = (q_title or "").strip()
content_q = (q_content or "").strip()
project_id_int = int(project_id) if (project_id and str(project_id).strip()) else None
where = []
params = []
if project_id_int:
where.append("d.project_id=?")
params.append(project_id_int)
if title_q:
where.append("LOWER(d.title) LIKE LOWER(?)")
params.append(f"%{title_q}%")
if content_q:
where.append("LOWER(d.content_md) LIKE LOWER(?)")
params.append(f"%{content_q}%")
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
with db() as c:
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
docs = c.execute(
f"""
SELECT d.id,d.kind,d.title,d.created_at,p.name AS project
FROM documents d JOIN projects p ON p.id=d.project_id
{where_sql}
ORDER BY d.id DESC LIMIT 500
""",
tuple(params),
).fetchall()
p_opts = "<option value=''>Alle</option>" + "".join(
[f"<option value='{p['id']}' {'selected' if project_id_int==p['id'] else ''}>{p['name']}</option>" for p in projects]
)
rows = "".join(
[
f"<tr>"
f"<td style='width:36px'><input type='checkbox' class='form-check-input 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><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"""
<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("'", "&#39;")}'>
</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("'", "&#39;")}'>
</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 id='bulkBar' class='alert alert-primary d-none d-flex align-items-center gap-2 py-2 mb-2' role='alert'>
<strong id='bulkCount'>0 ausgewählt</strong>
<button type='button' class='btn btn-sm btn-primary' onclick='bulkMove()'><i class='bi bi-folder-symlink'></i> Verschieben</button>
<button type='button' class='btn btn-sm btn-danger' onclick='bulkDelete()'><i class='bi bi-trash'></i> Löschen</button>
</div>
<div class='card'>
<div class='table-responsive'>
<table class='table table-sm table-striped align-middle mb-0'>
<thead><tr>
<th style='width:36px'><input type='checkbox' class='form-check-input' 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' class='text-secondary'>Keine Einträge.</td></tr>"}</tbody>
</table>
</div>
</div>
<div class='d-flex gap-2 mt-2'>
<button type='button' class='btn btn-sm btn-outline-secondary' onclick='selectAll()'><i class='bi bi-check2-all'></i> Alle wählen</button>
<button type='button' class='btn btn-sm btn-outline-secondary' onclick='selectNone()'><i class='bi bi-x-square'></i> Alle abwählen</button>
</div>
<script>
async function libPost(url, data) {{
const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body:new URLSearchParams(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 all = document.querySelectorAll('.row-cb');
document.getElementById('bulkCount').textContent = ids.length + ' ausgewählt';
document.getElementById('bulkBar').classList.toggle('d-none', ids.length === 0);
const cbAll = document.getElementById('cbAll');
cbAll.indeterminate = ids.length > 0 && ids.length < all.length;
cbAll.checked = all.length > 0 && ids.length === all.length;
}}
document.querySelectorAll('.row-cb').forEach(cb => {{
cb.addEventListener('change', function() {{
this.closest('tr').classList.toggle('table-active', this.checked);
updateBulkBar();
}});
}});
document.getElementById('cbAll').addEventListener('change', function() {{
document.querySelectorAll('.row-cb').forEach(cb => {{
cb.checked = this.checked;
cb.closest('tr').classList.toggle('table-active', this.checked);
}});
updateBulkBar();
}});
function selectAll() {{
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=true; cb.closest('tr').classList.add('table-active'); }});
updateBulkBar();
}}
function selectNone() {{
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=false; cb.closest('tr').classList.remove('table-active'); }});
updateBulkBar();
}}
window.libRename = async function(id, current) {{
const v = await window.uiPrompt('Dokument umbenennen', current || '');
if(v===null) return;
await libPost(`/document/${{id}}/rename`, {{title:v}});
}};
window.libMove = async function(id) {{
const options = {project_js};
const v = await window.uiSelect('In Projekt verschieben', options, 'Projekt wählen');
if(v===null || v==='') return;
await libPost(`/document/${{id}}/move`, {{project_id:v}});
}};
window.libDelete = async function(id) {{
const ok = await window.uiConfirm('Dokument löschen?');
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)
@app.get("/document/{doc_id}", response_class=HTMLResponse)
def view_document(doc_id: int):
with db() as c:
d = c.execute(
"""
SELECT d.*, p.name AS project, pr.name AS prompt_name
FROM documents d
JOIN projects p ON p.id=d.project_id
LEFT JOIN prompts pr ON pr.id=d.prompt_id
WHERE d.id=?
""",
(doc_id,),
).fetchone()
if not d:
raise HTTPException(404, "not found")
rendered = md.markdown(d["content_md"] or "", extensions=["fenced_code", "tables", "nl2br"])
projects = get_projects()
content_escaped = (d['content_md'] or '').replace('`', '&#96;').replace('</script>', '<\\/script>')
body = f"""
<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='d-flex gap-2 flex-wrap'>
<div class='btn-group btn-group-sm' id='view-actions'>
<a class='btn btn-outline-secondary' title='Download .md' href='/document/{doc_id}/download.md'><i class='bi bi-download'></i></a>
<button class='btn btn-outline-primary' title='Bearbeiten' onclick='startEdit()'><i class='bi bi-pencil'></i> Bearbeiten</button>
<a class='btn btn-outline-secondary' title='Umbenennen' href='#' onclick='renameDoc();return false;'><i class='bi bi-fonts'></i></a>
<a class='btn btn-outline-secondary' title='Verschieben' href='#' onclick='moveDoc();return false;'><i class='bi bi-folder-symlink'></i></a>
<a class='btn btn-outline-danger' title='Löschen' href='#' onclick='deleteDoc();return false;'><i class='bi bi-trash'></i></a>
</div>
<div class='btn-group btn-group-sm d-none' id='edit-actions'>
<button class='btn btn-success' onclick='saveEdit()'><i class='bi bi-check-lg'></i> Speichern</button>
<button class='btn btn-outline-secondary' onclick='cancelEdit()'><i class='bi bi-x-lg'></i> Abbrechen</button>
</div>
</div>
</div>
<div class='card mdview' id='doc-view'>{rendered}</div>
<div class='d-none' id='doc-edit'>
<textarea id='doc-textarea' class='form-control' style='min-height:60vh;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9rem;resize:vertical'></textarea>
</div>
<script>
const _originalContent = {json.dumps(d['content_md'] or '')};
function startEdit() {{
document.getElementById('doc-textarea').value = _originalContent;
document.getElementById('doc-view').classList.add('d-none');
document.getElementById('doc-edit').classList.remove('d-none');
document.getElementById('view-actions').classList.add('d-none');
document.getElementById('edit-actions').classList.remove('d-none');
document.getElementById('doc-textarea').focus();
}}
function cancelEdit() {{
document.getElementById('doc-view').classList.remove('d-none');
document.getElementById('doc-edit').classList.add('d-none');
document.getElementById('view-actions').classList.remove('d-none');
document.getElementById('edit-actions').classList.add('d-none');
}}
async function saveEdit() {{
const content = document.getElementById('doc-textarea').value;
const body = new URLSearchParams({{content_md: content}});
const r = await fetch('/document/{doc_id}/edit', {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body}});
if(!r.ok) {{ alert('Fehler: '+r.status); return; }}
location.reload();
}}
window.postForm = async function(url, data) {{
const body = new URLSearchParams(data);
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.href = '/library';
}};
window.renameDoc = async function() {{
const v = await window.uiPrompt('Neuer Dokumentname', {json.dumps(d['title'])});
if (v===null) return;
window.postForm('/document/{doc_id}/rename', {{title:v}});
}};
window.moveDoc = async function() {{
const options = {json.dumps([{"value": p['id'], "label": p['name']} for p in projects], ensure_ascii=False)};
const v = await window.uiSelect('In Projekt verschieben', options, 'Projekt wählen');
if (v===null || v==='') return;
window.postForm('/document/{doc_id}/move', {{project_id:v}});
}};
window.deleteDoc = async function() {{
const ok = await window.uiConfirm('Dokument wirklich löschen?');
if (!ok) return;
window.postForm('/document/{doc_id}/delete', {{}});
}};
</script>
"""
return layout("Dokument", body)
@app.get("/document/{doc_id}/download.md", response_class=PlainTextResponse)
def download_md(doc_id: int):
with db() as c:
d = c.execute("SELECT title,content_md FROM documents WHERE id=?", (doc_id,)).fetchone()
if not d:
raise HTTPException(404, "not found")
base = (d["title"] or f"document_{doc_id}").strip()
safe = "".join(ch if ch.isalnum() or ch in ("-", "_", " ") else "_" for ch in base).strip()
safe = safe.replace(" ", "_") or f"document_{doc_id}"
filename = f"{safe}.md"
return PlainTextResponse(
d["content_md"],
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@app.post("/document/{doc_id}/edit", response_class=HTMLResponse)
def edit_document(doc_id: int, content_md: str = Form(...)):
with db() as c:
c.execute("UPDATE documents SET content_md=? WHERE id=?", (content_md, doc_id))
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/document/{doc_id}'>")
@app.post("/document/{doc_id}/rename", response_class=HTMLResponse)
def rename_document(doc_id: int, title: str = Form(...)):
with db() as c:
c.execute("UPDATE documents SET title=? WHERE id=?", (title.strip(), doc_id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/document/{doc_id}/move", response_class=HTMLResponse)
def move_document(doc_id: int, project_id: int = Form(...)):
with db() as c:
c.execute("UPDATE documents SET project_id=? WHERE id=?", (project_id, doc_id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/document/{doc_id}/delete", response_class=HTMLResponse)
def delete_document(doc_id: int):
with db() as c:
c.execute("DELETE FROM documents WHERE id=?", (doc_id,))
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()
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'>"
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
]
) or "<p class='text-secondary'>Keine Prompts vorhanden.</p>"
body = f"""
<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>
<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(...)):
with db() as c:
c.execute(
"INSERT INTO prompts(name,prompt,created_at,updated_at) VALUES (?,?,?,?)",
(name.strip(), prompt.strip(), now_iso(), now_iso()),
)
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/prompts/update", response_class=HTMLResponse)
def prompt_update(id: int = Form(...), name: str = Form(...), prompt: str = Form(...)):
with db() as c:
c.execute("UPDATE prompts SET name=?, prompt=?, updated_at=? WHERE id=?", (name.strip(), prompt.strip(), now_iso(), id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/prompts/{prompt_id}/delete", response_class=HTMLResponse)
def prompt_delete(prompt_id: int):
with db() as c:
c.execute("DELETE FROM prompts WHERE id=?", (prompt_id,))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
def _parse_utcish(ts: Optional[str]) -> Optional[datetime]:
if not ts:
return None
try:
return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).replace(tzinfo=None)
except Exception:
try:
return datetime.fromisoformat(str(ts))
except Exception:
return None
def _fmt_elapsed(start_iso: Optional[str], end_iso: Optional[str] = None) -> str:
s = _parse_utcish(start_iso)
if not s:
return "-"
try:
e = _parse_utcish(end_iso) if end_iso else datetime.utcnow()
if not e:
e = datetime.utcnow()
sec = max(0, int((e - s).total_seconds()))
if sec < 60:
return f"{sec}s"
m, s2 = divmod(sec, 60)
if m < 60:
return f"{m}m {s2}s"
h, m2 = divmod(m, 60)
return f"{h}h {m2}m"
except Exception:
return "-"
def _jobs_payload(limit: int = 200):
with db() as c:
jobs = c.execute(
"""
SELECT j.*, p.name AS project_name, d.title AS document_title, pr.name AS prompt_name
FROM jobs j
LEFT JOIN projects p ON p.id=j.project_id
LEFT JOIN documents d ON d.id=j.document_id
LEFT JOIN prompts pr ON pr.id=j.prompt_id
ORDER BY j.id DESC LIMIT ?
""",
(limit,),
).fetchall()
return [dict(j) for j in jobs]
@app.get("/jobs/data")
def jobs_data(limit: int = 200):
return {"items": _jobs_payload(limit)}
@app.post("/jobs/{job_id}/cancel")
def jobs_cancel(job_id: int):
j = _job_get(job_id)
if not j:
raise HTTPException(404, "job not found")
if j["status"] not in ("done", "error", "cancelled"):
_job_set(job_id, status="cancelled", finished_at=now_iso(), error="Cancelled by user")
return {"ok": True, "status": "cancelled"}
@app.post("/jobs/{job_id}/cancel-form")
def jobs_cancel_form(job_id: int):
jobs_cancel(job_id)
return RedirectResponse(url="/jobs", status_code=303)
@app.post("/jobs/{job_id}/delete")
def jobs_delete(job_id: int):
with db() as c:
c.execute("DELETE FROM jobs WHERE id=?", (job_id,))
return {"ok": True}
@app.post("/jobs/{job_id}/delete-form")
def jobs_delete_form(job_id: int):
jobs_delete(job_id)
return RedirectResponse(url="/jobs", status_code=303)
@app.get("/jobs", response_class=HTMLResponse)
def jobs_page(queued: Optional[int] = None):
items = _jobs_payload(200)
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"""
<h2 class='h4 mb-2'>Hintergrundverarbeitung</h2>
<p class='text-secondary small'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p>
{notice}
<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'></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'));
}}
function since(ts, endTs=null) {{
if(!ts) return '-';
const s = Math.max(0, Math.floor(((endTs ? parseUtcish(endTs) : Date.now()) - parseUtcish(ts)) / 1000));
if(s<60) return s+'s';
const m = Math.floor(s/60); if(m<60) return m+'m '+(s%60)+'s';
const h = Math.floor(m/60); return h+'h '+(m%60)+'m';
}}
function badgeColor(s) {{
return {{done:'success', running:'primary', queued:'primary', cancelled:'warning'}}[s] || 'danger';
}}
function renderCard(it) {{
const startTs = it.started_at || it.created_at || '';
const endTs = it.finished_at || '';
let actions = '';
if(!['done','error','cancelled'].includes(it.status))
actions += `<button class='btn btn-outline-warning btn-sm' onclick='cancelJob(${{it.id}})'><i class='bi bi-x-circle'></i> Abbrechen</button> `;
actions += `<button class='btn btn-outline-danger btn-sm' onclick='deleteJob(${{it.id}})'><i class='bi bi-trash'></i> Löschen</button> `;
if(it.result_document_id)
actions += `<a class='btn btn-primary btn-sm' href='/document/${{it.result_document_id}}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>`;
const err = it.error ? `<div class='alert alert-danger mt-2 mb-0 py-2 small'>${{String(it.error).replace(/</g,'&lt;')}}</div>` : '';
const meta = [
it.project_name ? 'Projekt: '+it.project_name : '',
it.document_title? 'Dokument: '+it.document_title : '',
it.prompt_name ? 'Prompt: '+it.prompt_name : '',
].filter(Boolean).join('<br>');
return `<div class='card job-card' data-job-id='${{it.id}}'>
<div class='d-flex justify-content-between align-items-center flex-wrap gap-2'>
<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>
<div class='text-secondary small'>erstellt: ${{it.created_at}} · läuft: <span class='elapsed' data-start='${{startTs}}' data-end='${{endTs}}'>${{since(startTs, endTs||null)}}</span></div>
</div>
</div>
<span class='badge text-bg-${{badgeColor(it.status)}}'>${{it.status}}</span>
</div>
<div class='small mt-2'>${{meta}}</div>
<div class='d-flex flex-wrap gap-2 mt-3'>${{actions}}</div>
${{err}}
</div>`;
}}
async function post(url) {{
const r = await fetch(url, {{method:'POST'}});
if(!r.ok) throw new Error('Fehler '+r.status);
}}
function selectedJobIds() {{
return Array.from(document.querySelectorAll('.job-select:checked')).map(el => Number(el.value));
}}
function updateSelectionCount() {{
const el = document.getElementById('jobs-selected-count');
if(el) 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');
await refreshJobs();
}}
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');
await refreshJobs();
}}
async function cancelJob(id) {{
if(!confirm('Job abbrechen?')) return;
await post('/jobs/'+id+'/cancel');
await refreshJobs();
}}
async function deleteJob(id) {{
if(!confirm('Job löschen?')) return;
await post('/jobs/'+id+'/delete');
await refreshJobs();
}}
let _pollTimer = null;
async function refreshJobs() {{
try {{
const checked = new Set(selectedJobIds());
const r = await fetch('/jobs/data');
const {{items}} = await r.json();
const root = document.getElementById('jobs-root');
if(!items.length) {{
root.innerHTML = "<p class='text-secondary'>Keine Jobs.</p>";
}} else {{
root.innerHTML = items.map(renderCard).join('');
root.querySelectorAll('.job-select').forEach(el => {{
if(checked.has(Number(el.value))) el.checked = true;
}});
}}
updateSelectionCount();
const hasActive = items.some(it => ['queued','running'].includes(it.status));
document.getElementById('jobs-status').textContent = hasActive ? 'Live-Update aktiv …' : '';
schedulePoll(hasActive ? 3000 : 10000);
}} catch(e) {{
document.getElementById('jobs-status').textContent = 'Verbindungsfehler versuche erneut …';
schedulePoll(5000);
}}
}}
function schedulePoll(ms) {{
clearTimeout(_pollTimer);
_pollTimer = setTimeout(refreshJobs, ms);
}}
document.addEventListener('change', e => {{
if(e.target?.classList?.contains('job-select')) updateSelectionCount();
}});
document.addEventListener('visibilitychange', () => {{
if(document.visibilityState === 'visible') refreshJobs();
}});
setInterval(() => document.querySelectorAll('.elapsed').forEach(el => {{
el.textContent = since(el.dataset.start, el.dataset.end || null);
}}), 1000);
updateSelectionCount();
schedulePoll(3000);
</script>
"""
return layout("Jobs", body)
@app.get("/run", response_class=HTMLResponse)
def run_page():
with db() as c:
docs = c.execute("SELECT id,title,kind,created_at FROM documents ORDER BY id DESC LIMIT 200").fetchall()
prompts = c.execute("SELECT id,name FROM prompts ORDER BY name").fetchall()
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
d_opts = "".join([f"<option value='{d['id']}'>#{d['id']} [{d['kind']}] {d['title']}</option>" for d in docs])
p_opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in prompts])
proj_opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects])
body = f"""
<h2 class='h4 mb-3'>Prompt ausführen</h2>
<ul class='nav nav-tabs mb-3' id='runTabs' role='tablist'>
<li class='nav-item' role='presentation'>
<button class='nav-link active' data-bs-toggle='tab' data-bs-target='#pane-single' type='button' role='tab'><i class='bi bi-file-earmark-text'></i> Einzeldokument</button>
</li>
<li class='nav-item' role='presentation'>
<button class='nav-link' data-bs-toggle='tab' data-bs-target='#pane-project' type='button' role='tab'><i class='bi bi-folder2-open'></i> Projekt-Batch</button>
</li>
</ul>
<div class='tab-content'>
<div class='tab-pane fade show active' id='pane-single' role='tabpanel'>
<form method='post' action='/run' class='card'>
<div class='mb-3'>
<label class='form-label'>Dokument</label>
<select class='form-select' name='document_id'>{d_opts}</select>
</div>
<div class='mb-3'>
<label class='form-label'>Prompt</label>
<select class='form-select' name='prompt_id'>{p_opts}</select>
</div>
<div class='mb-3'>
<label class='form-label'>Zusatzinfos <span class='text-secondary fw-normal'>(optional wird dem LLM zusätzlich mitgegeben)</span></label>
<textarea class='form-control' name='user_prompt' rows='3' placeholder='z. B. Fokus auf Entscheidungen, Kontext zum Meeting …'></textarea>
</div>
<button class='btn btn-primary' type='submit'><i class='bi bi-play-circle'></i> Ausführen</button>
</form>
</div>
<div class='tab-pane fade' id='pane-project' role='tabpanel'>
<form method='post' action='/run/project' class='card'>
<div class='text-secondary small mb-3'>Führt den gewählten Prompt für <strong>alle Transkripte</strong> des Projekts aus je Transkript ein Job.</div>
<div class='mb-3'>
<label class='form-label'>Projekt</label>
<select class='form-select' name='project_id'>{proj_opts}</select>
</div>
<div class='mb-3'>
<label class='form-label'>Prompt</label>
<select class='form-select' name='prompt_id'>{p_opts}</select>
</div>
<div class='mb-3'>
<label class='form-label'>Zusatzinfos <span class='text-secondary fw-normal'>(optional wird für alle Jobs mitgegeben)</span></label>
<textarea class='form-control' name='user_prompt' rows='3' placeholder='z. B. Fokus auf Entscheidungen, Kontext zum Meeting …'></textarea>
</div>
<button class='btn btn-primary' type='submit'><i class='bi bi-play-fill'></i> Alle Transkripte verarbeiten</button>
</form>
</div>
</div>
"""
return layout("Run", body)
@app.post("/run", response_class=HTMLResponse)
def run_prompt(document_id: int = Form(...), prompt_id: int = Form(...), user_prompt: str = Form("")):
with db() as c:
doc = c.execute("SELECT id FROM documents WHERE id=?", (document_id,)).fetchone()
prm = c.execute("SELECT id FROM prompts WHERE id=?", (prompt_id,)).fetchone()
if not doc or not prm:
raise HTTPException(404, "Dokument oder Prompt nicht gefunden")
job_id = enqueue_job("analysis", document_id=document_id, prompt_id=prompt_id, user_prompt=user_prompt.strip() or None)
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/jobs?queued={job_id}'>")
@app.post("/run/project", response_class=HTMLResponse)
def run_project(project_id: int = Form(...), prompt_id: int = Form(...), user_prompt: str = Form("")):
with db() as c:
prm = c.execute("SELECT id FROM prompts WHERE id=?", (prompt_id,)).fetchone()
transcripts = c.execute(
"SELECT id FROM documents WHERE project_id=? AND kind='transcript'",
(project_id,),
).fetchall()
if not prm:
raise HTTPException(404, "Prompt nicht gefunden")
if not transcripts:
raise HTTPException(400, "Keine Transkripte im Projekt gefunden")
up = user_prompt.strip() or None
for doc in transcripts:
enqueue_job("analysis", document_id=doc["id"], prompt_id=prompt_id, user_prompt=up)
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/jobs'>")