aa077f60e6
War kaputt nach OpenClaw-Abriss: handleGetModel/handleSetModel haben gegen
aria-core (dockerExec + node-script in den Container) gearbeitet, der gibt's
nicht mehr.
diagnostic/server.js
- handleGetModel/handleSetModel lesen/schreiben jetzt brainModel in
/shared/config/runtime.json
- RUNTIME_CONFIG_FIELDS um "brainModel" erweitert
- Tote Variante (findSettingsFile + base64-node-script) komplett raus
aria-brain/proxy_client.py
- Liest brainModel aus runtime.json beim Container-Start
- Fallback: BRAIN_MODEL env → "claude-sonnet-4" Default
- Bei Aenderung in Diagnostic: aria-brain restarten damit's greift
(Hinweis steht in der UI)
diagnostic/index.html
- Section "Model" → "Sprachmodell (Brain)"
- Hinweis-Block mit Default-Erklaerung und Restart-Hinweis
- Modelle: claude-sonnet-4 (default), claude-opus-4, claude-haiku-4-5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2225 lines
88 KiB
JavaScript
2225 lines
88 KiB
JavaScript
"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_<name>.<ext> 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();
|
|
});
|