feat(diarization-ui): configurable Ollama settings page + Traefik integration

- Add settings table (key/value) to SQLite DB
- New /settings page: lazy-load models from Ollama API, configure
  num_ctx, num_predict, repeat_penalty, repeat_last_n, thinking toggle
- Analysis job reads all parameters from DB (fallback to env vars)
- Add Traefik labels and network to docker-compose.yml (voicelog.aquantico.lan)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:29:05 +02:00
parent 39250e6582
commit 98c97ff76c
2 changed files with 203 additions and 5 deletions

196
app.py
View File

@@ -124,6 +124,12 @@ def init_db():
c.execute("ALTER TABLE jobs ADD COLUMN llm_thinking TEXT") c.execute("ALTER TABLE jobs ADD COLUMN llm_thinking TEXT")
except Exception: except Exception:
pass pass
try:
c.execute(
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
except Exception:
pass
# defaults # defaults
c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso())) c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
@@ -147,6 +153,17 @@ def init_db():
) )
def get_setting(key: str, default: str) -> str:
with db() as c:
row = c.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
return row["value"] if row else default
def set_setting(key: str, value: str):
with db() as c:
c.execute("INSERT OR REPLACE INTO settings(key, value) VALUES (?,?)", (key, value))
def layout(title: str, body: str) -> str: def layout(title: str, body: str) -> str:
return f""" return f"""
<!doctype html> <!doctype html>
@@ -272,6 +289,7 @@ window.addEventListener('DOMContentLoaded', () => {{
<a href='/run' onclick='closeNav()'><i class='bi bi-play-circle'></i><span>Prompt ausführen</span></a> <a href='/run' onclick='closeNav()'><i class='bi bi-play-circle'></i><span>Prompt ausführen</span></a>
<a href='/jobs' onclick='closeNav()'><i class='bi bi-cpu'></i><span>Hintergrundjobs</span></a> <a href='/jobs' onclick='closeNav()'><i class='bi bi-cpu'></i><span>Hintergrundjobs</span></a>
<div class='navsection'>System</div> <div class='navsection'>System</div>
<a href='/settings' onclick='closeNav()'><i class='bi bi-gear'></i><span>Einstellungen</span></a>
<a href='/healthz' onclick='closeNav()'><i class='bi bi-heart-pulse'></i><span>Health</span></a> <a href='/healthz' onclick='closeNav()'><i class='bi bi-heart-pulse'></i><span>Health</span></a>
</nav> </nav>
<div class='app'> <div class='app'>
@@ -383,18 +401,24 @@ def _process_analysis_job(job_id: int):
+ f"\\nTEXT:\\n{doc['content_md']}\\n" + f"\\nTEXT:\\n{doc['content_md']}\\n"
) )
num_ctx = _estimate_num_ctx(llm_prompt) num_ctx_setting = get_setting("ollama_num_ctx", "auto")
num_ctx = _estimate_num_ctx(llm_prompt) if num_ctx_setting == "auto" else int(num_ctx_setting)
_job_set(job_id, llm_prompt=f"[num_ctx={num_ctx}]\n\n{llm_prompt}") _job_set(job_id, llm_prompt=f"[num_ctx={num_ctx}]\n\n{llm_prompt}")
with _JOB_STREAM_LOCK: with _JOB_STREAM_LOCK:
_JOB_STREAMS[job_id] = {"thinking": "", "response": ""} _JOB_STREAMS[job_id] = {"thinking": "", "response": ""}
model = get_setting("ollama_model", OLLAMA_MODEL)
think = get_setting("ollama_think", "true" if OLLAMA_THINK else "false") == "true"
num_predict = int(get_setting("ollama_num_predict", str(OLLAMA_NUM_PREDICT)))
repeat_penalty = float(get_setting("ollama_repeat_penalty", "1.15"))
repeat_last_n = int(get_setting("ollama_repeat_last_n", "128"))
r = requests.post( r = requests.post(
f"{OLLAMA_BASE_URL}/api/generate", f"{OLLAMA_BASE_URL}/api/generate",
json={"model": OLLAMA_MODEL, "prompt": llm_prompt, "stream": True, "think": OLLAMA_THINK, "options": { json={"model": model, "prompt": llm_prompt, "stream": True, "think": think, "options": {
"num_ctx": num_ctx, "num_ctx": num_ctx,
"num_predict": OLLAMA_NUM_PREDICT, "num_predict": num_predict,
"repeat_penalty": 1.15, "repeat_penalty": repeat_penalty,
"repeat_last_n": 128, "repeat_last_n": repeat_last_n,
}}, }},
stream=True, stream=True,
timeout=1200, timeout=1200,
@@ -529,6 +553,168 @@ def healthz():
return {"ok": True, "api_base": API_BASE, "ollama_base_url": OLLAMA_BASE_URL, "ollama_model": OLLAMA_MODEL, "db_path": DB_PATH} return {"ok": True, "api_base": API_BASE, "ollama_base_url": OLLAMA_BASE_URL, "ollama_model": OLLAMA_MODEL, "db_path": DB_PATH}
@app.get("/settings/models")
def settings_models():
try:
r = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5)
r.raise_for_status()
names = sorted(m["name"] for m in r.json().get("models", []))
return JSONResponse({"ok": True, "models": names})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e), "models": []})
@app.get("/settings", response_class=HTMLResponse)
def settings_page(msg: str = ""):
current_model = get_setting("ollama_model", OLLAMA_MODEL)
current_think = get_setting("ollama_think", "true" if OLLAMA_THINK else "false") == "true"
current_num_ctx = get_setting("ollama_num_ctx", "auto")
current_num_predict = get_setting("ollama_num_predict", str(OLLAMA_NUM_PREDICT))
current_repeat_penalty = get_setting("ollama_repeat_penalty", "1.15")
current_repeat_last_n = get_setting("ollama_repeat_last_n", "128")
think_checked = "checked" if current_think else ""
ctx_options = [("auto", "Automatisch (empfohlen)"), ("4096", "4 096"), ("8192", "8 192"),
("16384", "16 384"), ("32768", "32 768"), ("65536", "65 536"), ("131072", "131 072")]
ctx_opts_html = "".join(
f"<option value='{v}'{' selected' if v == current_num_ctx else ''}>{l}</option>"
for v, l in ctx_options
)
body = f"""
<div class='d-flex justify-content-between align-items-center mb-3'>
<h2 class='h4 mb-0'>Einstellungen</h2>
</div>
{f"<div class='alert alert-success py-2'>{msg}</div>" if msg else ""}
<div class='card'>
<h5 class='h6 fw-semibold mb-3'><i class='bi bi-robot text-primary me-1'></i> Ollama / LLM</h5>
<form method='post' action='/settings'>
<div class='row g-3'>
<div class='col-12'>
<label class='form-label'>Modell</label>
<div class='input-group'>
<input id='modelInput' class='form-control font-monospace' name='ollama_model'
value='{current_model}' placeholder='z.B. qwen3.5:9b' required>
<button type='button' class='btn btn-outline-secondary' id='loadModelsBtn' onclick='loadModels()'>
<i class='bi bi-arrow-clockwise' id='loadIcon'></i> Modelle laden
</button>
</div>
<div id='modelSelectWrap' class='mt-1 d-none'>
<select id='modelSelect' class='form-select font-monospace'></select>
</div>
<div class='form-text'>Aktuell gespeichert: <code>{current_model}</code></div>
</div>
<div class='col-12 col-md-4'>
<label class='form-label'>Kontextgröße (num_ctx)</label>
<select class='form-select' name='ollama_num_ctx'>{ctx_opts_html}</select>
<div class='form-text'>Automatisch wählt passend zur Promptgröße.</div>
</div>
<div class='col-12 col-md-4'>
<label class='form-label'>Max. Ausgabe-Tokens (num_predict)</label>
<input class='form-control' type='number' name='ollama_num_predict' min='256' max='131072' step='256'
value='{current_num_predict}'>
</div>
<div class='col-12 col-md-2'>
<label class='form-label'>Repeat Penalty</label>
<input class='form-control' type='number' name='ollama_repeat_penalty' min='1.0' max='2.0' step='0.01'
value='{current_repeat_penalty}'>
</div>
<div class='col-12 col-md-2'>
<label class='form-label'>Repeat Last N</label>
<input class='form-control' type='number' name='ollama_repeat_last_n' min='0' max='2048' step='1'
value='{current_repeat_last_n}'>
</div>
<div class='col-12'>
<div class='form-check form-switch'>
<input class='form-check-input' type='checkbox' role='switch' id='thinkSwitch'
name='ollama_think' value='true' {think_checked}>
<label class='form-check-label' for='thinkSwitch'>
Thinking aktivieren <span class='text-secondary small'>(Extended Thinking / Chain-of-Thought)</span>
</label>
</div>
</div>
</div>
<div class='mt-3'>
<button class='btn btn-primary' type='submit'><i class='bi bi-check2-circle'></i> Speichern</button>
</div>
</form>
</div>
<div class='card mt-3'>
<h5 class='h6 fw-semibold mb-2'><i class='bi bi-info-circle text-secondary me-1'></i> Systemkonfiguration (read-only)</h5>
<table class='table table-sm mb-0'>
<tbody>
<tr><td class='text-secondary' style='width:220px'>API_BASE</td><td><code>{API_BASE}</code></td></tr>
<tr><td class='text-secondary'>OLLAMA_BASE_URL</td><td><code>{OLLAMA_BASE_URL}</code></td></tr>
<tr><td class='text-secondary'>DB_PATH</td><td><code>{DB_PATH}</code></td></tr>
</tbody>
</table>
</div>
<script>
async function loadModels() {{
const btn = document.getElementById('loadModelsBtn');
const icon = document.getElementById('loadIcon');
const wrap = document.getElementById('modelSelectWrap');
const sel = document.getElementById('modelSelect');
const inp = document.getElementById('modelInput');
btn.disabled = true;
icon.className = 'bi bi-hourglass-split';
try {{
const res = await fetch('/settings/models');
const data = await res.json();
if (!data.ok || !data.models.length) {{
alert('Keine Modelle gefunden oder Ollama nicht erreichbar:\\n' + (data.error || ''));
return;
}}
const current = inp.value.trim();
sel.innerHTML = data.models.map(m =>
`<option value="${{m}}"${{m === current ? ' selected' : ''}}>${{m}}</option>`
).join('');
if (!data.models.includes(current) && current) {{
sel.innerHTML = `<option value="${{current}}" selected>${{current}} (aktuell)</option>` + sel.innerHTML;
}}
wrap.classList.remove('d-none');
sel.onchange = () => {{ inp.value = sel.value; }};
if (sel.value) inp.value = sel.value;
}} catch(e) {{
alert('Fehler: ' + e);
}} finally {{
btn.disabled = false;
icon.className = 'bi bi-check2';
}}
}}
</script>
"""
return layout("Einstellungen VoiceLog", body)
@app.post("/settings", response_class=HTMLResponse)
def settings_save(
ollama_model: str = Form(...),
ollama_think: Optional[str] = Form(None),
ollama_num_ctx: str = Form("auto"),
ollama_num_predict: str = Form("16384"),
ollama_repeat_penalty: str = Form("1.15"),
ollama_repeat_last_n: str = Form("128"),
):
set_setting("ollama_model", ollama_model.strip())
set_setting("ollama_think", "true" if ollama_think == "true" else "false")
set_setting("ollama_num_ctx", ollama_num_ctx.strip())
set_setting("ollama_num_predict", str(int(float(ollama_num_predict))))
set_setting("ollama_repeat_penalty", str(float(ollama_repeat_penalty)))
set_setting("ollama_repeat_last_n", str(int(float(ollama_repeat_last_n))))
return RedirectResponse("/settings?msg=Einstellungen+gespeichert.", status_code=303)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def upload_page(msg: str = ""): def upload_page(msg: str = ""):
projects = get_projects() projects = get_projects()

View File

@@ -14,6 +14,18 @@ services:
- DB_PATH=/data/ui.db - DB_PATH=/data/ui.db
volumes: volumes:
- diarization_ui_data:/data - diarization_ui_data:/data
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.openwebui.rule=Host(`voicelog.aquantico.lan`)"
- "traefik.http.routers.openwebui.entrypoints=websecure"
- "traefik.http.routers.openwebui.tls=true"
- "traefik.http.services.openwebui.loadbalancer.server.port=8094"
volumes: volumes:
diarization_ui_data: diarization_ui_data:
networks:
traefik:
external: true