Thinking tokens count against num_predict. At 4096 the model was running out mid-response after spending ~3000 tokens on thinking. 16384 gives enough headroom for thinking + full response. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1718 lines
74 KiB
Python
1718 lines
74 KiB
Python
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")
|
||
OLLAMA_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "16384"))
|
||
OLLAMA_THINK = os.getenv("OLLAMA_THINK", "true").lower() in ("1", "true", "yes")
|
||
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()
|
||
_JOB_STREAMS: dict = {}
|
||
_JOB_STREAM_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 _estimate_num_ctx(prompt: str) -> int:
|
||
needed = len(prompt) // 3 + 2048 # rough token estimate + response buffer
|
||
for ctx in (4096, 8192, 16384, 32768, 65536):
|
||
if needed <= ctx:
|
||
return ctx
|
||
return 65536
|
||
|
||
|
||
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
|
||
try:
|
||
c.execute("ALTER TABLE jobs ADD COLUMN llm_prompt TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE jobs ADD COLUMN llm_response TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE jobs ADD COLUMN llm_thinking 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,'"')}}">`}});
|
||
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")
|
||
if not (doc["content_md"] or "").strip():
|
||
raise RuntimeError("Dokument hat keinen Inhalt – bitte zuerst das Transkript prüfen")
|
||
|
||
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"
|
||
)
|
||
|
||
num_ctx = _estimate_num_ctx(llm_prompt)
|
||
_job_set(job_id, llm_prompt=f"[num_ctx={num_ctx}]\n\n{llm_prompt}")
|
||
with _JOB_STREAM_LOCK:
|
||
_JOB_STREAMS[job_id] = {"thinking": "", "response": ""}
|
||
|
||
r = requests.post(
|
||
f"{OLLAMA_BASE_URL}/api/generate",
|
||
json={"model": OLLAMA_MODEL, "prompt": llm_prompt, "stream": True, "think": OLLAMA_THINK, "options": {
|
||
"num_ctx": num_ctx,
|
||
"num_predict": OLLAMA_NUM_PREDICT,
|
||
"repeat_penalty": 1.15,
|
||
"repeat_last_n": 128,
|
||
}},
|
||
stream=True,
|
||
timeout=1200,
|
||
)
|
||
r.raise_for_status()
|
||
|
||
acc_thinking = ""
|
||
acc_response = ""
|
||
ollama_final = {}
|
||
chunk_count = 0
|
||
for line in r.iter_lines():
|
||
if not line:
|
||
continue
|
||
chunk = json.loads(line)
|
||
acc_thinking += chunk.get("thinking") or ""
|
||
acc_response += chunk.get("response") or ""
|
||
with _JOB_STREAM_LOCK:
|
||
_JOB_STREAMS[job_id] = {"thinking": acc_thinking, "response": acc_response}
|
||
chunk_count += 1
|
||
if chunk_count % 100 == 0:
|
||
j_check = _job_get(job_id)
|
||
if not j_check or j_check["status"] == "cancelled":
|
||
return
|
||
if chunk.get("done"):
|
||
ollama_final = chunk
|
||
break
|
||
|
||
answer = acc_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": ollama_final}, 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(), llm_response=answer, llm_thinking=acc_thinking or None)
|
||
except Exception as e:
|
||
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
|
||
finally:
|
||
with _JOB_STREAM_LOCK:
|
||
_JOB_STREAMS.pop(job_id, None)
|
||
|
||
|
||
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("'", "'")}'>
|
||
</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 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('`', '`').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/{job_id}/debug-data")
|
||
def job_debug_data(job_id: int):
|
||
j = _job_get(job_id)
|
||
if not j:
|
||
raise HTTPException(404, "job not found")
|
||
with _JOB_STREAM_LOCK:
|
||
live = dict(_JOB_STREAMS[job_id]) if job_id in _JOB_STREAMS else None
|
||
return {
|
||
"status": j["status"],
|
||
"kind": j["kind"],
|
||
"llm_prompt": j.get("llm_prompt"),
|
||
"llm_thinking": live["thinking"] if live is not None else j.get("llm_thinking"),
|
||
"llm_response": live["response"] if live is not None else j.get("llm_response"),
|
||
"streaming": live is not None,
|
||
}
|
||
|
||
|
||
@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>"
|
||
if it["kind"] == "analysis":
|
||
actions += f" <button class='btn btn-outline-info btn-sm' onclick='openDebug({it['id']})'><i class='bi bi-bug'></i> Debug</button>"
|
||
|
||
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 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>`;
|
||
if(it.kind === 'analysis')
|
||
actions += ` <button class='btn btn-outline-info btn-sm' onclick='openDebug(${{it.id}})'><i class='bi bi-bug'></i> Debug</button>`;
|
||
const err = it.error ? `<div class='alert alert-danger mt-2 mb-0 py-2 small'>${{String(it.error).replace(/</g,'<')}}</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);
|
||
|
||
let _debugJobId = null;
|
||
let _debugPollTimer = null;
|
||
async function openDebug(jobId) {{
|
||
_debugJobId = jobId;
|
||
document.getElementById('debug-job-id').textContent = jobId;
|
||
document.getElementById('debug-prompt-content').textContent = 'Lade …';
|
||
document.getElementById('debug-response-content').textContent = 'Lade …';
|
||
bootstrap.Modal.getOrCreateInstance(document.getElementById('debugModal')).show();
|
||
await refreshDebug();
|
||
}}
|
||
async function refreshDebug() {{
|
||
if (!_debugJobId) return;
|
||
try {{
|
||
const r = await fetch('/jobs/' + _debugJobId + '/debug-data');
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (d.kind !== 'analysis') {{
|
||
document.getElementById('debug-prompt-content').textContent = 'Upload-Job – kein LLM-Prompt';
|
||
document.getElementById('debug-response-content').textContent = '-';
|
||
return;
|
||
}}
|
||
document.getElementById('debug-prompt-content').textContent = d.llm_prompt || '(kein Prompt gespeichert)';
|
||
const respEl = document.getElementById('debug-response-content');
|
||
const atBottom = respEl.scrollHeight - respEl.scrollTop <= respEl.clientHeight + 20;
|
||
const thinking = d.llm_thinking || '';
|
||
const response = d.llm_response || '';
|
||
if (thinking) {{
|
||
respEl.innerHTML =
|
||
`<span style='color:#94a3b8;font-size:.75rem;letter-spacing:.06em'>▶ THINKING</span>\n` +
|
||
`<span style='color:#7dd3fc'>${{thinking.replace(/</g,'<')}}</span>` +
|
||
(response ? `\n\n<span style='color:#94a3b8;font-size:.75rem;letter-spacing:.06em'>▶ ANTWORT</span>\n<span style='color:#c7f9cc'>${{response.replace(/</g,'<')}}</span>` : '');
|
||
}} else {{
|
||
respEl.textContent = response || '(noch keine Antwort)';
|
||
}}
|
||
if (atBottom) respEl.scrollTop = respEl.scrollHeight;
|
||
const badge = document.getElementById('debug-streaming-badge');
|
||
badge.classList.toggle('d-none', !d.streaming);
|
||
clearTimeout(_debugPollTimer);
|
||
if (d.streaming || d.status === 'running') {{
|
||
_debugPollTimer = setTimeout(refreshDebug, 500);
|
||
}}
|
||
}} catch(e) {{
|
||
clearTimeout(_debugPollTimer);
|
||
_debugPollTimer = setTimeout(refreshDebug, 2000);
|
||
}}
|
||
}}
|
||
document.addEventListener('DOMContentLoaded', () => {{
|
||
const el = document.getElementById('debugModal');
|
||
if (el) el.addEventListener('hide.bs.modal', () => {{
|
||
_debugJobId = null;
|
||
clearTimeout(_debugPollTimer);
|
||
}});
|
||
}});
|
||
</script>
|
||
|
||
<div class='modal fade' id='debugModal' tabindex='-1' aria-hidden='true'>
|
||
<div class='modal-dialog modal-xl modal-dialog-scrollable'>
|
||
<div class='modal-content'>
|
||
<div class='modal-header py-2'>
|
||
<h5 class='modal-title'><i class='bi bi-bug'></i> Debug · Job #<span id='debug-job-id'></span></h5>
|
||
<button type='button' class='btn-close' data-bs-dismiss='modal'></button>
|
||
</div>
|
||
<div class='modal-body p-0'>
|
||
<ul class='nav nav-tabs px-3 pt-2'>
|
||
<li class='nav-item'>
|
||
<button class='nav-link active' data-bs-toggle='tab' data-bs-target='#debug-prompt-pane' type='button'>
|
||
<i class='bi bi-send'></i> Gesendeter Prompt
|
||
</button>
|
||
</li>
|
||
<li class='nav-item'>
|
||
<button class='nav-link' data-bs-toggle='tab' data-bs-target='#debug-response-pane' type='button'>
|
||
<i class='bi bi-cpu'></i> Antwort
|
||
<span id='debug-streaming-badge' class='badge text-bg-primary ms-1 d-none'>● live</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<div class='tab-content p-3'>
|
||
<div class='tab-pane fade show active' id='debug-prompt-pane'>
|
||
<pre id='debug-prompt-content' style='max-height:65vh;overflow:auto;white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;font-size:.82rem'></pre>
|
||
</div>
|
||
<div class='tab-pane fade' id='debug-response-pane'>
|
||
<pre id='debug-response-content' style='max-height:65vh;overflow:auto;white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;font-size:.82rem'></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
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'>")
|