2026-03-21 13:47:36 +01:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sqlite3
|
2026-03-21 15:00:21 +01:00
|
|
|
|
import threading
|
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-03-21 13:47:36 +01:00
|
|
|
|
from datetime import datetime
|
2026-03-21 15:00:21 +01:00
|
|
|
|
from pathlib import Path
|
2026-03-21 13:47:36 +01:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
2026-03-21 14:32:24 +01:00
|
|
|
|
import markdown as md
|
2026-03-21 13:47:36 +01:00
|
|
|
|
import requests
|
|
|
|
|
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
2026-03-21 15:19:03 +01:00
|
|
|
|
from fastapi.responses import HTMLResponse, PlainTextResponse, Response, JSONResponse, RedirectResponse
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
app = FastAPI(title="Diarization UI")
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
JOB_DIR = Path(os.getenv("JOB_DIR", "/data/jobs"))
|
|
|
|
|
|
EXECUTOR = ThreadPoolExecutor(max_workers=2)
|
|
|
|
|
|
JOB_LOCK = threading.Lock()
|
|
|
|
|
|
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
|
|
|
|
|
def db():
|
|
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
def now_iso() -> str:
|
|
|
|
|
|
return datetime.utcnow().isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 13:47:36 +01:00
|
|
|
|
def init_db():
|
|
|
|
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
|
|
|
|
with db() as c:
|
|
|
|
|
|
c.execute(
|
|
|
|
|
|
"""
|
2026-03-21 14:02:21 +01:00
|
|
|
|
CREATE TABLE IF NOT EXISTS projects (
|
2026-03-21 13:47:36 +01:00
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
2026-03-21 14:02:21 +01:00
|
|
|
|
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,
|
2026-03-21 13:47:36 +01:00
|
|
|
|
created_at TEXT NOT NULL,
|
2026-03-21 14:02:21 +01:00
|
|
|
|
updated_at TEXT NOT NULL
|
2026-03-21 13:47:36 +01:00
|
|
|
|
)
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
c.execute(
|
|
|
|
|
|
"""
|
2026-03-21 14:02:21 +01:00
|
|
|
|
CREATE TABLE IF NOT EXISTS documents (
|
2026-03-21 13:47:36 +01:00
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
2026-03-21 14:02:21 +01:00
|
|
|
|
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,
|
2026-03-21 13:47:36 +01:00
|
|
|
|
created_at TEXT NOT NULL,
|
2026-03-21 14:02:21 +01:00
|
|
|
|
FOREIGN KEY(project_id) REFERENCES projects(id),
|
|
|
|
|
|
FOREIGN KEY(source_document_id) REFERENCES documents(id),
|
|
|
|
|
|
FOREIGN KEY(prompt_id) REFERENCES prompts(id)
|
2026-03-21 13:47:36 +01:00
|
|
|
|
)
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
2026-03-21 15:00:21 +01:00
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
# 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>
|
2026-03-21 14:30:15 +01:00
|
|
|
|
<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'>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
<title>{title}</title>
|
|
|
|
|
|
<style>
|
2026-03-21 15:42:36 +01:00
|
|
|
|
:root{{--bg:#0b1020;--panel:#070c1b;--panel2:#0b1222;--card:#ffffff;--txt:#0f172a;--muted:#64748b;--acc:#0ea5e9;--acc2:#2563eb;--ok:#14b8a6;--border:#e2e8f0}}
|
2026-03-21 14:30:15 +01:00
|
|
|
|
*{{box-sizing:border-box}}
|
2026-03-21 15:42:36 +01:00
|
|
|
|
body{{font-family:Inter,system-ui,Arial;margin:0;background:#f1f5f9;color:var(--txt);display:flex;min-height:100vh}}
|
|
|
|
|
|
nav{{width:236px;background:linear-gradient(180deg,#050a17,#0a1329);color:#e2e8f0;border-right:1px solid #0f172a;padding:14px 10px;position:sticky;top:0;height:100vh;overflow:auto}}
|
|
|
|
|
|
.brand{{font-weight:800;letter-spacing:.2px;margin:4px 8px 12px 8px;color:#c7d2fe}}
|
|
|
|
|
|
.navsection{{margin:12px 8px 6px 8px;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.08em}}
|
|
|
|
|
|
nav a{{display:flex;gap:10px;align-items:center;color:#e2e8f0;text-decoration:none;padding:10px 10px;border-radius:10px;margin:4px 6px;border:1px solid transparent}}
|
|
|
|
|
|
nav a:hover{{background:#0f1b36;border-color:#1e2a4a}}
|
|
|
|
|
|
.app{{flex:1;display:flex;flex-direction:column;min-width:0}}
|
|
|
|
|
|
.topbar{{height:56px;background:#fff;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:10px;position:sticky;top:0;z-index:10}}
|
|
|
|
|
|
.topbar h1{{font-size:18px;margin:0;font-weight:700}}
|
|
|
|
|
|
.topbar .right{{margin-left:auto;display:flex;gap:8px;color:#64748b}}
|
|
|
|
|
|
main{{padding:14px;max-width:1400px}}
|
|
|
|
|
|
.card{{background:#fff;border:1px solid var(--border);border-radius:12px;padding:14px;margin:10px 0;box-shadow:0 2px 10px rgba(15,23,42,.04)}}
|
|
|
|
|
|
input,select,textarea,button{{padding:9px 11px;font-size:14px;border-radius:10px;border:1px solid #cbd5e1;background:#fff;color:#0f172a}}
|
|
|
|
|
|
button{{background:linear-gradient(90deg,var(--acc),var(--acc2));color:#fff;border:none;font-weight:700}}
|
|
|
|
|
|
button:hover{{filter:brightness(1.02)}}
|
2026-03-21 14:30:15 +01:00
|
|
|
|
textarea{{width:100%;min-height:150px}}
|
2026-03-21 15:42:36 +01:00
|
|
|
|
pre{{white-space:pre-wrap;background:#0f172a;color:#a7f3d0;padding:12px;border-radius:10px;border:1px solid #1e293b}}
|
2026-03-21 14:32:24 +01:00
|
|
|
|
.mdview{{line-height:1.55}}
|
|
|
|
|
|
.mdview h1,.mdview h2,.mdview h3{{margin:16px 0 8px}}
|
|
|
|
|
|
.mdview p{{margin:10px 0}}
|
|
|
|
|
|
.mdview ul,.mdview ol{{padding-left:22px}}
|
2026-03-21 15:42:36 +01:00
|
|
|
|
.mdview code{{background:#e2e8f0;padding:2px 5px;border-radius:6px}}
|
|
|
|
|
|
.mdview pre code{{display:block;padding:10px;background:transparent}}
|
|
|
|
|
|
.mdview blockquote{{border-left:3px solid #94a3b8;padding-left:10px;color:#334155}}
|
2026-03-21 14:02:21 +01:00
|
|
|
|
.row{{display:flex;gap:8px;flex-wrap:wrap;align-items:center}}
|
2026-03-21 14:30:15 +01:00
|
|
|
|
small{{color:var(--muted)}}
|
|
|
|
|
|
.hint{{color:var(--muted);font-size:13px}}
|
2026-03-22 07:43:09 +01:00
|
|
|
|
@media (max-width:1100px){{
|
|
|
|
|
|
nav{{width:84px;padding:8px}}
|
|
|
|
|
|
nav a span{{display:none}}
|
|
|
|
|
|
.brand{{font-size:11px}}
|
|
|
|
|
|
.topbar h1{{font-size:15px}}
|
|
|
|
|
|
main{{padding:10px}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
@media (max-width:760px){{
|
|
|
|
|
|
body{{display:block}}
|
|
|
|
|
|
nav{{position:fixed;left:0;right:0;bottom:0;top:auto;height:64px;width:100%;display:flex;align-items:center;overflow:auto;white-space:nowrap;padding:6px 8px;z-index:50}}
|
|
|
|
|
|
.brand,.navsection{{display:none}}
|
|
|
|
|
|
nav a{{margin:0 4px;min-width:auto;padding:8px 10px}}
|
|
|
|
|
|
nav a span{{display:inline}}
|
|
|
|
|
|
nav a span:last-child{{display:none}}
|
|
|
|
|
|
.app{{padding-bottom:72px}}
|
|
|
|
|
|
.topbar{{position:sticky;top:0}}
|
|
|
|
|
|
main{{padding:10px 8px}}
|
|
|
|
|
|
.row{{flex-direction:column;align-items:stretch}}
|
|
|
|
|
|
input,select,textarea,button{{width:100%}}
|
|
|
|
|
|
.grid{{min-width:640px}}
|
|
|
|
|
|
}}
|
2026-03-21 15:42:36 +01:00
|
|
|
|
.modal-backdrop{{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:2000}}
|
|
|
|
|
|
.modal{{width:min(92vw,520px);background:#fff;border:1px solid #cbd5e1;border-radius:14px;padding:14px;box-shadow:0 24px 60px rgba(0,0,0,.25)}}
|
2026-03-21 14:43:55 +01:00
|
|
|
|
.modal h4{{margin:0 0 8px 0}}
|
|
|
|
|
|
.modal .actions{{display:flex;gap:8px;justify-content:flex-end;margin-top:10px}}
|
|
|
|
|
|
.modal input,.modal select{{width:100%}}
|
2026-03-21 15:42:36 +01:00
|
|
|
|
.iconbtn{{text-decoration:none;font-size:18px;padding:2px 6px;border-radius:8px;border:1px solid #cbd5e1;background:#fff;color:#0f172a}}
|
2026-03-21 14:30:15 +01:00
|
|
|
|
</style>
|
|
|
|
|
|
<script>
|
|
|
|
|
|
if ('serviceWorker' in navigator) {{ window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{{}})); }}
|
2026-03-21 14:43:55 +01:00
|
|
|
|
|
|
|
|
|
|
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>`}});
|
|
|
|
|
|
}};
|
2026-03-21 14:30:15 +01:00
|
|
|
|
</script>
|
|
|
|
|
|
</head>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
<body>
|
2026-03-21 14:43:55 +01:00
|
|
|
|
<div id='modal-backdrop' class='modal-backdrop'><div id='modal-box' class='modal'></div></div>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
<nav>
|
2026-03-21 14:30:15 +01:00
|
|
|
|
<div class='brand'>🎙️ Audio Copilot</div>
|
2026-03-21 15:42:36 +01:00
|
|
|
|
<div class='navsection'>Navigation</div>
|
|
|
|
|
|
<a href='/'><span>🏠</span><span>Home / Upload</span></a>
|
|
|
|
|
|
<a href='/library'><span>📁</span><span>Projekte</span></a>
|
|
|
|
|
|
<a href='/prompts'><span>🧩</span><span>Vorlagen</span></a>
|
|
|
|
|
|
<div class='navsection'>Automatisierung</div>
|
2026-03-21 14:30:15 +01:00
|
|
|
|
<a href='/run'><span>🤖</span><span>Prompt ausführen</span></a>
|
2026-03-21 15:00:21 +01:00
|
|
|
|
<a href='/jobs'><span>⏳</span><span>Hintergrundjobs</span></a>
|
2026-03-21 15:42:36 +01:00
|
|
|
|
<div class='navsection'>System</div>
|
2026-03-21 14:30:15 +01:00
|
|
|
|
<a href='/healthz'><span>💚</span><span>Health</span></a>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
</nav>
|
2026-03-21 15:42:36 +01:00
|
|
|
|
<div class='app'>
|
|
|
|
|
|
<div class='topbar'>
|
|
|
|
|
|
<h1>{title}</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<main>{body}</main>
|
|
|
|
|
|
</div>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
</body></html>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_projects():
|
|
|
|
|
|
with db() as c:
|
|
|
|
|
|
return c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:25:18 +01:00
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
def get_prompts():
|
|
|
|
|
|
with db() as c:
|
|
|
|
|
|
return c.execute("SELECT id,name,prompt FROM prompts ORDER BY name").fetchall()
|
|
|
|
|
|
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 15:07:31 +01:00
|
|
|
|
def _job_get(job_id: int):
|
|
|
|
|
|
with db() as c:
|
|
|
|
|
|
return c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
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):
|
2026-03-21 15:07:31 +01:00
|
|
|
|
j = _job_get(job_id)
|
2026-03-21 15:00:21 +01:00
|
|
|
|
if not j:
|
|
|
|
|
|
return
|
2026-03-21 15:07:31 +01:00
|
|
|
|
if j["status"] == "cancelled":
|
|
|
|
|
|
return
|
2026-03-21 15:00:21 +01:00
|
|
|
|
|
|
|
|
|
|
_job_set(job_id, status="running", started_at=now_iso())
|
|
|
|
|
|
try:
|
2026-03-21 15:07:31 +01:00
|
|
|
|
j = _job_get(job_id)
|
|
|
|
|
|
if not j or j["status"] == "cancelled":
|
|
|
|
|
|
return
|
2026-03-21 15:00:21 +01:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-03-21 15:07:31 +01:00
|
|
|
|
j = _job_get(job_id)
|
|
|
|
|
|
if not j or j["status"] == "cancelled":
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
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):
|
2026-03-21 15:07:31 +01:00
|
|
|
|
j = _job_get(job_id)
|
2026-03-21 15:00:21 +01:00
|
|
|
|
if not j:
|
|
|
|
|
|
return
|
2026-03-21 15:07:31 +01:00
|
|
|
|
if j["status"] == "cancelled":
|
|
|
|
|
|
return
|
2026-03-21 15:00:21 +01:00
|
|
|
|
|
|
|
|
|
|
_job_set(job_id, status="running", started_at=now_iso())
|
|
|
|
|
|
try:
|
2026-03-21 15:07:31 +01:00
|
|
|
|
j = _job_get(job_id)
|
|
|
|
|
|
if not j or j["status"] == "cancelled":
|
|
|
|
|
|
return
|
2026-03-21 15:00:21 +01:00
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
llm_prompt = (
|
|
|
|
|
|
"Du bist ein präziser Assistent. Antworte auf Deutsch.\\n"
|
|
|
|
|
|
f"AUFTRAG:\\n{prm['prompt']}\\n\\n"
|
|
|
|
|
|
f"TEXT:\\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", "")
|
|
|
|
|
|
|
2026-03-21 15:07:31 +01:00
|
|
|
|
j = _job_get(job_id)
|
|
|
|
|
|
if not j or j["status"] == "cancelled":
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
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,created_at)
|
|
|
|
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
kind,
|
|
|
|
|
|
"queued",
|
|
|
|
|
|
kwargs.get("project_id"),
|
|
|
|
|
|
kwargs.get("document_id"),
|
|
|
|
|
|
kwargs.get("prompt_id"),
|
|
|
|
|
|
kwargs.get("title"),
|
|
|
|
|
|
kwargs.get("file_path"),
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 13:47:36 +01:00
|
|
|
|
@app.on_event("startup")
|
|
|
|
|
|
def startup():
|
|
|
|
|
|
init_db()
|
2026-03-21 15:00:21 +01:00
|
|
|
|
JOB_DIR.mkdir(parents=True, exist_ok=True)
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:30:15 +01:00
|
|
|
|
@app.get("/manifest.webmanifest")
|
|
|
|
|
|
def manifest():
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": "Audio Copilot",
|
|
|
|
|
|
"short_name": "Copilot",
|
|
|
|
|
|
"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")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 13:47:36 +01:00
|
|
|
|
@app.get("/healthz")
|
|
|
|
|
|
def healthz():
|
2026-03-21 14:02:21 +01:00
|
|
|
|
return {"ok": True, "api_base": API_BASE, "ollama_base_url": OLLAMA_BASE_URL, "ollama_model": OLLAMA_MODEL, "db_path": DB_PATH}
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
2026-03-21 14:02:21 +01:00
|
|
|
|
def upload_page(msg: str = ""):
|
|
|
|
|
|
projects = get_projects()
|
|
|
|
|
|
opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects])
|
|
|
|
|
|
body = f"""
|
|
|
|
|
|
<h2>Audio Upload</h2>
|
|
|
|
|
|
<p>Audio wird transkribiert + mit Sprechern angereichert und als Dokument gespeichert.</p>
|
|
|
|
|
|
{f"<p><b>{msg}</b></p>" if msg else ""}
|
|
|
|
|
|
<form action='/upload' method='post' enctype='multipart/form-data' class='card'>
|
|
|
|
|
|
<div class='row'>
|
|
|
|
|
|
<label>Projekt:</label>
|
|
|
|
|
|
<select name='project_id'>{opts}</select>
|
|
|
|
|
|
<input name='title' placeholder='Titel (optional)'>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class='row' style='margin-top:8px'>
|
|
|
|
|
|
<input type='file' name='file' accept='audio/*' required>
|
|
|
|
|
|
<button type='submit'>Verarbeiten & speichern</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
2026-03-21 13:47:36 +01:00
|
|
|
|
"""
|
2026-03-21 14:02:21 +01:00
|
|
|
|
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'>")
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:25:18 +01:00
|
|
|
|
@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'>")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
@app.post("/upload", response_class=HTMLResponse)
|
|
|
|
|
|
async def upload(project_id: int = Form(...), title: str = Form(""), file: UploadFile = File(...)):
|
2026-03-21 13:47:36 +01:00
|
|
|
|
data = await file.read()
|
|
|
|
|
|
if not data:
|
2026-03-21 14:02:21 +01:00
|
|
|
|
raise HTTPException(400, "Leere Datei")
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
JOB_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
filename = (file.filename or "audio.bin").replace("/", "_")
|
|
|
|
|
|
temp_path = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}"
|
|
|
|
|
|
temp_path.write_bytes(data)
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
job_id = enqueue_job(
|
|
|
|
|
|
"upload",
|
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
|
title=(title or "").strip() or filename,
|
|
|
|
|
|
file_path=str(temp_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/jobs?queued={job_id}'>")
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
@app.get("/library", response_class=HTMLResponse)
|
2026-03-21 15:38:47 +01:00
|
|
|
|
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 ""
|
|
|
|
|
|
|
2026-03-21 13:47:36 +01:00
|
|
|
|
with db() as c:
|
2026-03-21 14:02:21 +01:00
|
|
|
|
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
|
2026-03-21 15:38:47 +01:00
|
|
|
|
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()
|
2026-03-21 14:02:21 +01:00
|
|
|
|
|
|
|
|
|
|
p_opts = "<option value=''>Alle</option>" + "".join(
|
2026-03-21 15:38:47 +01:00
|
|
|
|
[f"<option value='{p['id']}' {'selected' if project_id_int==p['id'] else ''}>{p['name']}</option>" for p in projects]
|
2026-03-21 14:02:21 +01:00
|
|
|
|
)
|
2026-03-21 15:41:12 +01:00
|
|
|
|
rows = "".join(
|
2026-03-21 14:02:21 +01:00
|
|
|
|
[
|
2026-03-21 15:41:12 +01:00
|
|
|
|
f"<tr>"
|
|
|
|
|
|
f"<td>#{d['id']}</td>"
|
|
|
|
|
|
f"<td>{d['title']}</td>"
|
|
|
|
|
|
f"<td>{d['kind']}</td>"
|
|
|
|
|
|
f"<td>{d['project']}</td>"
|
|
|
|
|
|
f"<td><small>{d['created_at']}</small></td>"
|
|
|
|
|
|
f"<td class='row'>"
|
|
|
|
|
|
f"<a href='/document/{d['id']}' class='iconbtn' title='Ansehen'>👁️</a>"
|
2026-03-21 14:43:55 +01:00
|
|
|
|
f"<a href='/document/{d['id']}/download.md' class='iconbtn' title='Download'>⬇️</a>"
|
2026-03-21 15:41:12 +01:00
|
|
|
|
f"<a href='#' class='iconbtn' title='Umbenennen' onclick='libRename({d['id']}, {json.dumps(d['title'])});return false;'>✏️</a>"
|
|
|
|
|
|
f"<a href='#' class='iconbtn' title='Verschieben' onclick='libMove({d['id']});return false;'>📁</a>"
|
|
|
|
|
|
f"<a href='#' class='iconbtn' title='Löschen' onclick='libDelete({d['id']});return false;'>🗑️</a>"
|
|
|
|
|
|
f"</td>"
|
|
|
|
|
|
f"</tr>"
|
2026-03-21 14:02:21 +01:00
|
|
|
|
for d in docs
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-03-21 14:43:55 +01:00
|
|
|
|
project_js = json.dumps([{"value": p["id"], "label": p["name"]} for p in projects], ensure_ascii=False)
|
2026-03-21 14:02:21 +01:00
|
|
|
|
body = f"""
|
2026-03-21 15:41:12 +01:00
|
|
|
|
<style>
|
|
|
|
|
|
.toolbar{{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}}
|
|
|
|
|
|
.toolbar .spacer{{flex:1}}
|
|
|
|
|
|
.gridcard{{padding:0;overflow:auto}}
|
|
|
|
|
|
.grid{{width:100%;border-collapse:collapse;min-width:860px}}
|
|
|
|
|
|
.grid th,.grid td{{padding:10px 12px;border-bottom:1px solid #1f2937;text-align:left}}
|
|
|
|
|
|
.grid th{{position:sticky;top:0;background:#0b1222;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.04em}}
|
|
|
|
|
|
.grid tr:hover{{background:rgba(56,189,248,.06)}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
<h2 style='margin-bottom:6px'>Projekte · Dokumente</h2>
|
|
|
|
|
|
<div class='hint' style='margin-bottom:10px'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div>
|
|
|
|
|
|
<form method='get' class='card toolbar'>
|
|
|
|
|
|
<select name='project_id'>{p_opts}</select>
|
|
|
|
|
|
<input name='q_title' placeholder='Titel enthält …' value='{title_q.replace("'", "'")}' style='min-width:220px'>
|
|
|
|
|
|
<input name='q_content' placeholder='Inhalt enthält …' value='{content_q.replace("'", "'")}' style='min-width:260px'>
|
|
|
|
|
|
<button type='submit'>Filtern</button>
|
|
|
|
|
|
<a class='iconbtn' href='/library' title='Filter zurücksetzen'>↺</a>
|
|
|
|
|
|
<div class='spacer'></div>
|
|
|
|
|
|
<small>{len(docs)} Treffer</small>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
</form>
|
2026-03-21 15:41:12 +01:00
|
|
|
|
<div class='card gridcard'>
|
|
|
|
|
|
<table class='grid'>
|
|
|
|
|
|
<thead><tr><th>ID</th><th>Titel</th><th>Typ</th><th>Projekt</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
|
|
|
|
|
|
<tbody>{rows or "<tr><td colspan='6'>Keine Einträge.</td></tr>"}</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-03-21 14:43:55 +01:00
|
|
|
|
<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();
|
|
|
|
|
|
}}
|
|
|
|
|
|
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`, {{}});
|
|
|
|
|
|
}};
|
|
|
|
|
|
</script>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
"""
|
|
|
|
|
|
return layout("Library", body)
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
@app.get("/document/{doc_id}", response_class=HTMLResponse)
|
|
|
|
|
|
def view_document(doc_id: int):
|
2026-03-21 13:47:36 +01:00
|
|
|
|
with db() as c:
|
2026-03-21 14:02:21 +01:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-03-21 14:32:24 +01:00
|
|
|
|
rendered = md.markdown(d["content_md"] or "", extensions=["fenced_code", "tables", "nl2br"])
|
2026-03-21 14:35:26 +01:00
|
|
|
|
projects = get_projects()
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
body = f"""
|
|
|
|
|
|
<h2>Dokument #{d['id']} – {d['title']}</h2>
|
2026-03-21 14:35:26 +01:00
|
|
|
|
<p>
|
|
|
|
|
|
<small>Projekt: {d['project']} · Typ: {d['kind']} · {d['created_at']}</small>
|
|
|
|
|
|
|
|
|
|
|
|
<a title='Download .md' href='/document/{doc_id}/download.md' style='text-decoration:none'>⬇️</a>
|
|
|
|
|
|
<a title='Umbenennen' href='#' onclick='renameDoc();return false;' style='text-decoration:none'>✏️</a>
|
|
|
|
|
|
<a title='Verschieben' href='#' onclick='moveDoc();return false;' style='text-decoration:none'>📁</a>
|
|
|
|
|
|
<a title='Löschen' href='#' onclick='deleteDoc();return false;' style='text-decoration:none'>🗑️</a>
|
|
|
|
|
|
</p>
|
2026-03-21 14:32:24 +01:00
|
|
|
|
<div class='card mdview'>{rendered}</div>
|
2026-03-21 14:35:26 +01:00
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-03-21 14:38:40 +01:00
|
|
|
|
window.postForm = async function(url, data) {{
|
2026-03-21 14:35:26 +01:00
|
|
|
|
const body = new URLSearchParams(data);
|
2026-03-21 14:38:40 +01:00
|
|
|
|
const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body}});
|
|
|
|
|
|
if (!r.ok) {{ alert('Fehler: '+r.status); return; }}
|
2026-03-21 14:35:26 +01:00
|
|
|
|
location.href = '/library';
|
2026-03-21 14:38:40 +01:00
|
|
|
|
}};
|
2026-03-21 14:43:55 +01:00
|
|
|
|
window.renameDoc = async function() {{
|
|
|
|
|
|
const v = await window.uiPrompt('Neuer Dokumentname', {json.dumps(d['title'])});
|
2026-03-21 14:35:26 +01:00
|
|
|
|
if (v===null) return;
|
2026-03-21 14:38:40 +01:00
|
|
|
|
window.postForm('/document/{doc_id}/rename', {{title:v}});
|
|
|
|
|
|
}};
|
2026-03-21 14:43:55 +01:00
|
|
|
|
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;
|
2026-03-21 14:38:40 +01:00
|
|
|
|
window.postForm('/document/{doc_id}/move', {{project_id:v}});
|
|
|
|
|
|
}};
|
2026-03-21 14:43:55 +01:00
|
|
|
|
window.deleteDoc = async function() {{
|
|
|
|
|
|
const ok = await window.uiConfirm('Dokument wirklich löschen?');
|
|
|
|
|
|
if (!ok) return;
|
2026-03-21 14:38:40 +01:00
|
|
|
|
window.postForm('/document/{doc_id}/delete', {{}});
|
|
|
|
|
|
}};
|
2026-03-21 14:35:26 +01:00
|
|
|
|
</script>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
"""
|
|
|
|
|
|
return layout("Dokument", body)
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
|
2026-03-21 14:21:10 +01:00
|
|
|
|
@app.get("/document/{doc_id}/download.md", response_class=PlainTextResponse)
|
2026-03-21 14:02:21 +01:00
|
|
|
|
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")
|
2026-03-21 14:27:52 +01:00
|
|
|
|
|
|
|
|
|
|
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}"},
|
|
|
|
|
|
)
|
2026-03-21 14:02:21 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:25:18 +01:00
|
|
|
|
@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'>")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
@app.get("/prompts", response_class=HTMLResponse)
|
|
|
|
|
|
def prompts_page():
|
|
|
|
|
|
with db() as c:
|
|
|
|
|
|
prompts = c.execute("SELECT * FROM prompts ORDER BY name").fetchall()
|
|
|
|
|
|
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
p_list = "".join(
|
|
|
|
|
|
[
|
|
|
|
|
|
f"<div class='card'><b>{p['name']}</b><pre>{(p['prompt'] or '').replace('<','<')}</pre>"
|
2026-03-21 14:25:18 +01:00
|
|
|
|
f"<form method='post' action='/prompts/update'><input type='hidden' name='id' value='{p['id']}'><input name='name' value='{p['name']}'><br><textarea name='prompt'>{p['prompt']}</textarea><br><button>Speichern</button></form>"
|
|
|
|
|
|
f"<form method='post' action='/prompts/{p['id']}/delete' onsubmit=\"return confirm('Prompt löschen?')\" style='margin-top:6px'><button>Löschen</button></form>"
|
|
|
|
|
|
f"</div>"
|
2026-03-21 14:02:21 +01:00
|
|
|
|
for p in prompts
|
|
|
|
|
|
]
|
2026-03-21 13:47:36 +01:00
|
|
|
|
)
|
2026-03-21 14:02:21 +01:00
|
|
|
|
project_opts = "".join([f"<option value='{p['name']}'>{p['name']}</option>" for p in projects])
|
2026-03-21 14:25:18 +01:00
|
|
|
|
project_list = "".join([
|
|
|
|
|
|
f"<div class='card'><b>{p['name']}</b>"
|
|
|
|
|
|
f"<form method='post' action='/projects/update' class='row' style='margin-top:6px'>"
|
|
|
|
|
|
f"<input type='hidden' name='id' value='{p['id']}'><input name='name' value='{p['name']}'><button>Umbenennen</button></form>"
|
|
|
|
|
|
f"<form method='post' action='/projects/{p['id']}/delete' onsubmit=\"return confirm('Projekt löschen? Dokumente werden auf Default verschoben.')\" style='margin-top:6px'><button>Löschen</button></form>"
|
|
|
|
|
|
f"</div>" for p in projects
|
|
|
|
|
|
])
|
2026-03-21 14:02:21 +01:00
|
|
|
|
|
|
|
|
|
|
body = f"""
|
|
|
|
|
|
<h2>Prompt-Konfiguration</h2>
|
|
|
|
|
|
<div class='card'>
|
|
|
|
|
|
<form method='post' action='/prompts/add'>
|
|
|
|
|
|
<h4>Neuer Prompt</h4>
|
|
|
|
|
|
<input name='name' placeholder='Name' required>
|
|
|
|
|
|
<br><textarea name='prompt' placeholder='Prompttext' required></textarea>
|
|
|
|
|
|
<br><button type='submit'>Anlegen</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class='card'>
|
|
|
|
|
|
<form method='post' action='/projects'>
|
|
|
|
|
|
<h4>Neues Projekt</h4>
|
|
|
|
|
|
<input name='name' list='projectNames' placeholder='Projektname' required>
|
|
|
|
|
|
<datalist id='projectNames'>{project_opts}</datalist>
|
|
|
|
|
|
<button type='submit'>Anlegen</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
2026-03-21 14:25:18 +01:00
|
|
|
|
<h3>Projekte</h3>
|
|
|
|
|
|
{project_list}
|
|
|
|
|
|
<h3>Prompts</h3>
|
2026-03-21 14:02:21 +01:00
|
|
|
|
{p_list}
|
|
|
|
|
|
"""
|
|
|
|
|
|
return layout("Prompts", body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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'>")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:25:18 +01:00
|
|
|
|
@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'>")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:27:43 +01:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:21:00 +01:00
|
|
|
|
def _fmt_elapsed(start_iso: Optional[str], end_iso: Optional[str] = None) -> str:
|
2026-03-21 15:27:43 +01:00
|
|
|
|
s = _parse_utcish(start_iso)
|
|
|
|
|
|
if not s:
|
2026-03-21 15:21:00 +01:00
|
|
|
|
return "-"
|
|
|
|
|
|
try:
|
2026-03-21 15:27:43 +01:00
|
|
|
|
e = _parse_utcish(end_iso) if end_iso else datetime.utcnow()
|
|
|
|
|
|
if not e:
|
|
|
|
|
|
e = datetime.utcnow()
|
2026-03-21 15:21:00 +01:00
|
|
|
|
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 "-"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:07:31 +01:00
|
|
|
|
def _jobs_payload(limit: int = 200):
|
2026-03-21 15:00:21 +01:00
|
|
|
|
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
|
2026-03-21 15:07:31 +01:00
|
|
|
|
ORDER BY j.id DESC LIMIT ?
|
|
|
|
|
|
""",
|
|
|
|
|
|
(limit,),
|
2026-03-21 15:00:21 +01:00
|
|
|
|
).fetchall()
|
2026-03-21 15:07:31 +01:00
|
|
|
|
return [dict(j) for j in jobs]
|
2026-03-21 15:00:21 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:07:31 +01:00
|
|
|
|
@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")
|
2026-03-21 15:19:03 +01:00
|
|
|
|
if j["status"] not in ("done", "error", "cancelled"):
|
|
|
|
|
|
_job_set(job_id, status="cancelled", finished_at=now_iso(), error="Cancelled by user")
|
2026-03-21 15:07:31 +01:00
|
|
|
|
return {"ok": True, "status": "cancelled"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:19:03 +01:00
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:07:31 +01:00
|
|
|
|
@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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:19:03 +01:00
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 15:07:31 +01:00
|
|
|
|
@app.get("/jobs", response_class=HTMLResponse)
|
|
|
|
|
|
def jobs_page(queued: Optional[int] = None):
|
2026-03-21 15:10:39 +01:00
|
|
|
|
items = _jobs_payload(200)
|
|
|
|
|
|
pre = "".join([
|
2026-03-21 15:13:17 +01:00
|
|
|
|
(
|
2026-03-21 15:23:09 +01:00
|
|
|
|
f"<div class='card'><b>Job #{it['id']}</b> [{it['kind']}] · <b>{it['status']}</b> · läuft: <span class='elapsed' data-start='{(it.get('started_at') or it.get('created_at') or '')}' data-end='{(it.get('finished_at') or '')}'>{_fmt_elapsed(it.get('started_at') or it.get('created_at'), it.get('finished_at'))}</span><br>"
|
2026-03-21 15:13:17 +01:00
|
|
|
|
f"<small>{it['created_at']}</small><br>"
|
|
|
|
|
|
f"<div class='row' style='margin-top:8px'>"
|
|
|
|
|
|
+ (
|
|
|
|
|
|
""
|
|
|
|
|
|
if it["status"] in ("done", "error", "cancelled")
|
|
|
|
|
|
else (
|
2026-03-21 15:19:03 +01:00
|
|
|
|
f"<form method='post' action='/jobs/{it['id']}/cancel-form' style='display:inline'>"
|
2026-03-21 15:13:17 +01:00
|
|
|
|
f"<button class='iconbtn' title='Abbrechen'>⛔</button></form> "
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
+ (
|
2026-03-21 15:19:03 +01:00
|
|
|
|
f"<form method='post' action='/jobs/{it['id']}/delete-form' style='display:inline'>"
|
2026-03-21 15:13:17 +01:00
|
|
|
|
f"<button class='iconbtn' title='Löschen'>🗑️</button></form> "
|
|
|
|
|
|
)
|
|
|
|
|
|
+ (
|
|
|
|
|
|
f"<a href='/document/{it['result_document_id']}'>Ergebnis öffnen</a>"
|
|
|
|
|
|
if it.get("result_document_id")
|
|
|
|
|
|
else ""
|
|
|
|
|
|
)
|
|
|
|
|
|
+ "</div></div>"
|
|
|
|
|
|
)
|
2026-03-21 15:10:39 +01:00
|
|
|
|
for it in items
|
|
|
|
|
|
]) or "<p>Keine Jobs.</p>"
|
|
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
notice = f"<p><b>Job #{queued} wurde eingereiht.</b></p>" if queued else ""
|
|
|
|
|
|
body = f"""
|
|
|
|
|
|
<h2>Hintergrundverarbeitung</h2>
|
2026-03-21 15:07:31 +01:00
|
|
|
|
<p class='hint'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p>
|
2026-03-21 15:00:21 +01:00
|
|
|
|
{notice}
|
2026-03-21 15:10:39 +01:00
|
|
|
|
<div id='jobs-status' class='hint'>Live-Update aktiv …</div>
|
|
|
|
|
|
<div id='jobs-root'>{pre}</div>
|
2026-03-21 15:07:31 +01:00
|
|
|
|
<script>
|
2026-03-21 15:27:43 +01:00
|
|
|
|
function parseUtcish(ts) {{
|
|
|
|
|
|
if(!ts) return NaN;
|
|
|
|
|
|
const hasZone = /Z$|[+-]\d\d:\d\d$/.test(ts);
|
|
|
|
|
|
return Date.parse(hasZone ? ts : (ts + 'Z')); // DB stores UTC without suffix
|
|
|
|
|
|
}}
|
2026-03-21 15:23:09 +01:00
|
|
|
|
function since(ts, endTs=null) {{
|
2026-03-21 15:07:31 +01:00
|
|
|
|
if(!ts) return '-';
|
2026-03-21 15:27:43 +01:00
|
|
|
|
const startMs = parseUtcish(ts);
|
|
|
|
|
|
const end = endTs ? parseUtcish(endTs) : Date.now();
|
|
|
|
|
|
const s = Math.max(0, Math.floor((end-startMs)/1000));
|
2026-03-21 15:07:31 +01:00
|
|
|
|
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';
|
|
|
|
|
|
}}
|
2026-03-21 15:23:09 +01:00
|
|
|
|
function tickElapsed() {{
|
|
|
|
|
|
document.querySelectorAll('.elapsed').forEach(el => {{
|
|
|
|
|
|
const start = el.getAttribute('data-start') || '';
|
|
|
|
|
|
const end = el.getAttribute('data-end') || '';
|
|
|
|
|
|
el.textContent = since(start, end || null);
|
|
|
|
|
|
}});
|
|
|
|
|
|
}}
|
2026-03-21 15:07:31 +01:00
|
|
|
|
async function post(url) {{
|
|
|
|
|
|
const r = await fetch(url, {{method:'POST'}});
|
|
|
|
|
|
if(!r.ok) alert('Fehler '+r.status);
|
|
|
|
|
|
}}
|
|
|
|
|
|
async function renderJobs() {{
|
2026-03-21 15:10:39 +01:00
|
|
|
|
try {{
|
|
|
|
|
|
const r = await fetch('/jobs/data');
|
|
|
|
|
|
const j = await r.json();
|
|
|
|
|
|
const root = document.getElementById('jobs-root');
|
|
|
|
|
|
root.innerHTML = '';
|
|
|
|
|
|
if(!j.items.length) {{ root.innerHTML = '<p>Keine Jobs.</p>'; return; }}
|
|
|
|
|
|
for(const it of j.items) {{
|
|
|
|
|
|
const d = document.createElement('div'); d.className='card';
|
|
|
|
|
|
const actions = [];
|
2026-03-21 15:24:49 +01:00
|
|
|
|
if(!['done','error','cancelled'].includes(it.status)) actions.push("<a href='#' class='iconbtn' onclick='cancelJob("+it.id+");return false;'>⛔</a>");
|
|
|
|
|
|
actions.push("<a href='#' class='iconbtn' onclick='deleteJob("+it.id+");return false;'>🗑️</a>");
|
2026-03-21 15:10:39 +01:00
|
|
|
|
const result = it.result_document_id ? ("<a href='/document/"+it.result_document_id+"'>Ergebnis öffnen</a>") : '';
|
|
|
|
|
|
const err = it.error ? ("<pre>"+String(it.error).replaceAll('<','<')+"</pre>") : '';
|
2026-03-21 15:23:09 +01:00
|
|
|
|
const startTs = (it.started_at || it.created_at || '');
|
|
|
|
|
|
const endTs = (it.finished_at || '');
|
|
|
|
|
|
d.innerHTML = "<b>Job #"+it.id+"</b> ["+it.kind+"] · <b>"+it.status+"</b> · läuft: <span class='elapsed' data-start='"+startTs+"' data-end='"+endTs+"'>"+since(startTs, endTs || null)+"</span><br><small>"+it.created_at+"</small><br>"
|
2026-03-21 15:10:39 +01:00
|
|
|
|
+(it.project_name?('Projekt: '+it.project_name+'<br>'):'')
|
|
|
|
|
|
+(it.document_title?('Dokument: '+it.document_title+'<br>'):'')
|
|
|
|
|
|
+(it.prompt_name?('Prompt: '+it.prompt_name+'<br>'):'')
|
|
|
|
|
|
+"<div class='row' style='margin-top:8px'>"+actions.join(' ')+" "+result+"</div>"+err;
|
|
|
|
|
|
root.appendChild(d);
|
|
|
|
|
|
}}
|
2026-03-21 15:23:09 +01:00
|
|
|
|
tickElapsed();
|
2026-03-21 15:10:39 +01:00
|
|
|
|
document.getElementById('jobs-status').textContent = 'Live-Update aktiv';
|
|
|
|
|
|
}} catch(e) {{
|
|
|
|
|
|
document.getElementById('jobs-status').textContent = 'Live-Update Fehler: '+e;
|
2026-03-21 15:07:31 +01:00
|
|
|
|
}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
async function cancelJob(id) {{ if(!confirm('Job abbrechen?')) return; await post('/jobs/'+id+'/cancel'); renderJobs(); }}
|
|
|
|
|
|
async function deleteJob(id) {{ if(!confirm('Job löschen?')) return; await post('/jobs/'+id+'/delete'); renderJobs(); }}
|
2026-03-21 15:23:09 +01:00
|
|
|
|
renderJobs();
|
|
|
|
|
|
tickElapsed();
|
|
|
|
|
|
setInterval(tickElapsed, 1000);
|
|
|
|
|
|
setInterval(renderJobs, 5000);
|
2026-03-21 15:07:31 +01:00
|
|
|
|
</script>
|
2026-03-21 15:00:21 +01:00
|
|
|
|
"""
|
|
|
|
|
|
return layout("Jobs", body)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
@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()
|
|
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
|
|
body = f"""
|
|
|
|
|
|
<h2>Prompt ausführen</h2>
|
|
|
|
|
|
<form method='post' action='/run' class='card'>
|
|
|
|
|
|
<label>Dokument:</label><br>
|
|
|
|
|
|
<select name='document_id' style='width:100%'>{d_opts}</select><br><br>
|
|
|
|
|
|
<label>Prompt:</label><br>
|
|
|
|
|
|
<select name='prompt_id' style='width:100%'>{p_opts}</select><br><br>
|
|
|
|
|
|
<button type='submit'>Ausführen (Qwen)</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
"""
|
|
|
|
|
|
return layout("Run", body)
|
|
|
|
|
|
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 14:02:21 +01:00
|
|
|
|
@app.post("/run", response_class=HTMLResponse)
|
|
|
|
|
|
def run_prompt(document_id: int = Form(...), prompt_id: int = Form(...)):
|
|
|
|
|
|
with db() as c:
|
2026-03-21 15:00:21 +01:00
|
|
|
|
doc = c.execute("SELECT id FROM documents WHERE id=?", (document_id,)).fetchone()
|
|
|
|
|
|
prm = c.execute("SELECT id FROM prompts WHERE id=?", (prompt_id,)).fetchone()
|
2026-03-21 14:02:21 +01:00
|
|
|
|
if not doc or not prm:
|
|
|
|
|
|
raise HTTPException(404, "Dokument oder Prompt nicht gefunden")
|
2026-03-21 13:47:36 +01:00
|
|
|
|
|
2026-03-21 15:00:21 +01:00
|
|
|
|
job_id = enqueue_job("analysis", document_id=document_id, prompt_id=prompt_id)
|
|
|
|
|
|
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/jobs?queued={job_id}'>")
|