Both run on pinix, published only through the pinix Tailnet namespace and Traefik's web-internal entrypoint (unbound local-data + Headscale split DNS for intent./ereader.domain2). Live Intent reads its OpenAI key from the new pinix-only agenix secret. ereaderViewer is a path: flake input for now; the repo split plan moves it into its own repository on cgit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
7.8 KiB
JavaScript
210 lines
7.8 KiB
JavaScript
const start = document.querySelector("#start");
|
|
const mute = document.querySelector("#mute");
|
|
const end = document.querySelector("#end");
|
|
const retry = document.querySelector("#retry");
|
|
const status = document.querySelector("#status");
|
|
const usage = document.querySelector("#usage");
|
|
const captions = document.querySelector("#captions");
|
|
const download = document.querySelector("#download");
|
|
|
|
let peer;
|
|
let events;
|
|
let microphone;
|
|
let transcript = [];
|
|
let conversationId;
|
|
let ready = false;
|
|
let muted = false;
|
|
let terminalState;
|
|
let saving = false;
|
|
let saved = false;
|
|
let closeTimer;
|
|
let sessionTimer;
|
|
let sessionMaxSeconds = 600;
|
|
|
|
function setStatus(message) { status.textContent = message; }
|
|
function eventId(prefix) { return `${prefix}-${crypto.randomUUID()}`; }
|
|
|
|
function cleanup() {
|
|
clearTimeout(closeTimer);
|
|
clearTimeout(sessionTimer);
|
|
microphone?.getTracks().forEach((track) => track.stop());
|
|
events?.close();
|
|
peer?.close();
|
|
microphone = undefined;
|
|
events = undefined;
|
|
peer = undefined;
|
|
ready = false;
|
|
muted = false;
|
|
start.disabled = false;
|
|
mute.disabled = true;
|
|
end.disabled = true;
|
|
}
|
|
|
|
function addCaption(speaker, delta) {
|
|
if (!delta) return;
|
|
const previous = transcript.at(-1);
|
|
if (previous?.speaker === speaker && previous.text.length < 3500) {
|
|
previous.text += delta;
|
|
previous.element.lastChild.textContent = previous.text;
|
|
return;
|
|
}
|
|
const element = document.createElement("div");
|
|
element.className = "caption";
|
|
const label = document.createElement("strong");
|
|
label.textContent = `${speaker}: `;
|
|
const value = document.createTextNode(delta);
|
|
element.append(label, value);
|
|
captions.append(element);
|
|
transcript.push({ speaker, text: delta, element });
|
|
captions.scrollTop = captions.scrollHeight;
|
|
}
|
|
|
|
function send(event) {
|
|
if (events?.readyState === "open") events.send(JSON.stringify(event));
|
|
}
|
|
|
|
async function waitForIce(connection) {
|
|
if (connection.iceGatheringState === "complete") return;
|
|
await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error("ICE-Sammlung hat zu lange gedauert.")), 10_000);
|
|
connection.addEventListener("icegatheringstatechange", function stateChanged() {
|
|
if (connection.iceGatheringState !== "complete") return;
|
|
clearTimeout(timeout);
|
|
connection.removeEventListener("icegatheringstatechange", stateChanged);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function saveTranscript() {
|
|
if (saved || saving || transcript.length === 0 || !conversationId || !terminalState) {
|
|
if (transcript.length === 0 && terminalState) setStatus("Kein Transkript empfangen; es konnte keine Datei gespeichert werden.");
|
|
return;
|
|
}
|
|
saving = true;
|
|
retry.hidden = true;
|
|
setStatus(terminalState === "confirmed" ? "Speichere Transkript lokal auf pinix …" : "Speichere unbestätigt beendetes Transkript lokal auf pinix …");
|
|
try {
|
|
const response = await fetch("/api/transcripts", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
conversationId,
|
|
transcript: transcript.map(({ speaker, text }) => ({ speaker, text })),
|
|
finalization: terminalState,
|
|
}),
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error ?? "Speichern fehlgeschlagen.");
|
|
saved = true;
|
|
download.href = result.url;
|
|
download.hidden = false;
|
|
setStatus(terminalState === "confirmed"
|
|
? "Transkript gespeichert. Über den Link kannst du die gespeicherte transcript.md herunterladen."
|
|
: "Transkript trotz unbestätigtem Sessionende gespeichert. Der Abschlussstatus steht in transcript.md.");
|
|
} catch (error) {
|
|
setStatus(`Transkript konnte nicht gespeichert werden: ${error instanceof Error ? error.message : String(error)}`);
|
|
retry.hidden = false;
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
function finishAndSave(state) {
|
|
if (terminalState) return;
|
|
terminalState = state;
|
|
cleanup();
|
|
void saveTranscript();
|
|
}
|
|
|
|
function finishConversation() {
|
|
if (!ready) return;
|
|
end.disabled = true;
|
|
setStatus("Beende Gespräch und warte auf die finale Session-Bestätigung …");
|
|
send({ type: "session.close", event_id: eventId("close") });
|
|
closeTimer = setTimeout(() => finishAndSave("unconfirmed"), 15_000);
|
|
}
|
|
|
|
function handleEvent(event) {
|
|
if (event.type === "session.started") {
|
|
ready = true;
|
|
mute.disabled = false;
|
|
end.disabled = false;
|
|
setStatus("Verbunden. Du kannst jetzt sprechen.");
|
|
sessionTimer = setTimeout(() => {
|
|
setStatus("Zeitlimit erreicht; Gespräch wird beendet.");
|
|
finishConversation();
|
|
}, sessionMaxSeconds * 1000);
|
|
} else if (event.type === "session.input_transcript.delta") {
|
|
addCaption("Nutzer", event.delta);
|
|
} else if (event.type === "session.output_transcript.delta") {
|
|
addCaption("Assistent", event.delta);
|
|
} else if (event.type === "session.usage.updated") {
|
|
usage.textContent = `Bisherige GPT-Live-Dauer: ${event.usage?.seconds ?? 0} s. Responses-Delegation während des Gesprächs ist separat kostenpflichtig.`;
|
|
} else if (event.type === "session.input_audio.muted") {
|
|
muted = true;
|
|
mute.textContent = "Mikrofon aktivieren";
|
|
} else if (event.type === "session.input_audio.unmuted") {
|
|
muted = false;
|
|
mute.textContent = "Stummschalten";
|
|
} else if (event.type === "session.closed") {
|
|
finishAndSave("confirmed");
|
|
} else if (event.type === "error") {
|
|
setStatus(event.error?.message ?? "Live-Session meldete einen Fehler.");
|
|
}
|
|
}
|
|
|
|
start.addEventListener("click", async () => {
|
|
start.disabled = true;
|
|
transcript = [];
|
|
conversationId = `${new Date().toISOString().slice(0, 10)}-${crypto.randomUUID()}`;
|
|
terminalState = undefined;
|
|
saved = false;
|
|
captions.replaceChildren();
|
|
download.hidden = true;
|
|
retry.hidden = true;
|
|
setStatus("Verbinde …");
|
|
try {
|
|
const config = await fetch("/api/config").then((response) => response.json());
|
|
sessionMaxSeconds = Number.isSafeInteger(config.sessionMaxSeconds) ? config.sessionMaxSeconds : 600;
|
|
peer = new RTCPeerConnection();
|
|
peer.addEventListener("track", (event) => {
|
|
const audio = new Audio();
|
|
audio.autoplay = true;
|
|
audio.srcObject = new MediaStream([event.track]);
|
|
audio.play().catch(() => setStatus("Wiedergabe wurde blockiert. Bitte Audio im Browser erlauben."));
|
|
});
|
|
microphone = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
|
|
microphone.getAudioTracks().forEach((track) => peer.addTrack(track, microphone));
|
|
events = peer.createDataChannel("oai-events");
|
|
events.addEventListener("message", ({ data }) => {
|
|
try { handleEvent(JSON.parse(data)); } catch { setStatus("Ungültiges Session-Ereignis empfangen."); }
|
|
});
|
|
events.addEventListener("close", () => {
|
|
if (!terminalState) finishAndSave("unconfirmed");
|
|
});
|
|
const offer = await peer.createOffer();
|
|
await peer.setLocalDescription(offer);
|
|
await waitForIce(peer);
|
|
const sdp = peer.localDescription?.sdp;
|
|
if (!sdp) throw new Error("Lokales SDP-Angebot fehlt.");
|
|
const response = await fetch("/api/session", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sdp }),
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error ?? "Session konnte nicht gestartet werden.");
|
|
await peer.setRemoteDescription({ type: "answer", sdp: result.transport.sdp });
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : String(error));
|
|
terminalState = "aborted";
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
mute.addEventListener("click", () => {
|
|
if (ready) send({ type: muted ? "session.input_audio.unmute" : "session.input_audio.mute", event_id: eventId("mute") });
|
|
});
|
|
end.addEventListener("click", finishConversation);
|
|
retry.addEventListener("click", () => void saveTranscript());
|