monitor/html/js/record.js
fds2610-bot 9426406ee5 Sprachaufnahme: Agent-Kachel, Aufnahme-Webseite und API-Warteschlange
Setzt den in #84 skizzierten Workflow um: record.html nimmt im
Handy-Browser eine Sprachnotiz per MediaRecorder auf und laedt sie
unauthentifiziert zu POST /api/v1/agents/{id}/recordings hoch. Ein
Klick auf die Personen-Figur jedes registrierten Agenten in der
Buero-Visualisierung verlinkt dorthin, ein Zaehler am Schreibtisch
zeigt offene Aufnahmen. Der Agent selbst holt seine Warteschlange
ueber die bestehenden Api-Key-authentifizierten Endpunkte ab
(GET .../recordings, GET /recordings/{id}/audio) und markiert eine
Aufnahme nach der Transkription per PATCH als erledigt - die
eigentliche Transkription (whisper.cpp/faster-whisper) bleibt
agent-seitig, siehe fds2610/Agent#17.

Schliesst #85
2026-09-03 21:26:28 +02:00

151 lines
5.1 KiB
JavaScript

// Aufnahme-Webseite fuer Sprachnotizen an einen Agenten (Issue #85, Folge-Ticket aus #84).
// Laeuft im Handy-Browser eines Menschen, getrennt von der Buero-Visualisierung
// (office.html/office-render.js) - reines Vanilla-JS/DOM ohne Bundler, wie der Rest des
// Frontends. navigator.mediaDevices.getUserMedia()/MediaRecorder verlangen HTTPS (Ausnahme:
// localhost) - ohne das bricht init() kontrolliert mit einer Fehlermeldung ab, statt dass der
// Start-Knopf stumm nichts tut.
import { fetchAgent, uploadRecording } from "./office-api.js";
const agentNameEl = document.getElementById("agent-name");
const statusEl = document.getElementById("status");
const startButton = document.getElementById("start-button");
const stopButton = document.getElementById("stop-button");
const uploadButton = document.getElementById("upload-button");
const discardButton = document.getElementById("discard-button");
const audioPreview = document.getElementById("audio-preview");
const agentId = new URLSearchParams(window.location.search).get("agent");
// MediaRecorder erzeugt je nach Browser unterschiedliche Container/Codecs - der erste vom
// Browser unterstuetzte Typ gewinnt. webm/Opus zuerst (Chrome/Firefox/Desktop), mp4/AAC als
// Fallback fuer Safari/iOS (kennt kein webm). Muss zur Whitelist in
// VoiceRecordingRepository::ALLOWED_MIME_TYPES passen.
const CANDIDATE_MIME_TYPES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
let mediaRecorder = null;
let mediaStream = null;
let recordedChunks = [];
let recordedBlob = null;
function setStatus(message, isError = false) {
statusEl.textContent = message;
statusEl.classList.toggle("status-error", isError);
}
function pickMimeType() {
if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") {
return "";
}
return CANDIDATE_MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) || "";
}
async function init() {
if (!agentId) {
setStatus("Kein Agent angegeben - der Link muss ?agent=<id> enthalten.", true);
startButton.disabled = true;
return;
}
try {
const agent = await fetchAgent(agentId);
if (!agent) {
setStatus(`Unbekannter Agent (Id ${agentId}).`, true);
startButton.disabled = true;
return;
}
agentNameEl.textContent = agent.display_name ?? agent.name ?? `Agent ${agentId}`;
} catch {
// API evtl. gerade nicht erreichbar - der Upload braucht sie erst am Ende, die Aufnahme
// selbst funktioniert auch ohne Namen, deshalb hier nicht hart blockieren.
agentNameEl.textContent = `Agent ${agentId}`;
}
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
setStatus("Aufnahme wird von diesem Browser nicht unterstuetzt (HTTPS oder localhost noetig).", true);
startButton.disabled = true;
}
}
async function startRecording() {
recordedChunks = [];
recordedBlob = null;
audioPreview.hidden = true;
uploadButton.hidden = true;
discardButton.hidden = true;
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
setStatus(`Mikrofon-Zugriff nicht möglich: ${error.message}`, true);
return;
}
const mimeType = pickMimeType();
mediaRecorder = mimeType ? new MediaRecorder(mediaStream, { mimeType }) : new MediaRecorder(mediaStream);
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
});
mediaRecorder.addEventListener("stop", () => {
mediaStream.getTracks().forEach((track) => track.stop());
recordedBlob = new Blob(recordedChunks, { type: mediaRecorder.mimeType || "audio/webm" });
audioPreview.src = URL.createObjectURL(recordedBlob);
audioPreview.hidden = false;
uploadButton.hidden = false;
discardButton.hidden = false;
setStatus("Aufnahme beendet - anhören und hochladen oder verwerfen.");
});
mediaRecorder.start();
startButton.hidden = true;
stopButton.hidden = false;
setStatus("Aufnahme läuft …");
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
}
stopButton.hidden = true;
startButton.hidden = false;
}
function discardRecording() {
recordedBlob = null;
audioPreview.removeAttribute("src");
audioPreview.hidden = true;
uploadButton.hidden = true;
discardButton.hidden = true;
setStatus("Aufnahme verworfen.");
}
async function upload() {
if (!recordedBlob) {
return;
}
uploadButton.disabled = true;
setStatus("Wird hochgeladen …");
const extension = (recordedBlob.type.split("/")[1] || "webm").split(";")[0];
try {
await uploadRecording(agentId, recordedBlob, `aufnahme.${extension}`);
setStatus("Hochgeladen, danke!");
discardRecording();
} catch (error) {
setStatus(error.message, true);
} finally {
uploadButton.disabled = false;
}
}
startButton.addEventListener("click", startRecording);
stopButton.addEventListener("click", stopRecording);
uploadButton.addEventListener("click", upload);
discardButton.addEventListener("click", discardRecording);
init();