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>
284 lines
12 KiB
JavaScript
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 };
|