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:
196
app.py
196
app.py
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user