root 5321c8602e 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
2026-05-13 15:33:53 +00:00

126 lines
4.0 KiB
JavaScript

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);