feat: voice-agent MVP — LXC web frontend + Mac AI worker
LXC-Frontend (FastAPI + HTML/JS): - Audio-Upload (MP3/WAV/M4A/MP4/OGG/FLAC, max. 500 MB) - SQLite Job-Store, BackgroundTask-Pipeline - Job-Liste mit Live-Status, Downloads (DOCX + JSON) - Mac-Health-Indicator im UI Mac-Worker (FastAPI): - /api/transcribe (lightning-whisper-mlx | faster-whisper | mock) - /api/summarize + /api/protocol via Ollama (llama3.1:8b) - /api/export/docx via python-docx Deploy: - systemd-Service, Nginx Reverse-Proxy - deploy/install.sh: idempotentes LXC-Setup Doku: README.md, lxc-frontend/README.md, mac-worker/README.md
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
const form = document.getElementById("upload-form");
|
||||
const titleInput = document.getElementById("title");
|
||||
const audioInput = document.getElementById("audio");
|
||||
const submitBtn = document.getElementById("submit-btn");
|
||||
const uploadProgress = document.getElementById("upload-progress");
|
||||
const uploadBar = uploadProgress.querySelector(".bar-fill");
|
||||
const uploadMsg = document.getElementById("upload-msg");
|
||||
const jobsBody = document.getElementById("jobs-body");
|
||||
const macStatus = document.getElementById("mac-status");
|
||||
|
||||
async function checkMac() {
|
||||
try {
|
||||
const r = await fetch("/api/mac/health");
|
||||
const j = await r.json();
|
||||
if (r.ok && j.reachable) {
|
||||
macStatus.textContent = "Mac-Backend: online";
|
||||
macStatus.classList.add("ok");
|
||||
macStatus.classList.remove("err");
|
||||
} else {
|
||||
macStatus.textContent = "Mac-Backend: nicht erreichbar";
|
||||
macStatus.classList.add("err");
|
||||
macStatus.classList.remove("ok");
|
||||
}
|
||||
} catch {
|
||||
macStatus.textContent = "Mac-Backend: Fehler";
|
||||
macStatus.classList.add("err");
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
|
||||
}
|
||||
|
||||
function row(job) {
|
||||
const tr = document.createElement("tr");
|
||||
const title = job.title || job.original_name;
|
||||
const dl = (kind, label) => {
|
||||
const a = document.createElement("a");
|
||||
a.textContent = label;
|
||||
a.href = `/api/jobs/${job.id}/download/${kind}`;
|
||||
if (!job.has[kind]) a.classList.add("disabled");
|
||||
return a;
|
||||
};
|
||||
const dlCell = document.createElement("td");
|
||||
dlCell.className = "dl";
|
||||
["docx", "protocol", "summary", "transcript"].forEach((k) =>
|
||||
dlCell.appendChild(dl(k, k === "docx" ? "DOCX" : k.charAt(0).toUpperCase() + k.slice(1) + " (JSON)"))
|
||||
);
|
||||
tr.innerHTML = `
|
||||
<td>#${job.id}</td>
|
||||
<td>${title}<br><small class="muted">${job.original_name}</small></td>
|
||||
<td><span class="status-pill status-${job.status}">${job.status}${job.error ? " — " + job.error : ""}</span></td>
|
||||
<td><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
|
||||
<td><small class="muted">${fmtDate(job.updated_at)}</small></td>
|
||||
`;
|
||||
tr.appendChild(dlCell);
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const r = await fetch("/api/jobs");
|
||||
const jobs = await r.json();
|
||||
jobsBody.innerHTML = "";
|
||||
if (!jobs.length) {
|
||||
jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>';
|
||||
return;
|
||||
}
|
||||
jobs.forEach((j) => jobsBody.appendChild(row(j)));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!audioInput.files.length) return;
|
||||
const fd = new FormData();
|
||||
fd.append("audio", audioInput.files[0]);
|
||||
fd.append("title", titleInput.value);
|
||||
|
||||
submitBtn.disabled = true;
|
||||
uploadProgress.classList.remove("hidden");
|
||||
uploadBar.style.width = "0%";
|
||||
uploadMsg.textContent = "";
|
||||
uploadMsg.className = "msg";
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/jobs");
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const p = Math.round((e.loaded / e.total) * 100);
|
||||
uploadBar.style.width = p + "%";
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
submitBtn.disabled = false;
|
||||
uploadProgress.classList.add("hidden");
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const j = JSON.parse(xhr.responseText);
|
||||
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
|
||||
uploadMsg.className = "msg ok";
|
||||
form.reset();
|
||||
loadJobs();
|
||||
} else {
|
||||
let err = "Upload fehlgeschlagen";
|
||||
try { err = JSON.parse(xhr.responseText).detail || err; } catch {}
|
||||
uploadMsg.textContent = err;
|
||||
uploadMsg.className = "msg err";
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
submitBtn.disabled = false;
|
||||
uploadProgress.classList.add("hidden");
|
||||
uploadMsg.textContent = "Netzwerkfehler beim Upload";
|
||||
uploadMsg.className = "msg err";
|
||||
};
|
||||
xhr.send(fd);
|
||||
});
|
||||
|
||||
checkMac();
|
||||
loadJobs();
|
||||
setInterval(loadJobs, 3000);
|
||||
setInterval(checkMac, 15000);
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Voice-Agent — Transkription</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Voice-Agent</h1>
|
||||
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
|
||||
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="card">
|
||||
<h2>Neue Aufnahme hochladen</h2>
|
||||
<form id="upload-form">
|
||||
<label>
|
||||
Sitzungstitel (optional)
|
||||
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung 13.05.2026" />
|
||||
</label>
|
||||
<label>
|
||||
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
|
||||
<input type="file" name="audio" id="audio" accept=".mp3,.wav,.m4a,.mp4,.ogg,.flac" required />
|
||||
</label>
|
||||
<button type="submit" id="submit-btn">Hochladen & verarbeiten</button>
|
||||
</form>
|
||||
<div id="upload-progress" class="hidden">
|
||||
<div class="bar"><div class="bar-fill"></div></div>
|
||||
<small class="muted">Upload läuft …</small>
|
||||
</div>
|
||||
<p id="upload-msg" class="msg"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Jobs</h2>
|
||||
<table id="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Titel / Datei</th><th>Status</th><th>Fortschritt</th><th>Aktualisiert</th><th>Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobs-body">
|
||||
<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<small class="muted">DSGVO-konform, alles lokal verarbeitet.</small>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--card: #181c24;
|
||||
--border: #262b36;
|
||||
--text: #e6e8eb;
|
||||
--muted: #8b93a3;
|
||||
--accent: #4f8cff;
|
||||
--accent-hover: #3a78f0;
|
||||
--ok: #2ecc71;
|
||||
--warn: #f1c40f;
|
||||
--err: #e74c3c;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: 32px 24px 16px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header h1 { margin: 0 0 4px; font-size: 28px; letter-spacing: .3px; }
|
||||
.sub { color: var(--muted); margin: 0 0 12px; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.ok { color: var(--ok); border-color: rgba(46,204,113,.4); }
|
||||
.badge.err { color: var(--err); border-color: rgba(231,76,60,.4); }
|
||||
main {
|
||||
max-width: 1000px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.card h2 { margin-top: 0; font-size: 18px; }
|
||||
form { display: grid; gap: 12px; }
|
||||
label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); }
|
||||
input[type=text], input[type=file] {
|
||||
background: #0f1219;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: 0;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
justify-self: start;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.hidden { display: none; }
|
||||
.msg { min-height: 1em; margin: 8px 0 0; font-size: 13px; }
|
||||
.msg.ok { color: var(--ok); }
|
||||
.msg.err { color: var(--err); }
|
||||
.muted { color: var(--muted); }
|
||||
.bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #0f1219;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width .25s ease;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status-queued { color: var(--muted); }
|
||||
.status-transcribing, .status-summarizing, .status-protocolling, .status-exporting { color: var(--warn); border-color: rgba(241,196,15,.4); }
|
||||
.status-done { color: var(--ok); border-color: rgba(46,204,113,.4); }
|
||||
.status-failed { color: var(--err); border-color: rgba(231,76,60,.4); }
|
||||
.dl a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
margin-right: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dl a:hover { text-decoration: underline; }
|
||||
.dl .disabled { color: var(--muted); pointer-events: none; }
|
||||
footer { text-align: center; padding: 24px 16px 32px; }
|
||||
Reference in New Issue
Block a user