feat: settings page — PipeWire audio device + remote Whisper/Ollama config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -279,6 +279,15 @@ async def create_combined_source(body: dict, user: dict = Depends(current_user))
|
||||
return {"device": "transkriptor-combined.monitor", "module_ids": ids}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page_route(user: dict = Depends(current_user)):
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from pathlib import Path
|
||||
if not user.get("is_admin"):
|
||||
return RedirectResponse("/")
|
||||
return FileResponse(str(Path(__file__).parent.parent / "frontend" / "settings.html"))
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket):
|
||||
from auth import get_user_for_token
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>tüit Transkriptor — Einstellungen</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Overpass:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root { --red:#DA251C;--yellow:#FFD802;--bg:#111;--surface:#1a1a1a;--surface2:#232323;--text:#e8e8e8;--muted:#888;--border:#2e2e2e; }
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:'Overpass',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;flex-direction:column;}
|
||||
header{display:flex;align-items:center;gap:12px;padding:16px 24px;border-bottom:1px solid var(--border);}
|
||||
.header-logo{height:28px;width:auto;display:block;}
|
||||
.header-divider{width:1px;height:20px;background:var(--border);flex-shrink:0;}
|
||||
.header-appname{font-size:1rem;font-weight:600;letter-spacing:.04em;color:var(--muted);}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px;}
|
||||
.back-btn{font-size:.75rem;padding:4px 10px;border-radius:20px;background:none;border:1px solid var(--border);color:var(--muted);cursor:pointer;font-family:inherit;text-decoration:none;transition:border-color .15s,color .15s;}
|
||||
.back-btn:hover{border-color:var(--red);color:var(--red);}
|
||||
main{flex:1;display:flex;flex-direction:column;gap:24px;padding:24px;max-width:700px;width:100%;margin:0 auto;}
|
||||
h2{font-size:.8rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
||||
.field{display:flex;flex-direction:column;gap:6px;margin-bottom:14px;}
|
||||
label{font-size:.78rem;color:var(--muted);letter-spacing:.04em;}
|
||||
select,input[type=text]{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 12px;font-family:inherit;font-size:.9rem;outline:none;transition:border-color .15s;width:100%;}
|
||||
select:focus,input[type=text]:focus{border-color:var(--yellow);}
|
||||
.btn-row{display:flex;gap:10px;margin-top:4px;}
|
||||
.btn{font-size:.82rem;padding:8px 16px;border-radius:8px;border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:pointer;font-family:inherit;transition:border-color .15s,background .15s;}
|
||||
.btn:hover{border-color:var(--red);}
|
||||
.btn.primary{background:var(--red);border-color:var(--red);color:#fff;}
|
||||
.btn.primary:hover{background:#b81e16;border-color:#b81e16;}
|
||||
.toast{position:fixed;bottom:24px;right:24px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 16px;font-size:.85rem;opacity:0;transition:opacity .2s;pointer-events:none;}
|
||||
.toast.show{opacity:1;}
|
||||
.combined-form{display:none;flex-direction:column;gap:10px;margin-top:10px;padding:12px;background:var(--surface2);border-radius:8px;border:1px solid var(--border);}
|
||||
.combined-form.visible{display:flex;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="/logo.svg" class="header-logo" alt="tüit">
|
||||
<div class="header-divider"></div>
|
||||
<span class="header-appname">Transkriptor — Einstellungen</span>
|
||||
<div class="header-right">
|
||||
<a href="/" class="back-btn">← Zurück</a>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Audio</h2>
|
||||
<div class="field">
|
||||
<label>Aufnahmequelle</label>
|
||||
<select id="audio-device">
|
||||
<option value="">Systemstandard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" id="refresh-devices-btn">Geräte aktualisieren</button>
|
||||
<button class="btn" id="create-combined-btn">Combined Source erstellen</button>
|
||||
</div>
|
||||
<div class="combined-form" id="combined-form">
|
||||
<div class="field">
|
||||
<label>Mikrofon</label>
|
||||
<select id="combined-mic"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>System-Audio Monitor</label>
|
||||
<select id="combined-monitor"></select>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn primary" id="combined-confirm-btn">Erstellen</button>
|
||||
<button class="btn" id="combined-cancel-btn">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Verarbeitung</h2>
|
||||
<div class="field">
|
||||
<label>Whisper Server URL (leer = lokal)</label>
|
||||
<input type="text" id="whisper-url" placeholder="http://beastix:8000">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Whisper Modell</label>
|
||||
<input type="text" id="whisper-model" placeholder="large-v3">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ollama Server URL</label>
|
||||
<input type="text" id="ollama-url" placeholder="http://localhost:11434">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ollama Modell</label>
|
||||
<select id="ollama-model"></select>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn primary" id="save-btn">Speichern</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
const token = sessionStorage.getItem('token');
|
||||
function authHeaders() {
|
||||
return token ? { 'Authorization': 'Bearer ' + token } : {};
|
||||
}
|
||||
function apiFetch(url, options) {
|
||||
options = options || {};
|
||||
return fetch(url, Object.assign({}, options, {
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, authHeaders(), options.headers || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
let _devices = [];
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(function() { t.classList.remove('show'); }, 2500);
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
const r = await apiFetch('/audio/devices');
|
||||
if (!r.ok) return;
|
||||
_devices = await r.json();
|
||||
const sel = document.getElementById('audio-device');
|
||||
const current = sel.value;
|
||||
sel.replaceChildren(new Option('Systemstandard', ''));
|
||||
_devices.forEach(function(d) { sel.appendChild(new Option(d.name, d.name)); });
|
||||
if (current) sel.value = current;
|
||||
['combined-mic', 'combined-monitor'].forEach(function(id) {
|
||||
const el = document.getElementById(id);
|
||||
el.replaceChildren();
|
||||
_devices.forEach(function(d) { el.appendChild(new Option(d.name, d.name)); });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOllamaModels(baseUrl, current) {
|
||||
try {
|
||||
const r = await fetch(baseUrl + '/api/tags');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const sel = document.getElementById('ollama-model');
|
||||
sel.replaceChildren();
|
||||
(data.models || []).forEach(function(m) { sel.appendChild(new Option(m.name, m.name)); });
|
||||
if (current) sel.value = current;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const r = await apiFetch('/config');
|
||||
if (!r.ok) return;
|
||||
const cfg = await r.json();
|
||||
document.getElementById('audio-device').value = (cfg.audio && cfg.audio.device) || '';
|
||||
document.getElementById('whisper-url').value = (cfg.whisper && cfg.whisper.base_url) || '';
|
||||
document.getElementById('whisper-model').value = (cfg.whisper && cfg.whisper.model) || 'large-v3';
|
||||
const ollamaUrl = (cfg.ollama && cfg.ollama.base_url) || 'http://localhost:11434';
|
||||
document.getElementById('ollama-url').value = ollamaUrl;
|
||||
await loadOllamaModels(ollamaUrl, cfg.ollama && cfg.ollama.model);
|
||||
}
|
||||
|
||||
document.getElementById('refresh-devices-btn').addEventListener('click', loadDevices);
|
||||
|
||||
document.getElementById('create-combined-btn').addEventListener('click', function() {
|
||||
document.getElementById('combined-form').classList.toggle('visible');
|
||||
});
|
||||
document.getElementById('combined-cancel-btn').addEventListener('click', function() {
|
||||
document.getElementById('combined-form').classList.remove('visible');
|
||||
});
|
||||
document.getElementById('combined-confirm-btn').addEventListener('click', async function() {
|
||||
const mic = document.getElementById('combined-mic').value;
|
||||
const monitor = document.getElementById('combined-monitor').value;
|
||||
const r = await apiFetch('/audio/combined', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mic: mic, monitor: monitor }),
|
||||
});
|
||||
if (!r.ok) { showToast('Fehler beim Erstellen'); return; }
|
||||
const data = await r.json();
|
||||
showToast('Erstellt: ' + data.device);
|
||||
document.getElementById('combined-form').classList.remove('visible');
|
||||
await loadDevices();
|
||||
document.getElementById('audio-device').value = data.device;
|
||||
});
|
||||
|
||||
document.getElementById('ollama-url').addEventListener('change', function(e) {
|
||||
loadOllamaModels(e.target.value, document.getElementById('ollama-model').value);
|
||||
});
|
||||
|
||||
document.getElementById('save-btn').addEventListener('click', async function() {
|
||||
const body = {
|
||||
audio: { device: document.getElementById('audio-device').value },
|
||||
whisper: {
|
||||
base_url: document.getElementById('whisper-url').value,
|
||||
model: document.getElementById('whisper-model').value,
|
||||
},
|
||||
ollama: {
|
||||
base_url: document.getElementById('ollama-url').value,
|
||||
model: document.getElementById('ollama-model').value,
|
||||
},
|
||||
};
|
||||
const r = await apiFetch('/config', { method: 'PUT', body: JSON.stringify(body) });
|
||||
if (r.ok) { showToast('Gespeichert'); } else { showToast('Fehler beim Speichern'); }
|
||||
});
|
||||
|
||||
(async function() {
|
||||
if (!token) { location.href = '/login'; return; }
|
||||
await loadDevices();
|
||||
await loadConfig();
|
||||
})();
|
||||
@@ -44,6 +44,11 @@ async def logo():
|
||||
return FileResponse(str(FRONTEND_DIR / "logo.svg"), media_type="image/svg+xml")
|
||||
|
||||
|
||||
@app.get("/settings.js")
|
||||
async def settingsjs():
|
||||
return FileResponse(str(FRONTEND_DIR / "settings.js"))
|
||||
|
||||
|
||||
# ── PID file ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_pid(pid_path: str):
|
||||
|
||||
Reference in New Issue
Block a user