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/server.mjs
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

284 lines
12 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { mkdir, open, readFile, rename, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const publicDir = join(here, "public");
const MAX_SDP_BYTES = 64 * 1024;
const MAX_TRANSCRIPT_CHARS = 48 * 1024;
const conversationIdPattern = /^\d{4}-\d{2}-\d{2}-[0-9a-f-]{36}$/;
class ClientError extends Error {}
class ConflictError extends Error {}
function json(response, status, value) {
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
response.end(JSON.stringify(value));
}
function text(response, status, value, headers = {}) {
response.writeHead(status, {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
...headers,
});
response.end(value);
}
async function readJson(request, maxBytes) {
const length = Number.parseInt(request.headers["content-length"] ?? "0", 10);
if (!Number.isFinite(length) || length < 1 || length > maxBytes) {
throw new ClientError("Ungültige oder zu große Anfrage.");
}
let received = 0;
const chunks = [];
for await (const chunk of request) {
received += chunk.length;
if (received > maxBytes) throw new ClientError("Anfrage ist zu groß.");
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new ClientError("Anfrage ist kein gültiges JSON.");
}
}
function expectedOrigin(request, origin) {
return request.headers.origin === origin;
}
function apiKeyFromCredential(credentialsDirectory, credentialName) {
if (!credentialsDirectory) return "";
return readFile(join(credentialsDirectory, credentialName), "utf8")
.then((value) => {
const line = value.split(/\r?\n/).find((item) => item.startsWith("OPENAI_API_KEY="));
return line ? line.slice("OPENAI_API_KEY=".length).trim() : value.trim();
})
.catch(() => "");
}
function transcriptEntries(transcript) {
if (!Array.isArray(transcript) || transcript.length === 0) {
throw new ClientError("Für die Speicherung fehlt ein Transkript.");
}
let total = 0;
return transcript.map((entry) => {
if (!entry || !["Nutzer", "Assistent"].includes(entry.speaker) || typeof entry.text !== "string" || entry.text.trim().length === 0) {
throw new ClientError("Ungültiges Transkript.");
}
total += entry.text.length;
if (entry.text.length > 4000 || total > MAX_TRANSCRIPT_CHARS) {
throw new ClientError("Das Transkript ist zu groß.");
}
return { speaker: entry.speaker, text: entry.text };
});
}
function safeConversationId(value) {
return typeof value === "string" && conversationIdPattern.test(value) ? value : null;
}
function transcriptMarkdown(id, entries, finalization) {
if (!["confirmed", "unconfirmed"].includes(finalization)) {
throw new ClientError("Ungültiger Abschlussstatus.");
}
const state = finalization === "confirmed" ? "bestätigt" : "unbestätigt";
const content = entries.map(({ speaker, text: value }) => `### ${speaker}\n\n${value}`).join("\n\n");
return `# Gesprächstranskript\n\n- Gespräch-ID: ${id}\n- Session-Abschluss: ${state}\n\n## Transkript\n\n${content}\n`;
}
async function writeTranscript(transcriptsDirectory, id, markdown) {
const root = resolve(transcriptsDirectory);
const directory = resolve(root, id);
if (!directory.startsWith(`${root}/`)) throw new Error("Ungültiger Speicherpfad.");
await mkdir(root, { recursive: true, mode: 0o700 });
const target = join(directory, "transcript.md");
try {
await mkdir(directory, { mode: 0o700 });
} catch (error) {
if (error?.code !== "EEXIST") throw error;
let stored;
try {
stored = await readFile(target, "utf8");
} catch {
throw new ConflictError("Gesprächs-ID ist bereits belegt.");
}
if (stored !== markdown) throw new ConflictError("Gesprächs-ID enthält bereits ein anderes Transkript.");
return { id, target, created: false };
}
const temporary = join(directory, `.transcript-${randomUUID()}.tmp`);
const handle = await open(temporary, "wx", 0o600);
try {
await handle.writeFile(markdown, "utf8");
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, target);
return { id, target, created: true };
}
function sessionConfiguration(config, offerSdp) {
return {
session: {
model: config.liveModel,
store: false,
instructions: [
"Du bist eine ruhige deutschsprachige Gesprächspartnerin für Planungs-Gespräche.",
"Hilf, Ziele, Scope, Anforderungen, ausdrücklich bestätigte Entscheidungen und offene Fragen zu klären.",
"Erfinde keine Entscheidungen und führe niemals einen Intent oder eine andere Aktion aus.",
"Weise am Ende darauf hin, dass das Gespräch als lokales Transkript gespeichert wird. Es wird kein Intent erzeugt.",
"Interruption policy: Stop speaking when the user interrupts. Listen to what they say.",
"Delegation policy:\nBackend tools:\n- Planung: unterstützt sorgfältiges Einordnen des laufenden Gesprächs.\nDelegate to the backend when:\n- eine Zusammenfassung oder sorgfältige Strukturierung des Gesprächs nötig ist.\nDo not delegate to the backend when:\n- eine kurze Klärungsfrage reicht.\nDo not guess a result while waiting.",
].join("\n"),
delegation: {
type: "responses",
responses: {
model: config.backendModel,
instructions: "Unterstütze ausschließlich das laufende Planungs-Gespräch. Formuliere knapp, markiere Ungeklärtes als offene Frage und erfinde keine Fakten oder Entscheidungen. Führe keine Aktionen aus.",
tool_choice: "none",
max_output_tokens: 500,
reasoning: { effort: "low" },
},
},
},
transport: { type: "webrtc", sdp: offerSdp },
};
}
async function openaiJson(fetchImpl, apiKey, path, body) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
const response = await fetchImpl(`https://api.openai.com${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(`OpenAI-Anfrage fehlgeschlagen (${response.status}).`);
return payload;
} finally {
clearTimeout(timeout);
}
}
async function serveStatic(response, pathname) {
const name = pathname === "/" ? "index.html" : pathname.slice(1);
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return false;
const file = resolve(publicDir, name);
if (!file.startsWith(`${resolve(publicDir)}/`)) return false;
try {
const content = await readFile(file);
const type = name.endsWith(".js") ? "text/javascript; charset=utf-8" : name.endsWith(".css") ? "text/css; charset=utf-8" : "text/html; charset=utf-8";
response.writeHead(200, {
"Content-Type": type,
"Content-Security-Policy": "default-src 'self'; connect-src 'self'; media-src 'self' blob:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
});
response.end(content);
return true;
} catch {
return false;
}
}
export function createApp({
apiKey = "",
backendModel = process.env.LIVE_INTENT_BACKEND_MODEL ?? "gpt-5.6-luna",
fetchImpl = globalThis.fetch,
liveModel = process.env.LIVE_INTENT_LIVE_MODEL ?? "gpt-live-1",
origin = process.env.LIVE_INTENT_ORIGIN,
sessionMaxSeconds = Number(process.env.LIVE_INTENT_SESSION_MAX_SECONDS ?? 600),
transcriptsDirectory = process.env.LIVE_INTENT_TRANSCRIPTS_DIR ?? "/var/lib/live-intent/transcripts",
} = {}) {
if (!origin) throw new Error("LIVE_INTENT_ORIGIN muss gesetzt sein.");
const config = { backendModel, liveModel, sessionMaxSeconds };
return async function app(request, response) {
const url = new URL(request.url, origin);
if (request.method === "GET" && url.pathname === "/api/config") {
json(response, 200, { sessionMaxSeconds });
return;
}
if (request.method === "POST" && url.pathname.startsWith("/api/") && !expectedOrigin(request, origin)) {
json(response, 403, { error: "Unerwartete Herkunft." });
return;
}
try {
if (request.method === "POST" && url.pathname === "/api/session") {
if (!apiKey) {
json(response, 503, { error: "OpenAI-Credential ist auf dem Server nicht verfügbar." });
return;
}
const body = await readJson(request, MAX_SDP_BYTES);
if (typeof body.sdp !== "string" || body.sdp.trim().length === 0 || body.sdp.length > MAX_SDP_BYTES) {
json(response, 400, { error: "Ein SDP-Angebot ist erforderlich." });
return;
}
const result = await openaiJson(fetchImpl, apiKey, "/v1/live/sessions", sessionConfiguration(config, body.sdp));
if (typeof result?.session?.id !== "string" || typeof result?.transport?.sdp !== "string") throw new Error("Ungültige Live-Session-Antwort.");
json(response, 201, result);
return;
}
if (request.method === "POST" && url.pathname === "/api/transcripts") {
const body = await readJson(request, MAX_TRANSCRIPT_CHARS + 4096);
const id = safeConversationId(body.conversationId);
if (!id) {
json(response, 400, { error: "Ungültige Gesprächs-ID." });
return;
}
const markdown = transcriptMarkdown(id, transcriptEntries(body.transcript), body.finalization);
const saved = await writeTranscript(transcriptsDirectory, id, markdown);
json(response, saved.created ? 201 : 200, { id: saved.id, created: saved.created, url: `/api/transcripts/${saved.id}` });
return;
}
if (request.method === "GET" && url.pathname.startsWith("/api/transcripts/")) {
const id = safeConversationId(decodeURIComponent(url.pathname.slice("/api/transcripts/".length)));
if (!id) {
json(response, 404, { error: "Transkript nicht gefunden." });
return;
}
const file = join(transcriptsDirectory, id, "transcript.md");
try {
const details = await stat(file);
if (!details.isFile() || details.size > MAX_TRANSCRIPT_CHARS + 8192) throw new Error("invalid");
text(response, 200, await readFile(file, "utf8"), { "Content-Disposition": `attachment; filename="${id}-transcript.md"` });
} catch {
json(response, 404, { error: "Transkript nicht gefunden." });
}
return;
}
} catch (error) {
const status = error instanceof ClientError ? 400 : error instanceof ConflictError ? 409 : 502;
json(response, status, { error: error instanceof Error ? error.message : "Unerwarteter Fehler." });
return;
}
if (request.method === "GET" && (await serveStatic(response, url.pathname))) return;
json(response, 404, { error: "Nicht gefunden." });
};
}
async function main() {
const apiKey = await apiKeyFromCredential(process.env.CREDENTIALS_DIRECTORY, process.env.LIVE_INTENT_KEY_CREDENTIAL ?? "openai-api-key");
const server = createServer(createApp({ apiKey }));
const host = process.env.LIVE_INTENT_HOST ?? "127.0.0.1";
const port = Number(process.env.LIVE_INTENT_PORT ?? 8787);
server.listen(port, host, () => console.log(`live-intent listens on ${host}:${port}`));
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.close(() => process.exit(0)));
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
export { apiKeyFromCredential, sessionConfiguration, transcriptEntries, transcriptMarkdown };