"use strict"; /** * ARIA Diagnostic Server * * Leichtgewichtiges Diagnose-Tool: * - Verbindet sich direkt mit OpenClaw Gateway (ws://127.0.0.1:18789) * - Fuehrt den vollstaendigen Handshake durch * - Sendet Testnachrichten und zeigt Antworten * - Zeigt Verbindungsstatus aller Komponenten * * Laeuft im selben Netzwerk wie aria-core (network_mode: service:aria) */ const http = require("http"); const { WebSocket, WebSocketServer } = require("ws"); const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); // ── Konfiguration ─────────────────────────────────────── const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10); const GATEWAY_URL = process.env.ARIA_CORE_WS || "ws://127.0.0.1:18789"; const GATEWAY_TOKEN = process.env.ARIA_AUTH_TOKEN || ""; const RVS_HOST = process.env.RVS_HOST || ""; const RVS_PORT = process.env.RVS_PORT || "443"; const RVS_TLS = process.env.RVS_TLS || "true"; const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true"; const RVS_TOKEN = process.env.RVS_TOKEN || ""; const PROXY_URL = process.env.PROXY_URL || "http://proxy:3456"; // ── State ─────────────────────────────────────────────── const state = { gateway: { status: "disconnected", lastError: null, handshakeOk: false }, rvs: { status: "disconnected", lastError: null }, proxy: { status: "unknown", lastError: null }, }; const SESSION_KEY_FILE = "/data/active-session"; // /data Verzeichnis sicherstellen (Volume Mount) try { fs.mkdirSync("/data", { recursive: true }); } catch (e) { console.error(`[startup] /data mkdir fehlgeschlagen: ${e.message}`); } // sessionFromFile zeigt an, ob der aktive Key aus der Datei kam. // Wenn true, darf resolveActiveSession NICHT mehr auto-picken (Wahl respektieren). let sessionFromFile = false; let activeSessionKey = (() => { try { const saved = fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim(); if (saved) { console.log(`[startup] Gespeicherte Session geladen: '${saved}'`); sessionFromFile = true; return saved; } } catch (e) { console.error(`[startup] SESSION_KEY_FILE read: ${e.code || e.message}`); } console.log("[startup] Keine gespeicherte Session — Fallback 'main'"); return "main"; })(); // ── Runtime-Config: /shared/config/runtime.json ───────────── // ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang. // Bridge und ggf. andere Komponenten lesen dieselbe Datei. const RUNTIME_CONFIG_FILE = "/shared/config/runtime.json"; const RUNTIME_CONFIG_FIELDS = [ "RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN", "ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE", "brainModel", ]; function readRuntimeConfig() { const envDefaults = { RVS_HOST, RVS_PORT, RVS_TLS, RVS_TOKEN, ARIA_AUTH_TOKEN: process.env.ARIA_AUTH_TOKEN || "", WHISPER_MODEL: process.env.WHISPER_MODEL || "medium", WHISPER_LANGUAGE: process.env.WHISPER_LANGUAGE || "de", }; try { const raw = fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8"); const parsed = JSON.parse(raw); return { ...envDefaults, ...parsed }; } catch { return envDefaults; } } function writeRuntimeConfig(patch) { let current = {}; try { current = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8")); } catch {} for (const key of Object.keys(patch)) { if (RUNTIME_CONFIG_FIELDS.includes(key)) current[key] = patch[key]; } fs.mkdirSync("/shared/config", { recursive: true }); const tmp = RUNTIME_CONFIG_FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(current, null, 2)); fs.renameSync(tmp, RUNTIME_CONFIG_FILE); } // Atomic write: temp-file + rename, laute Logs bei Fehler. function persistActiveSession(key) { try { const tmp = SESSION_KEY_FILE + ".tmp"; fs.writeFileSync(tmp, key); fs.renameSync(tmp, SESSION_KEY_FILE); sessionFromFile = true; console.log(`[session] Aktive Session persistiert: '${key}'`); return true; } catch (e) { console.error(`[session] FEHLER beim Persistieren von '${key}': ${e.message}`); return false; } } const logs = []; let gatewayWs = null; let rvsWs = null; let reqIdCounter = 0; const browserClients = new Set(); // ── Trace-Tracking (End-to-End-Mitschnitt einer Anfrage) ────────────────────────────────── let traceActive = false; let traceStartTime = 0; // Nach chat:final kommen oft noch Trailing Agent-Events. Waehrend dieses // Fensters unterdruecken wir agent_activity-Broadcasts, damit der // Thinking-Indicator nicht wieder anspringt. let lastChatFinalAt = 0; const SETTLED_WINDOW_MS = 3000; function plog(message, level) { const elapsed = traceActive ? `+${Date.now() - traceStartTime}ms` : ""; const entry = { ts: new Date().toISOString(), level: level || "info", source: "trace", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` }; logs.push(entry); if (logs.length > 500) logs.shift(); console.log(`[TRACE] ${entry.message}`); broadcast({ type: "log", entry }); } let traceTimeout = null; function traceStart(method, text) { // Falls noch ein Trace laeuft, beenden if (traceActive) traceEnd(false, "Abgebrochen (neue Nachricht)"); traceActive = true; traceStartTime = Date.now(); if (traceTimeout) clearTimeout(traceTimeout); traceTimeout = setTimeout(() => { if (traceActive) traceEnd(false, "Timeout — keine Antwort nach 10min"); }, 600000); plog(`━━━ Trace Start: ${method} ━━━`); plog(`Nachricht: "${text}"`); } function traceEnd(ok, detail) { if (!traceActive) return; if (traceTimeout) { clearTimeout(traceTimeout); traceTimeout = null; } const elapsed = Date.now() - traceStartTime; if (ok) { plog(`>>> Fertig (${elapsed}ms): ${detail}`); } else { plog(`>>> FEHLER (${elapsed}ms): ${detail}`, "error"); } plog(`━━━ Trace Ende ━━━`); traceActive = false; // Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch broadcast({ type: "agent_activity", activity: "idle" }); pendingMessageTime = 0; } // Auto-Restart-Heuristik entfernt (war an aria-core-Network gebunden). // connectGateway ist ein No-Op solange der Brain-Loop noch nicht steht. let gatewayFailCount = 0; function checkGatewayHealth() { /* No-Op */ } function nextReqId() { return `diag-${++reqIdCounter}`; } function log(level, source, message) { const entry = { ts: new Date().toISOString(), level, source, message }; logs.push(entry); if (logs.length > 500) logs.shift(); console.log(`[${entry.ts}] [${level}] [${source}] ${message}`); // An alle Browser-Clients senden broadcast({ type: "log", entry }); } function broadcast(msg) { const data = JSON.stringify(msg); for (const client of browserClients) { if (client.readyState === WebSocket.OPEN) { client.send(data); } } } function broadcastState() { broadcast({ type: "state", state }); } // ── OpenClaw Gateway Verbindung ───────────────────────── async function connectGateway() { // aria-core/OpenClaw-Gateway abgerissen — diese Funktion ist ein No-Op, // bis der neue Brain-Loop angedockt ist. Wir setzen den Status nur einmal. state.gateway.status = "disabled"; state.gateway.lastError = "aria-core entfernt — Brain-Loop in Arbeit"; broadcastState(); return; // Originaler Connect-Code unten ist toter Code, bleibt zur Referenz. // eslint-disable-next-line no-unreachable log("info", "gateway", `Verbinde: ${GATEWAY_URL}`); try { const ws = new WebSocket(GATEWAY_URL); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { ws.close(); reject(new Error("Verbindungs-Timeout (10s)")); }, 10000); ws.on("open", () => { clearTimeout(timeout); resolve(); }); ws.on("error", (err) => { clearTimeout(timeout); reject(err); }); }); log("info", "gateway", "TCP-Verbindung hergestellt — warte auf Challenge"); // Schritt 1: Auf Challenge warten const challengeRaw = await waitForMessage(ws, 10000); const challenge = JSON.parse(challengeRaw); if (challenge.type !== "event" || challenge.event !== "connect.challenge") { throw new Error(`Unerwartete erste Nachricht: ${challengeRaw.slice(0, 200)}`); } const nonce = challenge.payload?.nonce || ""; log("info", "gateway", `Challenge empfangen (nonce: ${nonce.slice(0, 8)}...)`); // Schritt 2: Connect Request senden const connectReq = { type: "req", id: nextReqId(), method: "connect", params: { minProtocol: 3, maxProtocol: 3, client: { id: "cli", version: "0.0.1", platform: "linux", mode: "cli", }, role: "operator", scopes: ["operator.read", "operator.write"], caps: [], commands: [], permissions: {}, auth: GATEWAY_TOKEN ? { token: GATEWAY_TOKEN } : {}, locale: "de-DE", userAgent: "aria-diagnostic/0.0.1", }, }; ws.send(JSON.stringify(connectReq)); log("info", "gateway", "Connect-Request gesendet"); // Schritt 3: hello-ok warten const responseRaw = await waitForMessage(ws, 10000); const response = JSON.parse(responseRaw); if (response.type === "res" && response.ok) { log("info", "gateway", "Handshake erfolgreich — hello-ok!"); state.gateway.status = "connected"; state.gateway.handshakeOk = true; state.gateway.lastError = null; gatewayFailCount = 0; } else { const error = typeof response.error === "string" ? response.error : JSON.stringify(response.error || response).slice(0, 300); throw new Error(`Handshake fehlgeschlagen: ${error}`); } gatewayWs = ws; broadcastState(); // Nach (Re-)Connect: letzte aktive Session aus OpenClaw wiederherstellen resolveActiveSession().catch(err => { log("warn", "server", `Session-Aufloesung fehlgeschlagen: ${err.message}`); }); // Nachrichten-Loop — RAW logging fuer Debugging ws.on("message", (raw) => { try { const rawStr = raw.toString(); log("debug", "gateway", `RAW <<< ${rawStr.slice(0, 300)}`); const msg = JSON.parse(rawStr); handleGatewayMessage(msg); } catch (err) { log("error", "gateway", `Parse-Fehler: ${err.message}`); } }); ws.on("close", (code, reason) => { log("warn", "gateway", `Verbindung geschlossen (Code: ${code}, Reason: ${reason || "-"})`); state.gateway.status = "disconnected"; state.gateway.handshakeOk = false; gatewayWs = null; broadcastState(); // Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Trace abkackt if (traceActive) traceEnd(false, `Gateway-Verbindung verloren (${code})`); else broadcast({ type: "agent_activity", activity: "idle" }); checkGatewayHealth(); setTimeout(connectGateway, 5000); }); ws.on("error", (err) => { log("error", "gateway", `WebSocket-Fehler: ${err.message}`); state.gateway.lastError = err.message; broadcastState(); }); } catch (err) { log("error", "gateway", `Fehler: ${err.message}`); state.gateway.status = "error"; state.gateway.lastError = err.message; state.gateway.handshakeOk = false; gatewayWs = null; broadcastState(); checkGatewayHealth(); // Retry nach 5s setTimeout(connectGateway, 5000); } } // Extrahiert Text aus OpenClaw chat-Event message.content Array function extractChatText(payload) { try { const content = payload.message?.content; if (Array.isArray(content)) { return content .filter(c => c.type === "text") .map(c => c.text || "") .join(""); } if (typeof payload.message === "string") return payload.message; return payload.text || ""; } catch { return ""; } } // Deduplizierung: nur ein chat_final pro runId broadcasten const seenFinalRuns = new Set(); function handleGatewayMessage(msg) { if (msg.type === "res") { const status = msg.ok ? "OK" : `FEHLER: ${JSON.stringify(msg.error).slice(0, 100)}`; log("info", "gateway", `Response [${msg.id}]: ${status}`); if (traceActive) { if (msg.ok) plog(`Gateway ACK [${msg.id}] — Nachricht angenommen`); else plog(`Gateway NACK [${msg.id}] — ${JSON.stringify(msg.error).slice(0, 100)}`, "error"); } broadcast({ type: "response", msg }); return; } if (msg.type === "event") { const event = msg.event || "?"; const payload = msg.payload || {}; // ── agent Events: Streaming-Deltas vom LLM ── if (event === "agent") { const data = payload.data || {}; const delta = data.delta || ""; const stream = payload.stream || ""; if (delta && stream === "assistant") { broadcast({ type: "chat_delta", delta, payload }); } // Nach chat:final trickeln noch Aufraeum-Events rein — unterdruecken, // damit der Thinking-Indicator nicht wieder anspringt. const settled = lastChatFinalAt && (Date.now() - lastChatFinalAt) < SETTLED_WINDOW_MS; // Tool-Nutzung erkennen und broadcasten if (stream === "tool_use" || data.type === "tool_use") { const toolName = data.name || data.tool || payload.tool || ""; if (toolName && !settled) { broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data }); log("info", "gateway", `Tool: ${toolName}`); } } if (!settled) { broadcast({ type: "agent_activity", activity: stream || "thinking" }); } updateAgentActivity(); return; } // ── chat Events: Snapshots mit state=delta|final ── if (event === "chat") { const state = payload.state || ""; const text = extractChatText(payload); if (state === "final") { const runId = payload.runId || ""; if (runId && seenFinalRuns.has(runId)) return; // Duplikat if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); } // NO_REPLY → ARIA signalisiert "nicht antworten", Trace beenden aber nichts zeigen const trimmed = (text || "").trim().replace(/^["'`*.\s]+|["'`*.\s]+$/g, "").toUpperCase(); if (trimmed === "NO_REPLY" || trimmed.startsWith("NO_REPLY")) { log("info", "gateway", "NO_REPLY empfangen — still verworfen"); lastChatFinalAt = Date.now(); if (traceActive) traceEnd(true, "NO_REPLY (stumm)"); broadcast({ type: "agent_activity", activity: "idle" }); pendingMessageTime = 0; updateAgentActivity(); return; } log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`); lastChatFinalAt = Date.now(); if (traceActive) traceEnd(true, `"${text.slice(0, 120)}"`); broadcast({ type: "chat_final", text, payload }); broadcast({ type: "agent_activity", activity: "idle" }); pendingMessageTime = 0; // Watchdog: Antwort erhalten updateAgentActivity(); // Antwort in Backup-Log schreiben try { const entry = JSON.stringify({ ts: Date.now(), role: "assistant", text: text.slice(0, 2000), session: activeSessionKey }) + "\n"; fs.appendFileSync("/shared/config/chat_backup.jsonl", entry); } catch {} return; } if (state === "delta") { // Periodischer Snapshot — nicht einzeln loggen return; } if (state === "error") { const error = payload.error || text || "Unbekannt"; log("error", "gateway", `Chat-Fehler: ${error}`); if (traceActive) traceEnd(false, error); else broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "chat_error", error, payload }); return; } log("debug", "gateway", `chat state=${state}`); return; } // ── Legacy event names (chat:delta, chat:final, chat:error) ── if (event === "chat:delta") { const delta = payload.delta || payload.text || ""; if (delta) broadcast({ type: "chat_delta", delta, payload }); return; } if (event === "chat:final") { const runId = payload.runId || ""; if (runId && seenFinalRuns.has(runId)) return; // Duplikat if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); } const text = extractChatText(payload) || payload.text || ""; log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`); lastChatFinalAt = Date.now(); if (traceActive) traceEnd(true, `"${text.slice(0, 120)}"`); else broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "chat_final", text, payload }); return; } if (event === "chat:error") { const error = payload.error || payload.message || "Unbekannt"; log("error", "gateway", `Chat-Fehler: ${error}`); if (traceActive) traceEnd(false, error); else broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "chat_error", error, payload }); return; } // ── Andere Events (tick, health, presence) ── if (event === "tick" || event === "health") return; // Noise log("debug", "gateway", `Event: ${event}`); } } function sendToGateway(text, isTrace) { if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) { log("error", "gateway", "Nicht verbunden — kann nicht senden"); if (isTrace) traceEnd(false, "Gateway nicht verbunden"); return false; } const reqId = nextReqId(); const msg = { type: "req", id: reqId, method: "chat.send", params: { sessionKey: activeSessionKey, message: text, idempotencyKey: crypto.randomUUID(), }, }; const payload = JSON.stringify(msg); log("debug", "gateway", `RAW >>> ${payload}`); gatewayWs.send(payload); pendingMessageTime = Date.now(); // Watchdog: Nachricht gesendet // Nachricht sofort in Backup-Log schreiben (OpenClaw speichert erst nach Run-Ende) try { fs.mkdirSync("/shared/config", { recursive: true }); const entry = JSON.stringify({ ts: Date.now(), role: "user", text, session: activeSessionKey }) + "\n"; fs.appendFileSync("/shared/config/chat_backup.jsonl", entry); } catch {} log("info", "gateway", `chat.send [${reqId}]: "${text}"`); if (isTrace) plog(`chat.send [${reqId}] an Gateway gesendet — warte auf ACK...`); // Gateway-Nachrichten NICHT an RVS senden (sonst doppelter ARIA-Request via Bridge) return true; } // ── RVS Verbindung (optional) ─────────────────────────── function connectRVS(forcePlain) { if (!RVS_HOST || !RVS_TOKEN) { log("info", "rvs", "Nicht konfiguriert — ueberspringe"); state.rvs.status = "not_configured"; broadcastState(); return; } // Alte Verbindung sauber schliessen if (rvsWs) { try { rvsWs.removeAllListeners(); rvsWs.close(); } catch (_) {} rvsWs = null; } // TLS-Logik: wss zuerst, bei Fehler Fallback auf ws const useTls = RVS_TLS === "true" && !forcePlain; const proto = useTls ? "wss" : "ws"; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; state.rvs.status = "connecting"; broadcastState(); log("info", "rvs", `Verbinde: ${proto}://${RVS_HOST}:${RVS_PORT}`); let ws; try { ws = new WebSocket(url); } catch (err) { log("error", "rvs", `WebSocket erstellen fehlgeschlagen: ${err.message}`); if (useTls && RVS_TLS_FALLBACK === "true") { connectRVS(true); } return; } let fallbackTriggered = false; ws.on("open", () => { log("info", "rvs", `Verbunden (${proto})`); state.rvs.status = "connected"; state.rvs.lastError = null; rvsWs = ws; broadcastState(); // Keepalive: alle 25s ein Ping senden damit die Verbindung nicht stirbt const keepalive = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { try { ws.ping(); } catch (_) {} } else { clearInterval(keepalive); } }, 25000); ws._keepalive = keepalive; }); ws.on("message", (raw) => { try { const msg = JSON.parse(raw.toString()); if (msg.type === "chat" && msg.payload) { const sender = msg.payload.sender || "?"; // Eigene Nachrichten ignorieren (Echo) if (sender === "diagnostic") return; log("info", "rvs", `Chat von ${sender}: "${(msg.payload.text || "").slice(0, 100)}"`); if (traceActive) { traceEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`); } broadcast({ type: "rvs_chat", msg }); } else if (msg.type === "file_saved" && msg.payload) { // Bild/Datei-Upload von der App — im Chat anzeigen const name = msg.payload.name || "?"; const serverPath = msg.payload.serverPath || ""; const mimeType = msg.payload.mimeType || ""; log("info", "rvs", `Datei empfangen: ${name} (${serverPath})`); // Als User-Nachricht mit Pfad broadcasten (Diagnostic zeigt Bilder inline) broadcast({ type: "rvs_chat", msg: { type: "chat", payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" } }}); } else if (msg.type === "file_from_aria" && msg.payload) { // ARIA hat eine Datei fuer den User erstellt — im Chat als Anhang anzeigen const p = msg.payload; log("info", "rvs", `ARIA-Datei: ${p.name} (${p.mimeType}, ${(p.size||0)/1024|0}KB)`); broadcast({ type: "file_from_aria", payload: p }); } else if (msg.type === "heartbeat") { // ignorieren } else if (msg.type === "mode") { // Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`); broadcast({ type: "mode", payload: msg.payload }); } else if (msg.type === "voice_ready") { // XTTS-Bridge meldet Stimme fertig geladen → an Browser durchreichen const v = msg.payload?.voice || ""; const err = msg.payload?.error; const ms = msg.payload?.loadMs; if (err) { log("warn", "rvs", `Voice-Ready Fehler fuer "${v}": ${err}`); } else { log("info", "rvs", `Voice "${v || "default"}" geladen${ms ? ` in ${(ms/1000).toFixed(1)}s` : ""}`); } broadcast({ type: "voice_ready", payload: msg.payload }); } else if (msg.type === "service_status") { // Gamebox-Bridges (f5tts/whisper) melden ihren Lade-Status — // an Browser durchreichen fuer das Banner unten rechts const svc = msg.payload?.service || "?"; const state = msg.payload?.state || "?"; const model = msg.payload?.model || ""; const sec = msg.payload?.loadSeconds; const err = msg.payload?.error; if (err) { log("warn", "rvs", `service_status ${svc}: ${err}`); } else if (state === "ready" && sec) { log("info", "rvs", `service_status ${svc} ready (${model}, ${sec.toFixed(1)}s)`); } else { log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`); } broadcast({ type: "service_status", payload: msg.payload }); } else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) { // PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen _handlePreviewChunk(msg.payload); } else { log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`); } } catch {} }); ws.on("close", () => { log("warn", "rvs", "Verbindung geschlossen"); if (ws._keepalive) clearInterval(ws._keepalive); state.rvs.status = "disconnected"; if (rvsWs === ws) rvsWs = null; broadcastState(); if (!fallbackTriggered) { setTimeout(() => connectRVS(), 5000); } }); ws.on("error", (err) => { log("error", "rvs", `Fehler (${proto}): ${err.message}`); state.rvs.lastError = err.message; broadcastState(); // TLS Fallback if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) { fallbackTriggered = true; log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://"); try { ws.removeAllListeners(); ws.close(); } catch (_) {} if (rvsWs === ws) rvsWs = null; connectRVS(true); } }); } function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) { if (!RVS_HOST || !RVS_TOKEN) return; const proto = RVS_TLS === "true" ? "wss" : "ws"; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; const freshWs = new WebSocket(url); const timeout = setTimeout(() => { try { freshWs.close(); } catch (_) {} clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() })); }, 15000); freshWs.on("open", () => { freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })); }); freshWs.on("message", (raw) => { try { const resp = JSON.parse(raw.toString()); if (resp.type === expectType) { clearTimeout(timeout); clientWs.send(JSON.stringify(resp)); setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 1000); } } catch {} }); freshWs.on("error", () => {}); } function sendToRVS_raw(msgObj) { if (!RVS_HOST || !RVS_TOKEN) return; const payload = JSON.stringify(msgObj); // Persistente Connection bevorzugen — die ist garantiert connected // und wird vom RVS direkt an alle anderen Clients (App, Bridge) broadcastet. // Frische Connections hatten Race-Probleme: die WS war nach dem send manchmal // schon zu, bevor RVS broadcasten konnte → App-Nachrichten verloren. if (rvsWs && rvsWs.readyState === WebSocket.OPEN) { try { rvsWs.send(payload); return; } catch (err) { log("warn", "rvs", `persistente Verbindung send failed (${err.message}) — Fallback frische WS`); } } const proto = RVS_TLS === "true" ? "wss" : "ws"; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; const freshWs = new WebSocket(url); freshWs.on("open", () => { freshWs.send(payload); setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000); }); freshWs.on("error", () => {}); } function sendToRVS(text, isTrace) { // Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit // Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig, // aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme. // Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige. // 1. An Gateway senden (damit ARIA antwortet) const gatewayOk = sendToGateway(text, isTrace); // 2. An RVS senden (damit die App die Nachricht sieht) sendToRVS_raw({ type: "chat", payload: { text, sender: "diagnostic" }, timestamp: Date.now(), }); return gatewayOk; } // ── Claude Proxy Test ──────────────────────────────────── async function testProxy(prompt) { state.proxy.status = "testing"; state.proxy.lastError = null; broadcastState(); log("info", "proxy", `Teste Proxy: ${PROXY_URL}`); try { // Schritt 1: Erreichbarkeit pruefen const healthUrl = `${PROXY_URL}/v1/models`; log("info", "proxy", `Rufe ab: ${healthUrl}`); const modelsRes = await fetch(healthUrl, { headers: { "Authorization": "Bearer not-needed" }, signal: AbortSignal.timeout(30000), }); if (!modelsRes.ok) { throw new Error(`Models-Endpoint: HTTP ${modelsRes.status} ${modelsRes.statusText}`); } const modelsData = await modelsRes.json(); const models = (modelsData.data || []).map(m => m.id).filter(Boolean); log("info", "proxy", `Proxy erreichbar — ${models.length} Model(s) verfuegbar`); // Modellnamen loggen + OpenClaw-Config Hinweis if (models.length > 0) { log("info", "proxy", `Modelle: ${models.join(", ")}`); log("info", "proxy", `Fuer docker-compose.yml (DEFAULT_MODEL): ${models.map(m => m.replace("openai/", "")).join(" | ")}`); } // Schritt 1b: Auth-Dateien im Proxy-Container pruefen try { const authInfo = await dockerExec("aria-proxy", "echo '--- /root/.config/claude/ ---' && ls -la /root/.config/claude/ 2>&1 && echo '--- /root/.claude/ ---' && ls -la /root/.claude/ 2>&1 && echo '--- Credential-Dateien ---' && find /root/.config/claude /root/.claude -name '*.json' -o -name '*credential*' -o -name '*auth*' -o -name '*token*' 2>/dev/null | head -20"); log("info", "proxy", `Auth-Dateien im Container:\n${authInfo}`); broadcast({ type: "proxy_auth", info: authInfo }); } catch (authErr) { log("warn", "proxy", `Auth-Check fehlgeschlagen: ${authErr.message}`); } // Schritt 2: Chat Completion testen (kurzer Prompt) const testPrompt = prompt || "Antworte in einem Satz: Wer bist du und funktionierst du?"; log("info", "proxy", `Sende Test-Prompt: "${testPrompt}"`); const chatRes = await fetch(`${PROXY_URL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer not-needed", }, body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [{ role: "user", content: testPrompt }], max_tokens: 200, }), signal: AbortSignal.timeout(120000), // 2min — Cold Start braucht Zeit }); if (!chatRes.ok) { const errBody = await chatRes.text().catch(() => ""); throw new Error(`Chat-Completion: HTTP ${chatRes.status} — ${errBody.slice(0, 300)}`); } const chatData = await chatRes.json(); const reply = chatData.choices?.[0]?.message?.content || "(leer)"; log("info", "proxy", `Antwort: "${reply.slice(0, 200)}"`); state.proxy.status = "connected"; state.proxy.lastError = null; state.proxy.models = models; broadcastState(); broadcast({ type: "proxy_result", ok: true, reply, models }); } catch (err) { log("error", "proxy", `Fehler: ${err.message}`); state.proxy.status = "error"; state.proxy.lastError = err.message; broadcastState(); broadcast({ type: "proxy_result", ok: false, error: err.message }); } } // ── Claude Login im Proxy-Container ───────────────────── // ── Interaktives Terminal (xterm.js ↔ Docker Exec) ────── const net = require("net"); function attachTerminal(clientWs, containerName, cmd) { const createBody = JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: Array.isArray(cmd) ? cmd : ["sh", "-c", cmd], }); const createReq = http.request({ socketPath: "/var/run/docker.sock", path: `/containers/${containerName}/exec`, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) }, }, (res) => { let data = ""; res.on("data", (c) => data += c); res.on("end", () => { if (res.statusCode !== 201) { clientWs.send(JSON.stringify({ type: "term_error", error: `Exec create failed: HTTP ${res.statusCode}` })); return; } const execId = JSON.parse(data).Id; // Exec starten — raw TCP socket fuer bidirektionalen Stream // Docker Exec mit Tty macht ein HTTP Upgrade auf raw stream const startBody = JSON.stringify({ Detach: false, Tty: true }); const sock = net.connect({ path: "/var/run/docker.sock" }, () => { // Raw HTTP Request senden (Upgrade-artig) const req = `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(startBody)}\r\n\r\n${startBody}`; sock.write(req); }); let headersParsed = false; let headerBuf = ""; sock.on("data", (chunk) => { if (!headersParsed) { // HTTP Response Header parsen headerBuf += chunk.toString("utf-8"); const headerEnd = headerBuf.indexOf("\r\n\r\n"); if (headerEnd === -1) return; // Noch nicht komplett const headers = headerBuf.slice(0, headerEnd); const remaining = chunk.slice(chunk.length - (headerBuf.length - headerEnd - 4)); headersParsed = true; if (!headers.includes("200") && !headers.includes("101")) { clientWs.send(JSON.stringify({ type: "term_error", error: `Exec start failed: ${headers.split("\r\n")[0]}` })); sock.end(); return; } log("info", "proxy", "Terminal-Session gestartet"); clientWs.send(JSON.stringify({ type: "term_ready" })); clientWs._termSock = sock; // Verbleibende Daten nach dem Header if (remaining.length > 0) { clientWs.send(JSON.stringify({ type: "term_data", data: remaining.toString("base64") })); } return; } // Tty=true: Rohdaten direkt durchreichen if (clientWs.readyState === WebSocket.OPEN) { clientWs.send(JSON.stringify({ type: "term_data", data: chunk.toString("base64") })); } }); sock.on("end", () => { log("info", "proxy", "Terminal-Session beendet"); if (clientWs.readyState === WebSocket.OPEN) { clientWs.send(JSON.stringify({ type: "term_exit" })); } clientWs._termSock = null; setTimeout(() => checkProxyAuth(), 1000); }); sock.on("error", (err) => { log("error", "proxy", `Terminal-Socket-Fehler: ${err.message}`); if (clientWs.readyState === WebSocket.OPEN) { clientWs.send(JSON.stringify({ type: "term_error", error: err.message })); } }); // Wenn Browser-Client disconnected, Socket schliessen clientWs.on("close", () => { if (sock && !sock.destroyed) sock.end(); }); }); }); createReq.on("error", (err) => { clientWs.send(JSON.stringify({ type: "term_error", error: err.message })); }); createReq.write(createBody); createReq.end(); } function handleTermInput(clientWs, data) { if (clientWs._termSock && !clientWs._termSock.destroyed) { clientWs._termSock.write(Buffer.from(data, "base64")); } } // Credentials manuell einfuegen (von einem Rechner wo Claude eingeloggt ist) async function writeProxyCredentials(credentialsJson) { try { // Validieren const parsed = JSON.parse(credentialsJson); if (!parsed.claudeAiOauth && !parsed.oauth) { throw new Error("Ungueltig: Kein OAuth-Objekt gefunden. Erwartet 'claudeAiOauth' oder 'oauth' Key."); } log("info", "proxy", "Schreibe Credentials in Proxy-Container..."); // Escaped fuer Shell — Einfache Anfuehrungszeichen im JSON escapen const escaped = credentialsJson.replace(/'/g, "'\\''"); // In beide moegliche Speicherorte schreiben await dockerExec("aria-proxy", `mkdir -p /root/.config/claude && echo '${escaped}' > /root/.config/claude/.credentials.json && mkdir -p /root/.claude && echo '${escaped}' > /root/.claude/credentials.json`); log("info", "proxy", "Credentials geschrieben!"); broadcast({ type: "login_status", status: "done" }); broadcast({ type: "login_output", text: "Credentials erfolgreich geschrieben! Proxy muss neu gestartet werden." }); // Auth pruefen setTimeout(() => checkProxyAuth(), 500); } catch (err) { log("error", "proxy", `Credentials schreiben fehlgeschlagen: ${err.message}`); broadcast({ type: "login_status", status: "error", error: err.message }); } } async function checkProxyAuth() { try { log("info", "proxy", "Pruefe Auth-Dateien im Proxy-Container..."); // Breit suchen: Claude Code speichert Credentials je nach Version an verschiedenen Orten const authInfo = await dockerExec("aria-proxy", ` echo '=== /root/.config/claude/ ===' && ls -la /root/.config/claude/ 2>&1 && echo '' && echo '=== /root/.claude/ ===' && ls -la /root/.claude/ 2>&1 && echo '' && echo '=== /root/.claude/auth/ ===' && ls -la /root/.claude/auth/ 2>&1 && echo '' && echo '=== Credentials-Dateien (rekursiv) ===' && find /root/.config/claude /root/.claude -name '*.json' -o -name '*credential*' -o -name '*auth*' -o -name '*token*' -o -name '*oauth*' -o -name '*session*' 2>/dev/null | head -20 && echo '' && echo '=== .credentials.json ===' && cat /root/.config/claude/.credentials.json 2>/dev/null || echo '(nicht in .config/claude/)' && echo '' && echo '=== /root/.claude/credentials.json ===' && cat /root/.claude/credentials.json 2>/dev/null || echo '(nicht in .claude/)' && echo '' && echo '=== /root/.claude/auth/*.json ===' && cat /root/.claude/auth/*.json 2>/dev/null || echo '(keine auth/*.json)' `.trim()); log("info", "proxy", `Auth-Dateien:\n${authInfo}`); broadcast({ type: "proxy_auth", info: authInfo }); } catch (err) { log("error", "proxy", `Auth-Check fehlgeschlagen: ${err.message}`); broadcast({ type: "proxy_auth", info: null, error: err.message }); } } // ── OpenClaw Agent-Auth pruefen ────────────────────────── async function checkCoreAuth() { try { log("info", "gateway", "Pruefe OpenClaw Agent-Konfiguration..."); const info = await dockerExec("aria-core", ` echo '=== Agent-Verzeichnis ===' && ls -la /home/node/.openclaw/agents/main/agent/ 2>&1 && echo '' && echo '=== auth-profiles.json ===' && cat /home/node/.openclaw/agents/main/agent/auth-profiles.json 2>/dev/null || echo '(nicht vorhanden)' && echo '' && echo '=== Umgebungsvariablen ===' && echo "OPENAI_BASE_URL=$OPENAI_BASE_URL" && echo "OPENAI_API_KEY=$(echo $OPENAI_API_KEY | head -c 15)..." && echo "DEFAULT_MODEL=$DEFAULT_MODEL" && echo '' && echo '=== OpenClaw Version ===' && openclaw --version 2>/dev/null || echo '(openclaw CLI nicht gefunden)' && echo '' && echo '=== Agents Liste ===' && openclaw agents list 2>/dev/null || echo '(Befehl fehlgeschlagen)' `.trim()); log("info", "gateway", `OpenClaw Config:\n${info}`); broadcast({ type: "core_auth", info }); } catch (err) { log("error", "gateway", `Core-Auth-Check fehlgeschlagen: ${err.message}`); broadcast({ type: "core_auth", info: null, error: err.message }); } } // ── Docker Container Logs ──────────────────────────────── const CONTAINER_MAP = { gateway: "aria-core", proxy: "aria-proxy", bridge: "aria-bridge", }; function fetchDockerLogs(tab, tail) { const containerName = CONTAINER_MAP[tab]; if (!containerName) return; const lines = parseInt(tail, 10) || 100; const dockerPath = `/containers/${containerName}/logs?stdout=true&stderr=true&tail=${lines}×tamps=true`; return new Promise((resolve, reject) => { const req = http.request( { socketPath: "/var/run/docker.sock", path: dockerPath, method: "GET" }, (res) => { const chunks = []; res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => { // Docker log stream hat 8-byte Header pro Frame (stream type + size) const raw = Buffer.concat(chunks); const logLines = []; let offset = 0; while (offset < raw.length) { if (offset + 8 > raw.length) break; const size = raw.readUInt32BE(offset + 4); if (offset + 8 + size > raw.length) break; const line = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd(); if (line) logLines.push(line); offset += 8 + size; } resolve(logLines); }); } ); req.on("error", (err) => reject(err)); req.end(); }); } async function handleDockerLogs(ws, tab, tail) { const containerName = CONTAINER_MAP[tab]; if (!containerName) { ws.send(JSON.stringify({ type: "docker_logs", tab, error: `Unbekannter Tab: ${tab}` })); return; } try { log("info", "server", `Lade Docker-Logs: ${containerName} (tail ${tail || 100})`); const lines = await fetchDockerLogs(tab, tail); ws.send(JSON.stringify({ type: "docker_logs", tab, container: containerName, lines })); } catch (err) { log("error", "server", `Docker-Logs Fehler (${containerName}): ${err.message}`); ws.send(JSON.stringify({ type: "docker_logs", tab, error: err.message })); } } // ── Docker Exec (Befehl in Container ausfuehren) ──────── function dockerContainerStop(name, timeoutSec = 10) { return new Promise((resolve, reject) => { const req = http.request({ socketPath: "/var/run/docker.sock", path: `/containers/${name}/stop?t=${timeoutSec}`, method: "POST", headers: { "Content-Length": 0 }, timeout: (timeoutSec + 5) * 1000, }, (dRes) => { // 204 = OK, 304 = already stopped, 404 = nicht da if (dRes.statusCode === 204 || dRes.statusCode === 304) resolve(); else if (dRes.statusCode === 404) resolve(); // Container fehlt: nicht fatal else reject(new Error(`docker stop ${name}: HTTP ${dRes.statusCode}`)); dRes.resume(); }); req.on("error", reject); req.end(); }); } function dockerContainerStart(name) { return new Promise((resolve, reject) => { const req = http.request({ socketPath: "/var/run/docker.sock", path: `/containers/${name}/start`, method: "POST", headers: { "Content-Length": 0 }, timeout: 30000, }, (dRes) => { if (dRes.statusCode === 204 || dRes.statusCode === 304) resolve(); else reject(new Error(`docker start ${name}: HTTP ${dRes.statusCode}`)); dRes.resume(); }); req.on("error", reject); req.end(); }); } function dockerExec(containerName, cmd) { return new Promise((resolve, reject) => { const createBody = JSON.stringify({ AttachStdout: true, AttachStderr: true, Cmd: Array.isArray(cmd) ? cmd : ["sh", "-c", cmd], }); const createReq = http.request({ socketPath: "/var/run/docker.sock", path: `/containers/${containerName}/exec`, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) }, }, (res) => { let data = ""; res.on("data", (c) => data += c); res.on("end", () => { if (res.statusCode !== 201) return reject(new Error(`Exec create: HTTP ${res.statusCode} — ${data.slice(0, 200)}`)); const execId = JSON.parse(data).Id; // Exec starten const startBody = JSON.stringify({ Detach: false }); const startReq = http.request({ socketPath: "/var/run/docker.sock", path: `/exec/${execId}/start`, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(startBody) }, }, (sRes) => { const chunks = []; sRes.on("data", (c) => chunks.push(c)); sRes.on("end", () => { // Docker multiplexed stream: 8-byte header pro Frame const raw = Buffer.concat(chunks); const lines = []; let offset = 0; while (offset < raw.length) { if (offset + 8 > raw.length) { lines.push(raw.slice(offset).toString("utf-8").trim()); break; } const size = raw.readUInt32BE(offset + 4); if (size === 0 || offset + 8 + size > raw.length) { lines.push(raw.slice(offset).toString("utf-8").trim()); break; } const line = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd(); if (line) lines.push(line); offset += 8 + size; } resolve(lines.join("\n")); }); }); startReq.on("error", reject); startReq.write(startBody); startReq.end(); }); }); createReq.on("error", reject); createReq.write(createBody); createReq.end(); }); } // ── Hilfsfunktionen ───────────────────────────────────── function waitForMessage(ws, timeoutMs) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`Timeout (${timeoutMs}ms)`)); }, timeoutMs); ws.once("message", (data) => { clearTimeout(timeout); resolve(data.toString()); }); }); } // ── Watchdog entfernt — pendingMessageTime bleibt fuer /api/cancel let pendingMessageTime = 0; function updateAgentActivity() { /* No-Op nach Watchdog-Ausbau */ } // ── Disk-Space Monitor ─────────────────────────────── // Prueft regelmaessig die Host-Disk (via gemountetem /shared) und // broadcastet bei kritischen Schwellwerten ein disk_status Event. let lastDiskStatus = null; let currentDiskStatus = null; // Vollstaendig fuer neu verbundene Clients function checkDiskSpace() { const { exec } = require("child_process"); exec("df -B1 /shared", (err, stdout) => { if (err) return; const lines = stdout.trim().split("\n"); if (lines.length < 2) return; const cols = lines[1].split(/\s+/); // Filesystem Size Used Avail Use% MountedOn const total = parseInt(cols[1], 10); const used = parseInt(cols[2], 10); const avail = parseInt(cols[3], 10); if (!total) return; const pct = Math.round((used / total) * 100); let level = "ok"; if (pct >= 95) level = "critical"; else if (pct >= 85) level = "warn"; else if (pct >= 70) level = "info"; const status = { type: "disk_status", level, percent: pct, usedBytes: used, totalBytes: total, availBytes: avail, }; currentDiskStatus = status; // Nur broadcasten wenn sich was geaendert hat (oder alle 60s Refresh) const key = `${level}-${pct}`; if (lastDiskStatus !== key) { lastDiskStatus = key; broadcast(status); if (level !== "ok") { log(level === "critical" ? "error" : "warn", "server", `Disk ${pct}% belegt (${(used/1024/1024/1024).toFixed(1)}GB von ${(total/1024/1024/1024).toFixed(1)}GB)`); } } }); } // Beim Start + alle 30s setTimeout(checkDiskSpace, 2000); setInterval(checkDiskSpace, 30000); // Watchdog entfernt — war auf aria-core/OpenClaw zugeschnitten (doctor --fix, // docker restart aria-core). Der Brain-Loop bekommt seinen eigenen Health-Check. // ── HTTP Server + WebSocket fuer Browser ──────────────── const htmlPath = path.join(__dirname, "index.html"); const server = http.createServer((req, res) => { if (req.url === "/" || req.url === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(fs.readFileSync(htmlPath, "utf-8")); } else if (req.url === "/api/state") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ state, logs: logs.slice(-100) })); } else if (req.url === "/api/session") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ sessionKey: activeSessionKey })); } else if (req.url === "/api/runtime-config" && req.method === "GET") { // Zentrale Runtime-Config (ENV + Override aus /shared/config/runtime.json) res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(readRuntimeConfig())); } else if (req.url === "/api/runtime-config" && req.method === "POST") { let body = ""; req.on("data", chunk => { body += chunk; if (body.length > 32768) req.destroy(); }); req.on("end", () => { try { const patch = JSON.parse(body); writeRuntimeConfig(patch); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, config: readRuntimeConfig() })); log("info", "server", `Runtime-Config aktualisiert: ${Object.keys(patch).join(", ")}`); } catch (err) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } }); return; } else if (req.url === "/api/onboarding") { // RVS-Credentials fuer QR-Code App-Onboarding res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ rvsHost: RVS_HOST, rvsPort: RVS_PORT, rvsTLS: RVS_TLS === "true" || RVS_TLS === true, rvsToken: RVS_TOKEN, })); } else if (req.url === "/api/cancel" && req.method === "POST") { log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)"); pendingMessageTime = 0; if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen (App)"); else broadcast({ type: "agent_activity", activity: "idle" }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); } else if (req.url === "/api/files-list" && req.method === "GET") { // Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User // (Upload aus App/Diagnostic) oder von ARIA (aria_. Pattern). try { const dir = "/shared/uploads"; let entries = []; try { entries = fs.readdirSync(dir); } catch { entries = []; } const files = entries .map(name => { try { const full = path.join(dir, name); const st = fs.statSync(full); if (!st.isFile()) return null; return { name, path: full, size: st.size, mtime: Math.floor(st.mtimeMs), fromAria: name.startsWith("aria_"), }; } catch { return null; } }) .filter(Boolean) .sort((a, b) => b.mtime - a.mtime); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, files })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } return; } else if (req.url.startsWith("/api/files-download?") && req.method === "GET") { const u = new URL("http://x" + req.url); const p = u.searchParams.get("path") || ""; const safe = path.resolve(p); if (!safe.startsWith("/shared/uploads/") || !fs.existsSync(safe)) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "Datei nicht gefunden" })); return; } const stat = fs.statSync(safe); const fname = path.basename(safe); res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": stat.size, "Content-Disposition": `attachment; filename="${fname}"`, }); fs.createReadStream(safe).pipe(res); return; } else if (req.url === "/api/files-delete" && req.method === "POST") { let body = ""; req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); }); req.on("end", () => { try { const { path: p } = JSON.parse(body || "{}"); const safe = path.resolve(p || ""); if (!safe.startsWith("/shared/uploads/")) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "Pfad nicht erlaubt" })); return; } if (!fs.existsSync(safe)) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "Datei nicht vorhanden" })); return; } fs.unlinkSync(safe); log("info", "server", `Datei geloescht: ${safe}`); // Live-Event an alle Browser-Clients broadcast({ type: "file_deleted", path: safe }); // Auch an die Bridge weiterleiten, damit die App-Bubbles aktualisiert sendToRVS_raw({ type: "file_deleted", payload: { path: safe }, timestamp: Date.now(), }); // Im chat_backup.jsonl markieren — beim Reload sehen Clients dass weg try { const backupFile = "/shared/config/chat_backup.jsonl"; const line = JSON.stringify({ type: "file_deleted", path: safe, ts: Date.now(), by: "user", }) + "\n"; fs.appendFileSync(backupFile, line); } catch {} res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } }); return; } else if (req.url === "/api/voice-config-export" && req.method === "GET") { // voice_config.json + highlight_triggers.json als JSON-Bundle exportieren try { const bundle = {}; try { bundle.voice_config = JSON.parse(fs.readFileSync("/shared/config/voice_config.json", "utf-8")); } catch {} try { bundle.highlight_triggers = JSON.parse(fs.readFileSync("/shared/config/highlight_triggers.json", "utf-8")); } catch {} const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); res.writeHead(200, { "Content-Type": "application/json", "Content-Disposition": `attachment; filename="aria-voice-settings-${ts}.json"`, }); res.end(JSON.stringify(bundle, null, 2)); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } return; } else if (req.url === "/api/voice-config-import" && req.method === "POST") { let body = ""; req.on("data", c => { body += c; if (body.length > 1024 * 1024) req.destroy(); }); req.on("end", () => { try { const bundle = JSON.parse(body || "{}"); fs.mkdirSync("/shared/config", { recursive: true }); if (bundle.voice_config && typeof bundle.voice_config === "object") { fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(bundle.voice_config, null, 2)); } if (bundle.highlight_triggers && typeof bundle.highlight_triggers === "object") { fs.writeFileSync("/shared/config/highlight_triggers.json", JSON.stringify(bundle.highlight_triggers, null, 2)); } log("info", "server", "Voice-Settings importiert"); // Bridge bekommt die neue Config via RVS (bei naechstem Reload), aber // ein Restart ist sauberer damit Whisper/F5 sofort neu laden. res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "Voice-Settings importiert — Bridge-Restart empfohlen." })); } catch (err) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } }); return; } else if (req.url === "/api/wipe-all" && req.method === "POST") { // Komplett-Reset — Gedaechtnis, Stimmen, Config alle weg. SSH-Keys // und .env bleiben, RVS-Anbindung bleibt. Brain + Qdrant werden // gestoppt, Filesystem geleert, dann neu gestartet. log("warn", "server", "HTTP /api/wipe-all — Gesamt-Reset"); const { spawn } = require("child_process"); Promise.resolve() .then(() => dockerContainerStop("aria-brain").catch(() => {})) .then(() => dockerContainerStop("aria-qdrant").catch(() => {})) .then(() => new Promise((resolve, reject) => { // Liste der Pfade die innerhalb des Diagnostic-Containers gemountet // sind: /shared (config + voices) und /brain (Memory + Skills). const cmd = [ "rm -rf /shared/config /shared/voices /shared/conversations-archive /shared/chat_backup.jsonl", "rm -rf /brain/data /brain/qdrant /brain/.[!.]* 2>/dev/null || true", "mkdir -p /brain/data /brain/qdrant /shared/config /shared/voices", ].join(" && "); const sh = spawn("sh", ["-c", cmd]); let stderr = ""; sh.stderr.on("data", d => stderr += d.toString()); sh.on("close", c => c === 0 ? resolve() : reject(new Error(`wipe exit ${c}: ${stderr}`))); })) .then(async () => { // Bridge auch neustarten, damit Whisper-Config + Voice-Config frisch await dockerContainerStop("aria-bridge").catch(() => {}); await dockerContainerStart("aria-qdrant"); await new Promise(r => setTimeout(r, 2000)); await dockerContainerStart("aria-brain"); await dockerContainerStart("aria-bridge"); log("info", "server", "wipe-all OK — Brain + Qdrant + Bridge neu gestartet"); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "Komplett-Reset durchgefuehrt — ARIA ist leer." })); }) .catch(async err => { log("error", "server", `wipe-all: ${err.message}`); // Container trotzdem hochfahren try { await dockerContainerStart("aria-qdrant"); } catch {} try { await dockerContainerStart("aria-brain"); } catch {} try { await dockerContainerStart("aria-bridge"); } catch {} res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); }); return; } else if (req.url === "/api/container-restart" && req.method === "POST") { // Generischer Restart fuer ARIAs Container — Whitelist verhindert // dass jemand aria-proxy oder das Diagnostic selbst kickt. let body = ""; req.on("data", c => { body += c; if (body.length > 1024) req.destroy(); }); req.on("end", async () => { try { const { name } = JSON.parse(body || "{}"); const ALLOWED = ["aria-bridge", "aria-brain", "aria-qdrant"]; if (!ALLOWED.includes(name)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: `Container '${name}' nicht erlaubt (Whitelist: ${ALLOWED.join(", ")})` })); return; } log("info", "server", `HTTP /api/container-restart — ${name}`); await dockerContainerStop(name).catch(() => {}); await new Promise(r => setTimeout(r, 500)); await dockerContainerStart(name); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, container: name })); } catch (err) { log("error", "server", `container-restart: ${err.message}`); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } }); return; } else if (req.url.startsWith("/api/brain/")) { // Reverse-Proxy zum aria-brain Container (intern auf 8080, nicht expose'd). // Frontend ruft z.B. /api/brain/health → http://aria-brain:8080/health const targetPath = req.url.replace(/^\/api\/brain/, ""); const proxyReq = http.request({ host: "aria-brain", port: 8080, path: targetPath, method: req.method, headers: req.headers, timeout: 30000, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); proxyReq.on("error", (err) => { res.writeHead(503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "unreachable", error: err.message })); }); req.pipe(proxyReq); return; } else if (req.url === "/api/brain-export" && req.method === "GET") { // Komplettes Gehirn als tar.gz streamen. // Schritte: Brain + Qdrant stoppen (saubere Bytes) → tar streamen → wieder starten. log("info", "server", "HTTP /api/brain-export — Gehirn-Export gestartet"); const { spawn } = require("child_process"); dockerContainerStop("aria-brain").catch(() => {}) .then(() => dockerContainerStop("aria-qdrant").catch(() => {})) .then(() => { const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); res.writeHead(200, { "Content-Type": "application/gzip", "Content-Disposition": `attachment; filename="aria-brain-${ts}.tar.gz"`, "Cache-Control": "no-store", }); const tar = spawn("tar", ["czf", "-", "-C", "/brain", "."]); tar.stdout.pipe(res); let stderr = ""; tar.stderr.on("data", d => stderr += d.toString()); const restartAll = async () => { try { await dockerContainerStart("aria-qdrant"); await new Promise(r => setTimeout(r, 1500)); await dockerContainerStart("aria-brain"); log("info", "server", "brain-export: Container wieder gestartet"); } catch (e) { log("error", "server", `brain-export Restart fehlgeschlagen: ${e.message}`); } }; tar.on("close", (code) => { if (code !== 0) log("error", "server", `brain-export tar exit ${code}: ${stderr.slice(0, 200)}`); restartAll(); }); // Client-Disconnect → tar abbrechen + Container wieder hoch req.on("close", () => { if (!tar.killed) { tar.kill("SIGTERM"); restartAll(); } }); }) .catch(err => { log("error", "server", `brain-export: ${err.message}`); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } }); return; } else if (req.url === "/api/brain-import" && req.method === "POST") { // Body ist die rohe tar.gz-Datei (kein multipart). Beliebig grosse Uploads // werden direkt in tar -xz gepiped → kein Memory-Bloat. log("warn", "server", "HTTP /api/brain-import — Gehirn-Import gestartet"); const { spawn } = require("child_process"); dockerContainerStop("aria-brain").catch(() => {}) .then(() => dockerContainerStop("aria-qdrant").catch(() => {})) .then(() => new Promise((resolve, reject) => { // Brain-Verzeichnis leeren (Mount-Point selbst nicht entfernen) const rm = spawn("sh", ["-c", "rm -rf /brain/data /brain/qdrant /brain/.[!.]* 2>/dev/null; mkdir -p /brain/data /brain/qdrant"]); rm.on("close", (code) => code === 0 ? resolve() : reject(new Error(`cleanup exit ${code}`))); rm.on("error", reject); })) .then(() => new Promise((resolve, reject) => { const tar = spawn("tar", ["xzf", "-", "-C", "/brain"]); req.pipe(tar.stdin); let stderr = ""; tar.stderr.on("data", d => stderr += d.toString()); tar.on("close", (code) => { if (code === 0) resolve(); else reject(new Error(`tar exit ${code}: ${stderr.slice(0, 300)}`)); }); tar.on("error", reject); // Falls Client mittendrin abbricht req.on("close", () => { if (!tar.killed && !req.complete) tar.kill("SIGTERM"); }); })) .then(async () => { // Subdirs sicherstellen (falls Archiv unsauber war) await new Promise(r => spawn("mkdir", ["-p", "/brain/data", "/brain/qdrant"]).on("close", r)); await dockerContainerStart("aria-qdrant"); await new Promise(r => setTimeout(r, 2000)); await dockerContainerStart("aria-brain"); log("info", "server", "brain-import OK — Container neu gestartet"); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "Gehirn importiert, Container neu gestartet" })); }) .catch(async (err) => { log("error", "server", `brain-import fehlgeschlagen: ${err.message}`); // Container trotzdem wieder hochfahren, sonst steht alles try { await dockerContainerStart("aria-qdrant"); } catch {} try { await dockerContainerStart("aria-brain"); } catch {} if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } }); return; } else if (req.url.startsWith("/shared/")) { // Dateien aus Shared Volume ausliefern (Bilder, Uploads) const filePath = decodeURIComponent(req.url); const safePath = path.resolve(filePath); if (!safePath.startsWith("/shared/")) { res.writeHead(403); res.end("Forbidden"); return; } try { if (!fs.existsSync(safePath)) { res.writeHead(404); res.end("Not Found"); return; } const ext = path.extname(safePath).toLowerCase(); const mimeTypes = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json" }; const contentType = mimeTypes[ext] || "application/octet-stream"; const data = fs.readFileSync(safePath); res.writeHead(200, { "Content-Type": contentType, "Content-Length": data.length }); res.end(data); } catch (err) { res.writeHead(500); res.end("Error"); } } else { res.writeHead(404); res.end("Not Found"); } }); const wss = new WebSocketServer({ server }); wss.on("connection", (ws) => { browserClients.add(ws); // Initialen State + letzte Logs senden ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) })); // Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus)); ws.on("message", (raw) => { try { const msg = JSON.parse(raw.toString()); if (msg.action === "test_gateway") { traceStart("Gateway", msg.text || "aria lebst du noch?"); sendToGateway(msg.text || "aria lebst du noch?", true); } else if (msg.action === "test_rvs") { traceStart("RVS", msg.text || "aria lebst du noch?"); sendToRVS(msg.text || "aria lebst du noch?", true); } else if (msg.action === "reconnect_gateway") { connectGateway(); } else if (msg.action === "reconnect_rvs") { connectRVS(); } else if (msg.action === "test_proxy") { testProxy(msg.text); } else if (msg.action === "check_proxy_auth") { checkProxyAuth(); } else if (msg.action === "proxy_login") { attachTerminal(ws, "aria-proxy", "claude login"); } else if (msg.action === "core_terminal") { // Interaktive Shell in aria-core (fuer openclaw agents, etc.) attachTerminal(ws, "aria-core", msg.cmd || "sh"); } else if (msg.action === "check_core_auth") { checkCoreAuth(); } else if (msg.action === "term_input") { handleTermInput(ws, msg.data); } else if (msg.action === "write_credentials") { writeProxyCredentials(msg.credentials); } else if (msg.action === "docker_logs") { handleDockerLogs(ws, msg.tab, msg.tail); } else if (msg.action === "live_ssh_start") { startLiveSSH(ws); } else if (msg.action === "live_ssh_input") { if (ws._sshSock) ws._sshSock.write(msg.data); } else if (msg.action === "live_ssh_close") { if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; } } else if (msg.action === "send_file") { // Datei von Diagnostic an Bridge via RVS senden sendToRVS_raw({ type: "file", payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 }, timestamp: Date.now(), }); log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`); } else if (msg.action === "cancel_request") { // Laufende Anfrage abbrechen — doctor --fix beendet stuck runs log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus"); pendingMessageTime = 0; watchdogWarned = false; watchdogFixAttempted = false; if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen"); broadcast({ type: "agent_activity", activity: "idle" }); dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {}); } else if (msg.action === "voice_upload") { // Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`); sendToRVS_withResponse("voice_upload", { name: msg.name, samples: msg.samples }, "xtts_voice_saved", ws); } else if (msg.action === "xtts_list_voices") { // Frische Verbindung die auf Antwort wartet sendToRVS_withResponse("xtts_list_voices", {}, "xtts_voices_list", ws); } else if (msg.action === "xtts_export_voice") { // Anfrage an XTTS-Bridge — gibt die Stimme als base64 tar.gz zurueck sendToRVS_withResponse("xtts_export_voice", { name: msg.name }, "xtts_voice_exported", ws); } else if (msg.action === "xtts_import_voice") { // tar.gz (base64) an XTTS-Bridge schicken — die packt aus sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws); } else if (msg.action === "xtts_delete_voice") { // Weiterleiten an XTTS-Bridge, die antwortet mit neuer Liste sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() }); log("info", "server", `Voice-Delete '${msg.name}' an XTTS-Bridge gesendet`); } else if (msg.action === "set_mode") { // Mode-Wechsel → Bridge bearbeitet und broadcastet an alle Clients sendToRVS_raw({ type: "mode", payload: { mode: msg.mode }, timestamp: Date.now() }); log("info", "server", `Mode-Wechsel angefordert: ${msg.mode}`); } else if (msg.action === "get_voice_config") { handleGetVoiceConfig(ws); } else if (msg.action === "send_voice_config") { // Stimmen-Config persistent speichern + an Bridge via RVS senden let existing = {}; try { existing = JSON.parse(fs.readFileSync("/shared/config/voice_config.json", "utf-8")); } catch {} const voiceConfig = { ...existing, ttsEnabled: msg.ttsEnabled !== false, xttsVoice: msg.xttsVoice || "", }; if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel; // F5-TTS Tuning-Felder — immer mit dem vom User gesendeten Wert setzen, // auch leeren String. Leer = "reset auf Hard-Default". Sonst merkt die // Bridge nicht dass der User den Wert loeschen wollte (absent key war // vorher 'keep current' semantik → BigVGAN blieb drin obwohl User // leer eingetragen hatte). if (msg.f5ttsModel !== undefined) voiceConfig.f5ttsModel = msg.f5ttsModel || ""; if (msg.f5ttsCkptFile !== undefined) voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile || ""; if (msg.f5ttsVocabFile !== undefined) voiceConfig.f5ttsVocabFile = msg.f5ttsVocabFile || ""; if (msg.f5ttsCfgStrength !== undefined && !isNaN(msg.f5ttsCfgStrength)) { voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength; } if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) { voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep; } try { fs.mkdirSync("/shared/config", { recursive: true }); fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2)); } catch {} sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() }); log("info", "server", `Voice-Config gespeichert: xttsVoice=${voiceConfig.xttsVoice || "default"}, whisper=${voiceConfig.whisperModel || "-"}`); } else if (msg.action === "preview_voice") { handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo.", msg.speed); } else if (msg.action === "check_desktop") { checkDesktopAvailable(ws); } else if (msg.action === "load_chat_history") { handleLoadChatHistory(ws); // Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory // laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab). // restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert. } else if (msg.action === "restart_session") { handleRestartSession(ws); // ── Einstellungen ── // list_permissions / save_permissions entfernt — Alles-oder-Nichts via --dangerously-skip-permissions } else if (msg.action === "get_model") { handleGetModel(ws); } else if (msg.action === "set_model") { handleSetModel(ws, msg.model); } // get_openclaw_config entfernt — aria-core ist raus. } catch {} }); ws.on("close", () => { browserClients.delete(ws); }); }); // ── Live SSH ───────────────────────────────────────────── function startLiveSSH(clientWs) { // Bestehende Session schliessen if (clientWs._sshSock) { try { clientWs._sshSock.end(); } catch (_) {} clientWs._sshSock = null; } const createBody = JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: ["ssh", "-tt", "aria-wohnung"], }); const createReq = http.request({ socketPath: "/var/run/docker.sock", path: "/containers/aria-core/exec", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) }, }, (res) => { let data = ""; res.on("data", (c) => data += c); res.on("end", () => { if (res.statusCode !== 201) { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `Exec create failed: HTTP ${res.statusCode}` })); return; } const execId = JSON.parse(data).Id; const startBody = JSON.stringify({ Detach: false, Tty: true }); const sock = net.connect({ path: "/var/run/docker.sock" }, () => { const req = `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(startBody)}\r\n\r\n${startBody}`; sock.write(req); }); let headersParsed = false; let headerBuf = ""; sock.on("data", (chunk) => { if (!headersParsed) { headerBuf += chunk.toString("utf-8"); const headerEnd = headerBuf.indexOf("\r\n\r\n"); if (headerEnd === -1) return; const headers = headerBuf.slice(0, headerEnd); const remaining = chunk.slice(chunk.length - (headerBuf.length - headerEnd - 4)); headersParsed = true; if (!headers.includes("200") && !headers.includes("101")) { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `SSH start failed` })); sock.end(); return; } log("info", "server", "Live SSH-Session gestartet"); clientWs.send(JSON.stringify({ type: "live_ssh_connected" })); clientWs._sshSock = sock; if (remaining.length > 0) { clientWs.send(JSON.stringify({ type: "live_ssh_data", data: remaining.toString("base64") })); } return; } // Stream-Daten ans Frontend try { clientWs.send(JSON.stringify({ type: "live_ssh_data", data: chunk.toString("base64") })); } catch (_) { sock.end(); } }); sock.on("close", () => { clientWs._sshSock = null; try { clientWs.send(JSON.stringify({ type: "live_ssh_closed" })); } catch (_) {} log("info", "server", "Live SSH-Session beendet"); }); sock.on("error", (err) => { clientWs._sshSock = null; try { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: err.message })); } catch (_) {} }); }); }); createReq.on("error", (err) => { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `Docker: ${err.message}` })); }); createReq.end(createBody); } // ── Voice-Config laden ──────────────────────────────── function handleGetVoiceConfig(clientWs) { try { const configPath = "/shared/config/voice_config.json"; if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); clientWs.send(JSON.stringify({ type: "voice_config", ...config })); } else { clientWs.send(JSON.stringify({ type: "voice_config", ttsEnabled: true, xttsVoice: "" })); } } catch (err) { clientWs.send(JSON.stringify({ type: "voice_config", ttsEnabled: true, xttsVoice: "" })); } } // ── TTS Diagnose (XTTS) ─────────────────────────────── // ── Voice Preview ──────────────────────────────────────── // Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV // und schickt sie base64-kodiert an den Browser-Client. // // Map requestId → { clientWs, chunks: [Buffer], sampleRate, channels } const _previewPending = new Map(); function _buildWavFromPcm(pcmBuf, sampleRate, channels) { const bitsPerSample = 16; const byteRate = sampleRate * channels * bitsPerSample / 8; const blockAlign = channels * bitsPerSample / 8; const dataSize = pcmBuf.length; const header = Buffer.alloc(44); header.write("RIFF", 0); header.writeUInt32LE(36 + dataSize, 4); header.write("WAVE", 8); header.write("fmt ", 12); header.writeUInt32LE(16, 16); // subchunk1 size header.writeUInt16LE(1, 20); // PCM header.writeUInt16LE(channels, 22); header.writeUInt32LE(sampleRate, 24); header.writeUInt32LE(byteRate, 28); header.writeUInt16LE(blockAlign, 32); header.writeUInt16LE(bitsPerSample, 34); header.write("data", 36); header.writeUInt32LE(dataSize, 40); return Buffer.concat([header, pcmBuf]); } function _handlePreviewChunk(payload) { const reqId = payload?.requestId || ""; const entry = _previewPending.get(reqId); if (!entry) return; if (payload.base64) { try { entry.chunks.push(Buffer.from(payload.base64, "base64")); } catch {} } if (!entry.sampleRate && payload.sampleRate) entry.sampleRate = payload.sampleRate; if (!entry.channels && payload.channels) entry.channels = payload.channels; if (payload.final) { _previewPending.delete(reqId); try { const pcm = Buffer.concat(entry.chunks); const wav = _buildWavFromPcm(pcm, entry.sampleRate || 24000, entry.channels || 1); const b64 = wav.toString("base64"); if (entry.clientWs && entry.clientWs.readyState === 1) { entry.clientWs.send(JSON.stringify({ type: "voice_preview_audio", base64: b64, size: wav.length, })); } } catch (err) { if (entry.clientWs && entry.clientWs.readyState === 1) { entry.clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message, })); } } } } async function handleVoicePreview(clientWs, voice, text, speed) { try { // Speed clampen — Browser-Slider ist 0.1-5.0 let spd = parseFloat(speed); if (!isFinite(spd) || spd < 0.1 || spd > 5.0) spd = 1.0; const requestId = crypto.randomUUID(); _previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 }); // Timeout safety net setTimeout(() => { if (_previewPending.has(requestId)) { _previewPending.delete(requestId); if (clientWs && clientWs.readyState === 1) { clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: "Timeout (60s) — keine Antwort vom f5tts-bridge", })); } } }, 60000); log("info", "server", `Voice-Preview: voice="${voice}" speed=${spd.toFixed(1)}x text="${text.slice(0, 60)}"`); sendToRVS_raw({ type: "xtts_request", payload: { text, language: "de", requestId, voice, speed: spd }, timestamp: Date.now(), }); } catch (err) { clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message })); } } function checkDesktopAvailable(clientWs) { // Pruefen ob VNC auf der VM laeuft (Port 5900/5901) const checkSock = net.connect({ host: "host.docker.internal", port: 5901 }, () => { checkSock.end(); clientWs.send(JSON.stringify({ type: "desktop_status", available: true, url: `http://${clientWs._socket?.remoteAddress || "localhost"}:6080/vnc.html?autoconnect=true`, message: "VNC Desktop verfuegbar", })); }); checkSock.on("error", () => { clientWs.send(JSON.stringify({ type: "desktop_status", available: false, message: "Kein VNC-Server auf aria-wohnung gefunden (Port 5901)", })); }); checkSock.setTimeout(3000, () => { checkSock.destroy(); clientWs.send(JSON.stringify({ type: "desktop_status", available: false, message: "VNC-Server nicht erreichbar (Timeout)", })); }); } // ── Session-Viewer + Brain-File-Viewer entfernt ───────── // OpenClaw-Sessions sind raus. Wer alte jsonl-Konversationen sichern // will: POST /api/sessions-snapshot kopiert sie nach // /shared/conversations-archive/ → spaeter Brain-Migration. // Memory laeuft jetzt komplett ueber den aria-brain Container // (Vector-DB, siehe /api/brain/* Proxy). // ── Chat-History: liest aus chat_backup.jsonl ──────────── // Ablage: jede Zeile ein JSON-Eintrag. // {ts, role: "user"|"assistant", text, session} // {ts, type: "file_deleted", path, by} // Marker [FILE: /shared/uploads/...] werden zu aria_file-Bubbles aufgeloest, // wenn die Datei noch existiert; geloeschte Dateien zu deleted-Markern. async function handleLoadChatHistory(clientWs) { try { const file = "/shared/config/chat_backup.jsonl"; let raw = ""; try { raw = fs.readFileSync(file, "utf-8"); } catch { clientWs.send(JSON.stringify({ type: "chat_history", messages: [] })); return; } const lines = raw.split("\n").filter(l => l.trim()); const deletedPaths = new Set(); const messages = []; const mimeMap = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json", ".zip": "application/zip", }; // Erst Durchlauf: deleted-Marker sammeln for (const line of lines) { try { const obj = JSON.parse(line); if (obj.type === "file_deleted" && obj.path) deletedPaths.add(obj.path); } catch {} } for (const line of lines) { let obj; try { obj = JSON.parse(line); } catch { continue; } if (obj.type === "file_deleted") continue; // Marker selbst nicht anzeigen if (obj.role !== "user" && obj.role !== "assistant") continue; const ts = obj.ts || 0; const text = String(obj.text || ""); if (obj.role === "user") { if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts }); continue; } // assistant: nach FILE-Markern scannen, eigene aria_file-Eintraege pro Datei const fileRe = /\[FILE:\s*(\/shared\/uploads\/[^\]]+?)\s*\]/gi; let m; while ((m = fileRe.exec(text)) !== null) { const p = m[1].trim(); const wasDeleted = deletedPaths.has(p); let size = 0; let exists = false; try { const st = fs.statSync(p); size = st.size; exists = true; } catch {} if (!exists && !wasDeleted) continue; // war vielleicht nie da const ext = path.extname(p).toLowerCase(); messages.push({ type: "aria_file", serverPath: p, name: path.basename(p), mimeType: mimeMap[ext] || "application/octet-stream", size, ts, deleted: wasDeleted || !exists, }); } if (text) messages.push({ type: "received", text, meta: "chat:final", ts }); } clientWs.send(JSON.stringify({ type: "chat_history", messages })); log("info", "server", `Chat-History geladen: ${messages.length} Eintraege (${deletedPaths.size} geloescht)`); } catch (err) { log("error", "server", `Chat-History: ${err.message}`); clientWs.send(JSON.stringify({ type: "chat_history", messages: [], error: err.message })); } } // ── Session neu starten (Container Restart) — toter Code fuer aria-core async function handleRestartSession(clientWs) { try { log("info", "server", "Starte aria-core Container neu (Session Restart)..."); clientWs.send(JSON.stringify({ type: "session_restarted", status: "restarting" })); // Container neu starten via Docker API const http = require("http"); await new Promise((resolve, reject) => { const req = http.request({ socketPath: "/var/run/docker.sock", path: "/containers/aria-core/restart?t=5", method: "POST", }, (res) => { let body = ""; res.on("data", d => body += d); res.on("end", () => { if (res.statusCode === 204 || res.statusCode === 200) resolve(); else reject(new Error(`Restart fehlgeschlagen: HTTP ${res.statusCode} ${body}`)); }); }); req.on("error", reject); req.end(); }); log("info", "server", "aria-core Container neu gestartet"); // Warten bis Gateway wieder erreichbar (max 30s) for (let i = 0; i < 15; i++) { await new Promise(r => setTimeout(r, 2000)); try { await dockerExec("aria-core", "echo ok"); log("info", "server", "aria-core ist wieder erreichbar"); clientWs.send(JSON.stringify({ type: "session_restarted", status: "ok", info: "aria-core neu gestartet — Permissions werden bei der naechsten Nachricht geladen", })); // Aktive Session beibehalten for (const c of browserClients) { c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey })); } return; } catch {} } clientWs.send(JSON.stringify({ type: "session_restarted", status: "timeout", error: "aria-core antwortet nicht nach 30s" })); } catch (err) { log("error", "server", `Session Restart fehlgeschlagen: ${err.message}`); clientWs.send(JSON.stringify({ type: "session_restarted", status: "error", error: err.message })); } } // ── Einstellungen: Tool-Berechtigungen ────────────────── // ENTFERNT: Granulare Permissions haben nie funktioniert. // Claude Code laeuft mit --dangerously-skip-permissions (Alles oder Nichts). // Root-Check wird via CLAUDE_CODE_BUBBLEWRAP=1 in docker-compose.yml umgangen. // ── Einstellungen: Model (Brain) ──────────────────────── // Liest/schreibt brainModel in /shared/config/runtime.json — Brain liest // das beim Container-Start. Bei Aenderung: aria-brain restarten // (Einstellungen → Reparatur → 🚨 aria-brain neu). function handleGetModel(clientWs) { try { const cfg = readRuntimeConfig(); const model = (cfg.brainModel || "").trim() || "claude-sonnet-4"; clientWs.send(JSON.stringify({ type: "model_info", model, info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten", })); } catch (err) { clientWs.send(JSON.stringify({ type: "model_info", error: err.message })); } } function handleSetModel(clientWs, model) { if (!model || typeof model !== "string") { clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" })); return; } try { writeRuntimeConfig({ brainModel: model.trim() }); log("info", "server", `Brain-Model gesetzt: ${model.trim()}`); clientWs.send(JSON.stringify({ type: "model_info", model: model.trim(), info: `Model auf "${model.trim()}" gesetzt — aria-brain neu starten damit's greift`, })); } catch (err) { clientWs.send(JSON.stringify({ type: "model_info", error: err.message })); } return; } // OpenClaw-Config-Handler entfernt — aria-core ist raus. // ── Start ─────────────────────────────────────────────── server.listen(HTTP_PORT, "0.0.0.0", () => { log("info", "server", `Diagnostic Server laeuft auf http://0.0.0.0:${HTTP_PORT}`); log("info", "server", `RVS: ${RVS_HOST ? `${RVS_HOST}:${RVS_PORT}` : "(nicht konfiguriert)"}`); log("info", "server", `Proxy: ${PROXY_URL}`); log("info", "server", `Brain: ${process.env.BRAIN_URL || "http://aria-brain:8080"}`); // RVS-Verbindung — aria-core/OpenClaw-Gateway entfaellt. connectRVS(); // gateway-State bleibt "disconnected" stehen; State-Card im UI // wurde mit ausgebaut. state.gateway.status = "disabled"; state.gateway.lastError = "aria-core entfernt — Brain-Loop in Arbeit"; broadcastState(); });