This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/services/live-intent/public/app.js
pijukka c8cfdcd88e host(pinix): add live intent and ereader viewer services
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>
2026-09-17 12:18:55 +02:00

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