Beide hiessen bisher so, wie es in IHRER .env steht. Umbenennen hiess also: auf
den Rechner, Datei aendern, Dienst neu starten — beim Fire TV sogar neu
provisionieren. In der Oberflaeche gab es dafuer gar keinen Weg.
Jetzt ein ✎ pro Eintrag. Der Alias liegt zentral in
/shared/config/display_names.json ({"host": {...}, "satellite": {...}}) und
wirkt sofort fuer JEDEN schon ausgerollten Agenten, ohne ihn anzufassen.
Der entscheidende Teil ist die Rueckrichtung: die Bridge loest den Alias beim
ADRESSIEREN wieder auf (_resolve_host / _resolve_satellite). Ohne das haette man
etwas umbenennen koennen und es danach nicht mehr ansprechen — der Agent
vergleicht die Adresse gegen seine EIGENE Kennung und kennt den Alias nicht.
Angesprochen werden kann weiterhin alles: Alias, Eigenname oder technische ID.
Der Eigenname bleibt in der Liste klein daneben sichtbar, damit man den Bezug
zum Geraet nicht verliert. Leerer Name entfernt den Alias.
Diagnostic und Bridge teilen sich die Datei ueber /shared — kein neuer
Nachrichtentyp, keine Allow-List-Aenderung, eine Wahrheit.
Nebenbei behoben: _display_name_set legte fest "/shared/config" an, obwohl der
Dateipfad konfigurierbar ist. Damit scheiterte das Schreiben stillschweigend,
sobald der Pfad abwich. Verzeichnis wird jetzt aus dem Pfad abgeleitet.
17 Pruefungen: Aliasvergabe, Liste, Aufloesung in beide Richtungen,
Gross/Kleinschreibung, alter Name bleibt gueltig, anderer Agent unberuehrt,
beide Bereiche in einer Datei ohne sich zu stoeren, Entfernen, unbekanntes Ziel
wird nicht still umgebogen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4006 lines
174 KiB
JavaScript
4006 lines
174 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";
|
|
|
|
// ── Persistenz fuer agent_stream-Events ──────────────────
|
|
// Jeder agent_stream-Event wird parallel zum Broadcast in eine .jsonl
|
|
// geschrieben. Live-View laedt beim Tab-Oeffnen die letzten ~200 Zeilen,
|
|
// damit Browser-Reload / Standby den Verlauf nicht wegwerfen. Rotation
|
|
// haendelt logrotate / manual cleanup — wir cappen hier nur weichweich.
|
|
const AGENT_STREAM_LOG = process.env.AGENT_STREAM_LOG || "/shared/logs/agent_stream.jsonl";
|
|
const AGENT_STREAM_MAX_BYTES = 50 * 1024 * 1024; // 50 MB → halten den File handlebar
|
|
function appendAgentStream(payload) {
|
|
if (!payload || typeof payload !== "object") return;
|
|
try {
|
|
const line = JSON.stringify({ ts: Date.now(), ...payload }) + "\n";
|
|
// Soft-Cap: bei >50 MB ein Truncate auf den letzten ~25 MB Inhalt
|
|
try {
|
|
const st = fs.statSync(AGENT_STREAM_LOG);
|
|
if (st.size > AGENT_STREAM_MAX_BYTES) {
|
|
const half = Math.floor(AGENT_STREAM_MAX_BYTES / 2);
|
|
const fd = fs.openSync(AGENT_STREAM_LOG, "r");
|
|
const buf = Buffer.alloc(half);
|
|
fs.readSync(fd, buf, 0, half, st.size - half);
|
|
fs.closeSync(fd);
|
|
// bis zum naechsten Newline springen damit wir keine halbe Zeile haben
|
|
const firstNl = buf.indexOf(0x0a);
|
|
const start = firstNl >= 0 ? firstNl + 1 : 0;
|
|
fs.writeFileSync(AGENT_STREAM_LOG, buf.slice(start));
|
|
}
|
|
} catch {}
|
|
// Verzeichnis sicherstellen
|
|
try { fs.mkdirSync(path.dirname(AGENT_STREAM_LOG), { recursive: true }); } catch {}
|
|
fs.appendFileSync(AGENT_STREAM_LOG, line);
|
|
} catch (e) {
|
|
// Schweigend ignorieren — Persistence darf den Stream nicht blockieren
|
|
}
|
|
}
|
|
|
|
// ── 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";
|
|
})();
|
|
|
|
// ── Auto-Versionierung /shared/uploads/ via git ────────────────
|
|
//
|
|
// Jede Aenderung im uploads/-Verzeichnis (User-Upload, ARIA-Generate,
|
|
// ARIA-Bearbeitung) wird durch eine 30s-Polling-Loop in einen git-Commit
|
|
// gepackt. Idempotent (kein Commit ohne Diff), kein Bloat im Normalbetrieb.
|
|
// Stefan kann via UI eine Version anschauen, herunterladen oder als
|
|
// neue aktive Version setzen (Restore = neuer commit mit altem Inhalt,
|
|
// non-destructive).
|
|
const SHARED_UPLOADS = "/shared/uploads";
|
|
const VERSIONING_INTERVAL_MS = 30 * 1000;
|
|
const { execFile } = require("child_process");
|
|
|
|
function git(args, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = execFile(
|
|
"git",
|
|
["-C", SHARED_UPLOADS, ...args],
|
|
{ maxBuffer: 20 * 1024 * 1024, ...opts },
|
|
(err, stdout, stderr) => {
|
|
if (err && !opts.allowFail) {
|
|
err.stderr = stderr;
|
|
return reject(err);
|
|
}
|
|
resolve({
|
|
stdout: stdout || "",
|
|
stderr: stderr || "",
|
|
code: err ? (err.code || 1) : 0,
|
|
});
|
|
},
|
|
);
|
|
if (opts.input != null) {
|
|
try { child.stdin.write(opts.input); } catch (_) {}
|
|
try { child.stdin.end(); } catch (_) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function initSharedVersioning() {
|
|
try {
|
|
fs.mkdirSync(SHARED_UPLOADS, { recursive: true });
|
|
} catch (e) {
|
|
console.error(`[shared-git] mkdir uploads fehlgeschlagen: ${e.message}`);
|
|
return;
|
|
}
|
|
const gitDir = path.join(SHARED_UPLOADS, ".git");
|
|
if (!fs.existsSync(gitDir)) {
|
|
console.log("[shared-git] Initialisiere /shared/uploads als git-Repo");
|
|
try {
|
|
await git(["init", "-q", "-b", "main"]);
|
|
await git(["config", "user.email", "aria@diagnostic"]);
|
|
await git(["config", "user.name", "aria-diagnostic"]);
|
|
// Initial commit (auch wenn leer) damit log/checkout immer funktioniert
|
|
await git(["commit", "-q", "--allow-empty", "-m", "initial snapshot"]);
|
|
// Falls schon Files drin sind: noch ein 'auto'-Commit hinten dran
|
|
const status = await git(["status", "--porcelain"]);
|
|
if (status.stdout.trim()) {
|
|
await git(["add", "-A"]);
|
|
await git(["commit", "-q", "-m", `auto: ${new Date().toISOString()}`]);
|
|
}
|
|
console.log("[shared-git] Init OK");
|
|
} catch (e) {
|
|
console.error(`[shared-git] Init fehlgeschlagen: ${e.message}`);
|
|
return;
|
|
}
|
|
} else {
|
|
console.log("[shared-git] Bestehendes git-Repo erkannt — uebernehme");
|
|
}
|
|
setInterval(autoCommitTick, VERSIONING_INTERVAL_MS);
|
|
console.log(`[shared-git] Auto-Commit-Loop alle ${VERSIONING_INTERVAL_MS}ms aktiv`);
|
|
}
|
|
|
|
let autoCommitBusy = false;
|
|
async function autoCommitTick() {
|
|
if (autoCommitBusy) return; // re-entrancy guard fuer langsame git ops
|
|
autoCommitBusy = true;
|
|
try {
|
|
const status = await git(["status", "--porcelain"]);
|
|
if (!status.stdout.trim()) return;
|
|
await git(["add", "-A"]);
|
|
const ts = new Date().toISOString();
|
|
await git(["commit", "-q", "-m", `auto: ${ts}`]);
|
|
console.log(`[shared-git] auto-commit @ ${ts}`);
|
|
} catch (e) {
|
|
console.error(`[shared-git] auto-commit fehlgeschlagen: ${e.message}`);
|
|
} finally {
|
|
autoCommitBusy = false;
|
|
}
|
|
}
|
|
|
|
// Versions-API helpers — werden weiter unten von den Routen genutzt.
|
|
function isPathSafe(rel) {
|
|
if (!rel || typeof rel !== "string") return false;
|
|
if (rel.includes("..") || rel.startsWith("/") || rel.startsWith(".git")) return false;
|
|
return true;
|
|
}
|
|
async function listVersionsForFile(rel) {
|
|
// git log --follow damit Renames trotzdem die Historie zeigen.
|
|
// NUL-Separator damit Subjects mit Leerzeichen nicht falsch splitten.
|
|
const out = await git(["log", "--follow", "--format=%H%x00%aI%x00%s", "--", rel]);
|
|
const lines = out.stdout.trim().split("\n").filter(Boolean);
|
|
const enriched = [];
|
|
for (const line of lines) {
|
|
const [hash, isoTs, subject] = line.split("\x00");
|
|
if (!hash) continue;
|
|
let blob = null;
|
|
try {
|
|
const ls = await git(["ls-tree", hash, "--", rel]);
|
|
// Format: "100644 blob <40-hex>\t<path>"
|
|
const m = ls.stdout.match(/blob ([0-9a-f]{40})/);
|
|
if (m) blob = m[1];
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
if (!blob) continue;
|
|
enriched.push({ hash, ts: Date.parse(isoTs) || 0, subject: subject || "", blob });
|
|
}
|
|
// Dedup auf Blob-Ebene — Restore-Commits sind inhaltlich gleich mit dem
|
|
// restorten alten Commit. Zeige nur den AELTESTEN (= zuerst erschienenen)
|
|
// Eintrag pro identischem Blob. Damit blaeht Restore die Liste nicht auf.
|
|
const seen = new Set();
|
|
const unique = [];
|
|
for (let i = enriched.length - 1; i >= 0; i--) {
|
|
const v = enriched[i];
|
|
if (seen.has(v.blob)) continue;
|
|
seen.add(v.blob);
|
|
unique.push(v);
|
|
}
|
|
unique.reverse(); // wieder neueste-zuerst fuers UI
|
|
// AKTIV-Marker: Commit dessen Blob == aktuelle Working-Copy. Nach Restore
|
|
// wandert AKTIV auf den restorten alten Stand, nicht auf den gefilterten
|
|
// Restore-Commit.
|
|
let currentBlob = null;
|
|
try {
|
|
const abs = path.join(SHARED_UPLOADS, rel);
|
|
if (fs.existsSync(abs)) {
|
|
const r = await git(["hash-object", abs]);
|
|
currentBlob = (r.stdout || "").trim();
|
|
}
|
|
} catch (_) {}
|
|
for (const v of unique) {
|
|
if (currentBlob && v.blob === currentBlob) v.isCurrent = true;
|
|
}
|
|
// Blob aus Response strippen — sieht im UI aus wie zweite Commit-ID, unnoetig.
|
|
return unique.map(({ blob, ...rest }) => rest);
|
|
}
|
|
async function getVersionContent(rel, hash) {
|
|
// git show <hash>:<path> liefert den Inhalt aus diesem Commit
|
|
// Binary-safe via stdio buffer
|
|
const out = await git(["show", `${hash}:${rel}`], { encoding: "buffer" });
|
|
return out.stdout; // Buffer
|
|
}
|
|
async function restoreVersion(rel, hash) {
|
|
// Variante: non-destructive — wir holen den alten Inhalt und schreiben
|
|
// ihn als NEUE Version drueber. Damit bleibt die aktuelle Version
|
|
// ebenfalls in der git-History rollback-bar.
|
|
const content = await getVersionContent(rel, hash);
|
|
const abs = path.join(SHARED_UPLOADS, rel);
|
|
fs.writeFileSync(abs, content);
|
|
await git(["add", "--", rel]);
|
|
await git(["commit", "-q", "-m", `restore: ${rel} <- ${hash.slice(0, 7)}`]);
|
|
return true;
|
|
}
|
|
|
|
// Beim Startup einmalig aufrufen
|
|
initSharedVersioning().catch(e =>
|
|
console.error(`[shared-git] initSharedVersioning crashed: ${e.message}`),
|
|
);
|
|
|
|
// ── 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",
|
|
// Groessen-Limit fuer Memory-Anhaenge in MB. Das Brain liest es pro Upload
|
|
// aus derselben Datei — Aenderung greift ohne Neustart.
|
|
"memoryAttachmentMaxMb",
|
|
];
|
|
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.
|
|
|
|
// ── Local-LLM-Config: /shared/config/local_llm.json ─────────────────
|
|
// Der Router im Brain (router.py) liest diese Datei pro Request. Wir schreiben
|
|
// sie hier aus den Diagnostic-Schaltern. Default = alles aus (nur Claude).
|
|
const LOCAL_LLM_CONFIG_FILE = "/shared/config/local_llm.json";
|
|
function readLocalLlmConfig() {
|
|
try {
|
|
const p = JSON.parse(fs.readFileSync(LOCAL_LLM_CONFIG_FILE, "utf-8"));
|
|
return {
|
|
enabled: !!p.enabled,
|
|
localOnly: !!p.localOnly,
|
|
toolVariant: p.toolVariant === "full" ? "full" : "slim",
|
|
localLlmModel: (typeof p.localLlmModel === "string" && p.localLlmModel) ? p.localLlmModel : "qwen3-8b",
|
|
};
|
|
} catch {
|
|
return { enabled: false, localOnly: false, toolVariant: "slim", localLlmModel: "qwen3-8b" };
|
|
}
|
|
}
|
|
function writeLocalLlmConfig(patch) {
|
|
const cur = readLocalLlmConfig();
|
|
if (typeof patch.enabled === "boolean") cur.enabled = patch.enabled;
|
|
if (typeof patch.localOnly === "boolean") cur.localOnly = patch.localOnly;
|
|
if (patch.toolVariant === "slim" || patch.toolVariant === "full") cur.toolVariant = patch.toolVariant;
|
|
if (typeof patch.localLlmModel === "string" && patch.localLlmModel.trim()) cur.localLlmModel = patch.localLlmModel.trim();
|
|
fs.mkdirSync("/shared/config", { recursive: true });
|
|
const tmp = LOCAL_LLM_CONFIG_FILE + ".tmp";
|
|
fs.writeFileSync(tmp, JSON.stringify(cur, null, 2));
|
|
fs.renameSync(tmp, LOCAL_LLM_CONFIG_FILE);
|
|
return cur;
|
|
}
|
|
|
|
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
|
|
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
|
|
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
|
|
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
|
|
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
|
|
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
|
|
function readJellyfinConfig() {
|
|
try {
|
|
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
|
|
const servers = Array.isArray(p.servers) ? p.servers : [];
|
|
return {
|
|
servers: servers.map(s => ({
|
|
id: String(s.id || ""),
|
|
name: String(s.name || ""),
|
|
url: String(s.url || ""),
|
|
apiKey: String(s.apiKey || ""),
|
|
isDefault: !!s.isDefault,
|
|
})).filter(s => s.id && s.url),
|
|
};
|
|
} catch {
|
|
return { servers: [] };
|
|
}
|
|
}
|
|
function writeJellyfinConfig(cfg) {
|
|
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
|
|
.map(s => ({
|
|
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
|
|
name: String(s.name || "").trim(),
|
|
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
|
|
apiKey: String(s.apiKey || "").trim(),
|
|
isDefault: !!s.isDefault,
|
|
}))
|
|
.filter(s => s.url);
|
|
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
|
|
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
|
|
let seen = false;
|
|
for (const s of servers) {
|
|
if (s.isDefault && seen) s.isDefault = false;
|
|
if (s.isDefault) seen = true;
|
|
}
|
|
fs.mkdirSync("/shared/config", { recursive: true });
|
|
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
|
|
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
|
|
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
|
|
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
|
|
return { servers };
|
|
}
|
|
|
|
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
|
|
function testJellyfin(url, apiKey) {
|
|
return new Promise((resolve) => {
|
|
let u;
|
|
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
|
|
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
|
|
const mod = u.protocol === "https:" ? require("https") : require("http");
|
|
const req = mod.request(u, {
|
|
method: "GET",
|
|
timeout: 8000,
|
|
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
|
|
}, (res) => {
|
|
let body = "";
|
|
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
|
|
res.on("end", () => {
|
|
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
|
|
}
|
|
try {
|
|
const j = JSON.parse(body);
|
|
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
|
|
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
|
|
});
|
|
});
|
|
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
|
|
req.on("error", (e) => resolve({ ok: false, error: e.message }));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
|
|
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
|
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
|
const LOCAL_MODELS_FILE = "/shared/config/local_models.json";
|
|
const DEFAULT_LOCAL_MODELS = [
|
|
{ id: "qwen3-8b", display_name: "Qwen3 8B (Standard)", description: "Bestes Tool-Calling, ~6 GB. Passt auf 12 GB." },
|
|
{ id: "qwen3-4b", display_name: "Qwen3 4B (schneller)", description: "Kleiner + flotter, ~3 GB. Etwas schwaecher." },
|
|
];
|
|
function loadLocalModels() {
|
|
try {
|
|
const arr = JSON.parse(fs.readFileSync(LOCAL_MODELS_FILE, "utf-8"));
|
|
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) return arr;
|
|
} catch {}
|
|
// Seed defaults
|
|
try {
|
|
fs.mkdirSync("/shared/config", { recursive: true });
|
|
fs.writeFileSync(LOCAL_MODELS_FILE, JSON.stringify(DEFAULT_LOCAL_MODELS, null, 2));
|
|
} catch {}
|
|
return DEFAULT_LOCAL_MODELS;
|
|
}
|
|
|
|
// ── LLM-Modell-Katalog (Stage D): herunterladbare GGUF-Modelle ───────
|
|
// /shared/config/llm_catalog.json — kuratierte Liste guter GGUF-Modelle plus
|
|
// per HuggingFace-Refresh nachgeladene. Der llm-adapter zieht ein Modell via
|
|
// -hf beim ersten Load. { id(key), hfRepo, quant, sizeGB, description, source }.
|
|
const LLM_CATALOG_FILE = "/shared/config/llm_catalog.json";
|
|
const DEFAULT_LLM_CATALOG = [
|
|
{ id: "qwen3-8b", hfRepo: "Qwen/Qwen3-8B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 6, description: "Bestes Tool-Calling, passt auf 12 GB.", source: "curated" },
|
|
{ id: "qwen3-4b", hfRepo: "Qwen/Qwen3-4B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 3, description: "Kleiner + flotter, etwas schwaecher.", source: "curated" },
|
|
{ id: "qwen3-14b", hfRepo: "Qwen/Qwen3-14B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 10, description: "Staerker, braucht mehr VRAM (~16 GB).", source: "curated" },
|
|
{ id: "llama-3.1-8b", hfRepo: "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 5, description: "Llama 3.1 8B Instruct.", source: "curated" },
|
|
{ id: "mistral-small-3", hfRepo: "bartowski/Mistral-Small-24B-Instruct-2501-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 14, description: "Mistral Small 24B — stark, viel VRAM.", source: "curated" },
|
|
{ id: "gemma-2-9b", hfRepo: "bartowski/gemma-2-9b-it-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 6, description: "Google Gemma 2 9B Instruct.", source: "curated" },
|
|
];
|
|
function loadLlmCatalog() {
|
|
try {
|
|
const arr = JSON.parse(fs.readFileSync(LLM_CATALOG_FILE, "utf-8"));
|
|
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) return arr;
|
|
} catch {}
|
|
try {
|
|
fs.mkdirSync("/shared/config", { recursive: true });
|
|
fs.writeFileSync(LLM_CATALOG_FILE, JSON.stringify(DEFAULT_LLM_CATALOG, null, 2));
|
|
} catch {}
|
|
return DEFAULT_LLM_CATALOG;
|
|
}
|
|
function saveLlmCatalog(arr) {
|
|
try {
|
|
fs.mkdirSync("/shared/config", { recursive: true });
|
|
const tmp = LLM_CATALOG_FILE + ".tmp";
|
|
fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
|
|
fs.renameSync(tmp, LLM_CATALOG_FILE);
|
|
return true;
|
|
} catch (e) { log("warn", "llm", `Katalog speichern fehlgeschlagen: ${e.message}`); return false; }
|
|
}
|
|
function slugModelId(repo) {
|
|
return String(repo).toLowerCase().replace(/^.*\//, "").replace(/-gguf$/,"").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model";
|
|
}
|
|
// Holt populaere GGUF-Modelle von der HuggingFace-API und merged sie in den
|
|
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten).
|
|
async function refreshLlmCatalogFromHF() {
|
|
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
|
|
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
|
if (!r.ok) throw new Error(`HF API ${r.status}`);
|
|
const list = await r.json();
|
|
const existing = loadLlmCatalog();
|
|
const byId = new Map(existing.map(m => [m.id, m]));
|
|
let added = 0;
|
|
for (const m of (Array.isArray(list) ? list : [])) {
|
|
const repo = m.id || m.modelId;
|
|
if (!repo || !/gguf/i.test(repo)) continue;
|
|
const id = slugModelId(repo);
|
|
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
|
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
|
|
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf" };
|
|
byId.set(id, entry); added++;
|
|
}
|
|
const merged = Array.from(byId.values());
|
|
saveLlmCatalog(merged);
|
|
return { models: merged, added };
|
|
}
|
|
|
|
// ── File-Project-Manifest ───────────────────────────────────────────
|
|
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
|
|
// Wird vom files-list-Endpoint + files-set-project gepflegt.
|
|
const FILE_PROJECTS_FILE = "/shared/config/file_projects.json";
|
|
|
|
function loadFileProjects() {
|
|
try {
|
|
if (!fs.existsSync(FILE_PROJECTS_FILE)) return {};
|
|
const data = JSON.parse(fs.readFileSync(FILE_PROJECTS_FILE, "utf-8"));
|
|
return (data && typeof data === "object") ? data : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveFileProjects(manifest) {
|
|
try {
|
|
fs.mkdirSync("/shared/config", { recursive: true });
|
|
const tmp = FILE_PROJECTS_FILE + ".tmp";
|
|
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
|
|
fs.renameSync(tmp, FILE_PROJECTS_FILE);
|
|
} catch (err) {
|
|
log("warn", "files", `file-projects-Manifest schreiben fehlgeschlagen: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// ── Anzeigenamen fuer Satelliten + Host-Agenten ──────────────────
|
|
//
|
|
// Ein Agent/Satellit nennt sich selbst so, wie es in SEINER .env steht.
|
|
// Umbenennen hiesse also: auf den Rechner, Datei aendern, neu starten — beim
|
|
// Fire TV sogar neu provisionieren. Darum ein Alias, der hier zentral liegt.
|
|
//
|
|
// Dieselbe Datei nutzt auch die Bridge (sie liegt im geteilten /shared), und
|
|
// die loest den Alias beim Adressieren wieder auf. Sonst koennte man etwas
|
|
// umbenennen, es danach aber nicht mehr ansprechen.
|
|
const DISPLAY_NAMES_FILE = "/shared/config/display_names.json";
|
|
|
|
function readDisplayNames() {
|
|
try {
|
|
const d = JSON.parse(fs.readFileSync(DISPLAY_NAMES_FILE, "utf-8"));
|
|
return (d && typeof d === "object") ? d : {};
|
|
} catch { return {}; }
|
|
}
|
|
|
|
function setDisplayName(kind, id, name) {
|
|
if (!["host", "satellite"].includes(kind)) throw new Error("kind muss host oder satellite sein");
|
|
if (!id) throw new Error("id ist Pflicht");
|
|
const data = readDisplayNames();
|
|
const sub = (data[kind] && typeof data[kind] === "object") ? data[kind] : {};
|
|
const clean = String(name || "").trim().slice(0, 60);
|
|
if (clean) sub[id] = clean; else delete sub[id];
|
|
data[kind] = sub;
|
|
fs.mkdirSync("/shared/config", { recursive: true });
|
|
const tmp = DISPLAY_NAMES_FILE + ".tmp";
|
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
fs.renameSync(tmp, DISPLAY_NAMES_FILE);
|
|
log("info", "server", `Anzeigename ${kind} ${id} -> ${clean || "(entfernt)"}`);
|
|
return clean;
|
|
}
|
|
|
|
// ── Satelliten-Registry (Aussenposten in fremden Netzen) ──────────
|
|
const satellites = new Map(); // id → {id, location, caps, control, last_seen}
|
|
|
|
function satelliteList() {
|
|
const now = Date.now();
|
|
const alias = readDisplayNames().satellite || {};
|
|
return Array.from(satellites.values()).map(s => ({
|
|
id: s.id, location: alias[s.id] || s.location,
|
|
agentName: s.location, alias: alias[s.id] || "",
|
|
caps: s.caps, control: s.control, net: s.net || null,
|
|
online: (now - (s.last_seen || 0)) < 300000,
|
|
}));
|
|
}
|
|
|
|
function broadcastSatellites() {
|
|
broadcast({ type: "sat_update", satellites: satelliteList() });
|
|
}
|
|
|
|
// ── Host-Agenten: Direktzugriff auf einen Rechner ────────────────
|
|
const hosts = new Map(); // hostId → {hostId, name, os, caps, control, last_seen}
|
|
|
|
function hostList() {
|
|
const now = Date.now();
|
|
const alias = readDisplayNames().host || {};
|
|
return Array.from(hosts.values()).map(h => ({
|
|
hostId: h.hostId, name: alias[h.hostId] || h.name,
|
|
agentName: h.name, alias: alias[h.hostId] || "",
|
|
os: h.os, caps: h.caps, control: h.control,
|
|
ip: h.ip || "", online: (now - (h.last_seen || 0)) < 300000,
|
|
}));
|
|
}
|
|
|
|
function broadcastHosts() {
|
|
broadcast({ type: "host_update", hosts: hostList() });
|
|
}
|
|
|
|
// ── Compute-Fleet: GPU-Worker (voxtral/whisper/f5tts/llm) ──────────
|
|
// Worker melden sich per worker_hello + halten sich per worker_ping (busy) frisch.
|
|
const workers = new Map(); // instanceId → {instanceId, service, node, gpus, model, busy, last_seen}
|
|
const WORKER_OFFLINE_MS = 35000; // ping ~10s; nach 35s ohne Ping = offline
|
|
|
|
function workerList() {
|
|
const now = Date.now();
|
|
return Array.from(workers.values()).map(w => ({
|
|
instanceId: w.instanceId, service: w.service, node: w.node,
|
|
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
|
|
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
|
|
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
|
|
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
|
}));
|
|
}
|
|
|
|
function broadcastWorkers() {
|
|
broadcast({ type: "worker_update", workers: workerList() });
|
|
}
|
|
|
|
// ── Voice-Flotte: zentraler Stimmen-Store + Auto-Provisioning ──────
|
|
// Der Diagnostic-Server ist der "Stimmen-Bibliothekar": Stimmen liegen
|
|
// zentral als /shared/voices/{name}.tar.gz (das f5tts-Export-Artefakt =
|
|
// wav+txt). Meldet sich eine f5tts-Box (worker_hello), gleichen wir ab und
|
|
// schieben ihr fehlende Stimmen (xtts_import_voice, targetInstance) bzw. ziehen
|
|
// bei ihr vorhandene, zentral fehlende Stimmen (xtts_export_voice) in den Store.
|
|
const CENTRAL_VOICES_DIR = "/shared/voices";
|
|
const centralExportPending = new Map(); // requestId -> name (unsere eigenen Export-Anfragen)
|
|
|
|
function ensureCentralVoicesDir() {
|
|
try { fs.mkdirSync(CENTRAL_VOICES_DIR, { recursive: true }); } catch (_) {}
|
|
}
|
|
function centralVoiceNames() {
|
|
ensureCentralVoicesDir();
|
|
try {
|
|
return fs.readdirSync(CENTRAL_VOICES_DIR)
|
|
.filter(f => f.endsWith(".tar.gz"))
|
|
.map(f => f.slice(0, -7));
|
|
} catch (_) { return []; }
|
|
}
|
|
function readCentralVoiceB64(name) {
|
|
try { return fs.readFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`).toString("base64"); }
|
|
catch (_) { return null; }
|
|
}
|
|
function writeCentralVoice(name, dataB64) {
|
|
ensureCentralVoicesDir();
|
|
try { fs.writeFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`, Buffer.from(dataB64, "base64")); return true; }
|
|
catch (e) { log("warn", "voice", `zentral schreiben ${name} fehlgeschlagen: ${e.message}`); return false; }
|
|
}
|
|
function deleteCentralVoice(name) {
|
|
try { fs.unlinkSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`); log("info", "voice", `zentral geloescht: ${name}`); }
|
|
catch (_) {}
|
|
}
|
|
function requestCentralExport(name) {
|
|
// Broadcast-Export-Anfrage; die Box, die die Stimme hat, antwortet. Wir
|
|
// korrelieren die Antwort ueber requestId (nur unsere eigenen verarbeiten).
|
|
const requestId = "central_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
|
|
centralExportPending.set(requestId, name);
|
|
setTimeout(() => centralExportPending.delete(requestId), 30000);
|
|
sendToRVS_raw({ type: "xtts_export_voice", payload: { name, requestId }, timestamp: Date.now() });
|
|
}
|
|
function provisionVoiceToInstance(name, instanceId) {
|
|
const data = readCentralVoiceB64(name);
|
|
if (!data) return;
|
|
sendToRVS_raw({ type: "xtts_import_voice",
|
|
payload: { name, data, targetInstance: instanceId }, timestamp: Date.now() });
|
|
log("info", "voice", `provisioniere '${name}' → ${instanceId}`);
|
|
}
|
|
// Abgleich beim worker_hello einer f5tts-Box: push (zentral→Box) + pull (Box→zentral, seed).
|
|
function reconcileVoices(instanceId, boxVoices) {
|
|
const central = centralVoiceNames();
|
|
const boxSet = new Set(Array.isArray(boxVoices) ? boxVoices : []);
|
|
const centralSet = new Set(central);
|
|
for (const name of central) if (!boxSet.has(name)) provisionVoiceToInstance(name, instanceId);
|
|
for (const name of boxSet) if (!centralSet.has(name)) requestCentralExport(name);
|
|
log("info", "voice", `reconcile ${instanceId}: box=${boxSet.size} central=${central.length}`);
|
|
}
|
|
|
|
// ── 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();
|
|
// KEIN chat_backup-Write mehr hier: die Bridge (_process_core_response)
|
|
// ist der massgebliche Writer und schreibt den Assistant-Eintrag MIT
|
|
// project_id. Dieser Gateway-Watch-Pfad kennt die project_id nicht —
|
|
// ein Write hier erzeugte ein untagged Duplikat, das beim Reload im
|
|
// Hauptchat auftaucht (statt im Projekt).
|
|
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) {
|
|
// OpenClaw-Gateway ist raus — Brain via Bridge via RVS ist die einzige
|
|
// Route. Wir loggen nichts mehr; alte Trace-Aufrufe schliessen wir clean.
|
|
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
|
|
if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
|
|
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 === "project_changed") {
|
|
// Ein Projekt wurde geaendert (ARIA-Tool, App-Verstecken, …) → an die
|
|
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
|
|
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
|
|
broadcast({ type: "project_changed", payload: msg.payload || {} });
|
|
} else if (msg.type === "sat_hello") {
|
|
// Ein Satellit (Aussenposten in einem fremden Netz) meldet sich.
|
|
const p = msg.payload || {};
|
|
if (p.id) {
|
|
satellites.set(p.id, {
|
|
id: p.id, location: p.location || p.id,
|
|
caps: p.caps || [], control: !!p.control, net: p.net || null,
|
|
last_seen: Date.now(),
|
|
});
|
|
broadcastSatellites();
|
|
}
|
|
} else if (msg.type === "host_hello") {
|
|
// Ein Host-Agent (Direktzugriff auf einen Rechner) meldet sich.
|
|
const p = msg.payload || {};
|
|
if (p.hostId) {
|
|
const wasKnown = hosts.has(p.hostId);
|
|
hosts.set(p.hostId, {
|
|
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
|
|
caps: p.caps || [], control: !!p.control, ip: p.ip || "",
|
|
last_seen: Date.now(),
|
|
});
|
|
if (!wasKnown) broadcastHosts();
|
|
else hosts.get(p.hostId).last_seen = Date.now();
|
|
}
|
|
} else if (msg.type === "host_ping") {
|
|
const p = msg.payload || {};
|
|
if (p.hostId && hosts.has(p.hostId)) hosts.get(p.hostId).last_seen = Date.now();
|
|
} else if (msg.type === "rooms_info") {
|
|
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
|
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
|
} else if (msg.type === "llm_openai_config_result") {
|
|
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
|
} else if (msg.type === "sat_result") {
|
|
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
|
broadcast({ type: "sat_result", payload: msg.payload || {} });
|
|
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
|
|
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
|
|
// der Bridge → an den Browser (Agent-Historie).
|
|
broadcast({ type: msg.type, payload: msg.payload || {} });
|
|
} else if (msg.type === "sat_devices") {
|
|
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
|
const p = msg.payload || {};
|
|
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
|
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
|
location: p.location || "", devices: p.devices || [] });
|
|
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
|
|
// Credential-Store-Antworten eines Satelliten → an den Browser.
|
|
broadcast({ type: msg.type, payload: msg.payload || {} });
|
|
} else if (msg.type === "worker_hello") {
|
|
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
|
|
const p = msg.payload || {};
|
|
if (p.instanceId) {
|
|
const prev = workers.get(p.instanceId) || {};
|
|
// War die Box vor diesem hello schon frisch gesehen? worker_hello wird
|
|
// jetzt alle ~30s wiederholt — Reconcile nur beim ERSTEN/erneuten
|
|
// Auftauchen, nicht bei jedem Resend.
|
|
const wasFresh = prev.last_seen && (Date.now() - prev.last_seen < WORKER_OFFLINE_MS);
|
|
workers.set(p.instanceId, {
|
|
instanceId: p.instanceId, service: p.service || "",
|
|
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
|
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
|
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
|
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
|
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
|
openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
|
|
openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
|
|
busy: !!prev.busy, last_seen: Date.now(),
|
|
});
|
|
broadcastWorkers();
|
|
// Voice-Flotte: f5tts-Box NEU online → Stimmen abgleichen/provisionieren.
|
|
if ((p.service || "") === "f5tts" && !wasFresh) reconcileVoices(p.instanceId, p.voices);
|
|
}
|
|
} else if (msg.type === "worker_ping") {
|
|
// Heartbeat eines Workers (traegt busy-Status).
|
|
const p = msg.payload || {};
|
|
if (p.instanceId) {
|
|
let w = workers.get(p.instanceId);
|
|
if (!w) {
|
|
const svc = String(p.instanceId).split("@")[0];
|
|
w = { instanceId: p.instanceId, service: svc, node: "", gpus: "", model: "", models: [], busy: false, last_seen: 0 };
|
|
workers.set(p.instanceId, w);
|
|
}
|
|
w.busy = !!p.busy;
|
|
w.last_seen = Date.now();
|
|
broadcastWorkers();
|
|
}
|
|
} else if (msg.type === "xtts_voice_saved") {
|
|
// Neue Stimme (App- ODER Diagnostic-Upload, via RVS-Broadcast) → zentral
|
|
// sichern. Online-Boxen haben sie durch den voice_upload-Broadcast schon;
|
|
// der zentrale Store macht sie persistent + fuer spaeter joinende Boxen
|
|
// verfuegbar (die holt dann reconcileVoices ab).
|
|
const p = msg.payload || {};
|
|
if (p.name && !p.error) {
|
|
log("info", "voice", `Stimme '${p.name}' gespeichert → zentraler Ingest`);
|
|
requestCentralExport(p.name);
|
|
}
|
|
} else if (msg.type === "xtts_voice_exported") {
|
|
// Antwort auf eine UNSERER zentralen Export-Anfragen (requestId-Match) →
|
|
// in den zentralen Store schreiben. Browser-initiierte Exports tragen
|
|
// keinen centralExportPending-requestId und werden hier ignoriert.
|
|
const p = msg.payload || {};
|
|
const rid = p.requestId || "";
|
|
if (rid && centralExportPending.has(rid)) {
|
|
centralExportPending.delete(rid);
|
|
if (p.ok && p.name && p.data) writeCentralVoice(p.name, p.data);
|
|
}
|
|
} else if (msg.type === "xtts_delete_voice") {
|
|
// App-initiierter Delete (Broadcast) → zentrale Kopie mitloeschen.
|
|
const p = msg.payload || {};
|
|
if (p.name) deleteCentralVoice(p.name);
|
|
} else if (msg.type === "agent_activity") {
|
|
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
|
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
|
// Wenn gerade ein chat:final vorbei ist, unterdruecken wir trailing
|
|
// 'thinking'-Events (gleiches Schema wie alter OpenClaw-Pfad).
|
|
const activity = msg.payload?.activity || msg.activity || "idle";
|
|
if (activity !== "idle" && Date.now() - lastChatFinalAt < SETTLED_WINDOW_MS) {
|
|
// chat:final ist gerade durch — verstaubende thinking-Events ignorieren
|
|
} else {
|
|
broadcast({
|
|
type: "agent_activity",
|
|
activity,
|
|
tool: msg.payload?.tool || msg.tool || "",
|
|
});
|
|
}
|
|
} else if (msg.type === "agent_stream") {
|
|
// Voller Live-Stream der Claude-Code-Session (assistant_text +
|
|
// tool_use mit Input + tool_result mit truncated Output). Geht
|
|
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
|
|
// Zusaetzlich persistieren damit Browser-Reload / Standby den
|
|
// History-Verlauf nicht wegwirft.
|
|
try { appendAgentStream(msg.payload); } catch {}
|
|
broadcast({ type: "agent_stream", payload: msg.payload });
|
|
} else if (msg.type === "memory_saved") {
|
|
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
|
const m = msg.payload || {};
|
|
log("info", "rvs", `ARIA-Memory gespeichert: "${m.title}" (type=${m.type}, pinned=${m.pinned})`);
|
|
broadcast({ type: "memory_saved", payload: m });
|
|
} else if (msg.type === "chat_message_deleted") {
|
|
// Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt.
|
|
// An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen.
|
|
const ts = msg.payload?.ts;
|
|
log("info", "rvs", `chat_message_deleted ts=${ts}`);
|
|
broadcast({ type: "chat_message_deleted", 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") {
|
|
// AI-Box-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 === "llm_provision_result") {
|
|
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
|
|
broadcast({ type: "llm_provision_result", payload: msg.payload || {} });
|
|
} else if (msg.type === "node_stats" || msg.type === "node_stats_history" || msg.type === "node_stats_reset_done") {
|
|
// Auslastungs-Monitor (Stage E): Box-Antworten an die Browser durchreichen.
|
|
broadcast({ type: msg.type, 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 nur bei wirklichen TLS/Handshake-Fehlern.
|
|
// Bei Netz-Problemen wie EHOSTUNREACH, ECONNREFUSED, ENETUNREACH,
|
|
// EAI_AGAIN ist der Server eh tot — Fallback bringt nichts ausser
|
|
// Log-Spam und doppelten Retries.
|
|
const netErr = (err.code || err.message || "").toString();
|
|
const isNetDown =
|
|
/^(EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)$/.test(netErr) ||
|
|
/EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/.test(err.message || "");
|
|
|
|
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered && !isNetDown) {
|
|
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, timeoutMs = 15000) {
|
|
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() }));
|
|
}, timeoutMs);
|
|
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, projectId) {
|
|
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
|
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
|
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
|
// Diagnostic die Bubble live spiegeln koennen.
|
|
//
|
|
// projectId (Multi-Threading 06/2026): optional — leerer/undefined String
|
|
// = Hauptchat, sonst project_id. Bridge liest payload.projectId und routet
|
|
// an /chat body.project_id — Brain queued per Kontext.
|
|
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
|
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
|
return false;
|
|
}
|
|
sendToRVS_raw({
|
|
type: "chat",
|
|
payload: {
|
|
text,
|
|
sender: "diagnostic",
|
|
projectId: projectId || "",
|
|
},
|
|
timestamp: Date.now(),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// ── 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();
|
|
});
|
|
}
|
|
|
|
// POST gegen die Docker-Daemon-API (via gemountetem Socket). Fuer prune-
|
|
// Endpoints — die geben SpaceReclaimed (Bytes) zurueck.
|
|
function dockerApiPost(apiPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request({
|
|
socketPath: "/var/run/docker.sock",
|
|
path: apiPath,
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Content-Length": 0 },
|
|
}, (res) => {
|
|
let data = "";
|
|
res.on("data", (c) => data += c);
|
|
res.on("end", () => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
try { resolve(JSON.parse(data || "{}")); } catch { resolve({}); }
|
|
} else {
|
|
reject(new Error(`Docker API ${apiPath}: HTTP ${res.statusCode} — ${String(data).slice(0, 200)}`));
|
|
}
|
|
});
|
|
});
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// "Sicher aufraeumen": Build-Cache + ungenutzte Images prunen — OHNE Volumes
|
|
// (keine Daten weg). "aggressive": zusaetzlich gestoppte Container + ungenutzte
|
|
// Volumes (kann Daten kosten → nur auf ausdrueckliche Wahl). Fuehrt es WIRKLICH
|
|
// aus (frueher kopierte der Button nur den Befehl in die Zwischenablage).
|
|
async function handleDiskCleanup(clientWs, variant) {
|
|
const aggressive = variant === "aggressive";
|
|
const send = (o) => { try { clientWs.send(JSON.stringify(o)); } catch (_) {} };
|
|
send({ type: "disk_cleanup", status: "running", variant });
|
|
log("warn", "server", `Disk-Cleanup gestartet (${aggressive ? "aggressive" : "safe"})`);
|
|
try {
|
|
let reclaimed = 0;
|
|
const steps = [];
|
|
const bp = await dockerApiPost("/build/prune?all=true");
|
|
reclaimed += (bp.SpaceReclaimed || 0);
|
|
steps.push("Build-Cache");
|
|
// dangling=false → ALLE ungenutzten Images (nicht nur dangling).
|
|
// Docker-API-Filterformat: map[string][]string.
|
|
const imgFilter = encodeURIComponent(JSON.stringify({ dangling: ["false"] }));
|
|
const ip = await dockerApiPost("/images/prune?filters=" + imgFilter);
|
|
reclaimed += (ip.SpaceReclaimed || 0);
|
|
steps.push("ungenutzte Images");
|
|
if (aggressive) {
|
|
const cp = await dockerApiPost("/containers/prune");
|
|
reclaimed += (cp.SpaceReclaimed || 0);
|
|
steps.push("gestoppte Container");
|
|
const vp = await dockerApiPost("/volumes/prune");
|
|
reclaimed += (vp.SpaceReclaimed || 0);
|
|
steps.push("ungenutzte Volumes");
|
|
}
|
|
const mb = (reclaimed / (1024 * 1024));
|
|
const freed = mb >= 1024 ? (mb / 1024).toFixed(2) + " GB" : mb.toFixed(0) + " MB";
|
|
log("info", "server", `Disk-Cleanup fertig: ${freed} frei (${steps.join(", ")})`);
|
|
send({ type: "disk_cleanup", status: "done", variant, reclaimedBytes: reclaimed, freed, steps });
|
|
} catch (err) {
|
|
log("error", "server", `Disk-Cleanup fehlgeschlagen: ${err.message}`);
|
|
send({ type: "disk_cleanup", status: "error", variant, error: String(err && err.message || err) });
|
|
}
|
|
}
|
|
|
|
// ── 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") {
|
|
// no-store: das Dashboard ist eine Single-HTML-App die bei jedem Deploy
|
|
// neue Inline-JS/CSS bekommt. Ohne Cache-Header servierte der Browser die
|
|
// alte Version trotz Reload (neue Features tauchten erst nach Hard-Reload
|
|
// auf) — genau das Symptom „ich seh den Button nicht".
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
"Pragma": "no-cache",
|
|
"Expires": "0",
|
|
});
|
|
res.end(fs.readFileSync(htmlPath, "utf-8"));
|
|
} else if (req.url.startsWith("/uploads/")) {
|
|
// Screenshots aus der Agent-Historie (nur /shared/uploads, kein Traversal).
|
|
const base = path.basename(decodeURIComponent(req.url.slice("/uploads/".length)));
|
|
const file = path.join("/shared/uploads", base);
|
|
if (!file.startsWith("/shared/uploads/") || !fs.existsSync(file)) {
|
|
res.writeHead(404); res.end("not found"); return;
|
|
}
|
|
const ext = path.extname(file).toLowerCase();
|
|
const ct = ext === ".png" ? "image/png"
|
|
: (ext === ".jpg" || ext === ".jpeg") ? "image/jpeg"
|
|
: ext === ".webp" ? "image/webp" : "application/octet-stream";
|
|
res.writeHead(200, { "Content-Type": ct, "Cache-Control": "max-age=86400" });
|
|
res.end(fs.readFileSync(file));
|
|
} 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/local-models-list" && req.method === "GET") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, models: loadLocalModels() }));
|
|
} else if (req.url === "/api/llm-catalog" && req.method === "GET") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, models: loadLlmCatalog() }));
|
|
} else if (req.url === "/api/llm-catalog/refresh" && req.method === "POST") {
|
|
refreshLlmCatalogFromHF()
|
|
.then(r => {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
|
|
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
|
|
})
|
|
.catch(err => {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
|
|
});
|
|
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(readLocalLlmConfig()));
|
|
} else if (req.url === "/api/local-llm-config" && req.method === "POST") {
|
|
let body = "";
|
|
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
|
|
req.on("end", () => {
|
|
try {
|
|
const cfg = writeLocalLlmConfig(JSON.parse(body));
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, config: cfg }));
|
|
log("info", "server", `Local-LLM-Config: enabled=${cfg.enabled} localOnly=${cfg.localOnly} tools=${cfg.toolVariant}`);
|
|
} catch (err) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
}
|
|
});
|
|
return;
|
|
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(readJellyfinConfig()));
|
|
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
|
|
let body = "";
|
|
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
|
|
req.on("end", () => {
|
|
try {
|
|
const cfg = writeJellyfinConfig(JSON.parse(body));
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, config: cfg }));
|
|
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
|
|
} catch (err) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
}
|
|
});
|
|
return;
|
|
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
|
|
let body = "";
|
|
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
|
|
req.on("end", async () => {
|
|
try {
|
|
const { url, apiKey } = JSON.parse(body);
|
|
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(r));
|
|
} 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.startsWith("/api/app-log") && req.method === "GET") {
|
|
// App-Crash-Reporting-Log lesen — die App schickt JS-Errors via RVS,
|
|
// Bridge schreibt JSONL nach /shared/logs/app.log. Wir liefern die
|
|
// letzten 200 Eintraege (oder ?limit=N).
|
|
const url = new URL(req.url, "http://x");
|
|
const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get("limit") || "200", 10) || 200));
|
|
try {
|
|
const file = "/shared/logs/app.log";
|
|
if (!fs.existsSync(file)) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ count: 0, entries: [] }));
|
|
return;
|
|
}
|
|
const raw = fs.readFileSync(file, "utf-8");
|
|
const lines = raw.split("\n").filter(l => l.trim());
|
|
const tail = lines.slice(-limit);
|
|
const entries = tail.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ count: entries.length, entries }));
|
|
} catch (err) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: err.message }));
|
|
}
|
|
return;
|
|
} else if (req.url === "/api/app-log/clear" && req.method === "POST") {
|
|
// App-Log leeren — nach erfolgreichem Debug.
|
|
try {
|
|
const file = "/shared/logs/app.log";
|
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
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.startsWith("/api/models-list") && req.method === "GET") {
|
|
// Kuratierte Model-Liste vom Proxy (/v1/models) — Tier-Auswahl fuers
|
|
// Sprachmodell-Dropdown. ARIA laeuft ueber das Max-Abo/CLI, waehlbar ist
|
|
// der Tier (opus/sonnet/haiku), keine feste Version.
|
|
(async () => {
|
|
try {
|
|
// ?refresh=1 weiterreichen: der Proxy umgeht damit seinen Katalog-Cache
|
|
// und holt die Liste frisch von Anthropic („↻ Aktualisieren").
|
|
const refresh = /[?&]refresh=1\b/.test(req.url) ? "?refresh=1" : "";
|
|
const r = await fetch(`${PROXY_URL}/v1/models${refresh}`);
|
|
const d = await r.json();
|
|
const models = (d.data || []).map(m => ({
|
|
id: m.id,
|
|
tier: m.tier || m.id,
|
|
displayName: m.display_name || m.id,
|
|
description: m.description || "",
|
|
// section: tier | main | overflow — die UI gruppiert danach.
|
|
section: m.section || "tier",
|
|
}));
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, models, catalogOk: d.catalog_ok !== false }));
|
|
} catch (err) {
|
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
|
|
}
|
|
})();
|
|
return;
|
|
} 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 manifest = loadFileProjects();
|
|
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_"),
|
|
projectId: manifest[full] || '',
|
|
};
|
|
} 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 === "/api/files-set-project" && req.method === "POST") {
|
|
// Body: { path, projectId } — projectId leer = Hauptchat (= Eintrag entfernen)
|
|
let body = "";
|
|
req.on("data", c => { body += c; if (body.length > 8192) req.destroy(); });
|
|
req.on("end", () => {
|
|
try {
|
|
const data = JSON.parse(body || "{}");
|
|
const fpath = String(data.path || "");
|
|
const pid = String(data.projectId || "");
|
|
if (!fpath.startsWith("/shared/uploads/") || !fs.existsSync(fpath)) {
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ ok: false, error: "Datei nicht gefunden" }));
|
|
}
|
|
const manifest = loadFileProjects();
|
|
if (pid) manifest[fpath] = pid;
|
|
else delete manifest[fpath];
|
|
saveFileProjects(manifest);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, path: fpath, projectId: pid }));
|
|
} 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.url.startsWith("/api/files-view?")) && req.method === "GET") {
|
|
// /api/files-download → mit Content-Disposition:attachment (Browser downloaded)
|
|
// /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab)
|
|
const isInline = req.url.startsWith("/api/files-view?");
|
|
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);
|
|
// Beim View-Modus echten MIME-Type setzen damit Browser inline rendert.
|
|
// Bei Download-Modus weiter octet-stream + attachment-Disposition.
|
|
const ext = path.extname(fname).toLowerCase();
|
|
const mimeMap = {
|
|
".pdf": "application/pdf",
|
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
|
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
|
".mp4": "video/mp4", ".webm": "video/webm",
|
|
".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8",
|
|
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
|
|
".json": "application/json; charset=utf-8", ".csv": "text/csv; charset=utf-8",
|
|
".zip": "application/zip",
|
|
};
|
|
const mime = isInline ? (mimeMap[ext] || "application/octet-stream")
|
|
: "application/octet-stream";
|
|
res.writeHead(200, {
|
|
"Content-Type": mime,
|
|
"Content-Length": stat.size,
|
|
"Content-Disposition": `${isInline ? "inline" : "attachment"}; filename="${fname}"`,
|
|
});
|
|
fs.createReadStream(safe).pipe(res);
|
|
return;
|
|
} else if (req.url === "/api/files-download-zip" && req.method === "POST") {
|
|
// Multi-Datei-Download als ZIP. Body: {paths: ["/shared/uploads/...", ...]}.
|
|
// Streamt zip stdout direkt in die Response.
|
|
let body = "";
|
|
req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
|
|
req.on("end", () => {
|
|
let paths = [];
|
|
try { paths = (JSON.parse(body || "{}").paths || []); } catch { paths = []; }
|
|
// Whitelist: nur /shared/uploads/, existieren muessen sie
|
|
paths = paths
|
|
.map(p => path.resolve(String(p)))
|
|
.filter(p => p.startsWith("/shared/uploads/") && fs.existsSync(p));
|
|
if (!paths.length) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: "Keine gueltigen Pfade" }));
|
|
return;
|
|
}
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
const fname = `aria-files-${ts}.zip`;
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/zip",
|
|
"Content-Disposition": `attachment; filename="${fname}"`,
|
|
});
|
|
// zip -j: junk paths (Dateien ohne Verzeichnisstruktur ablegen)
|
|
const { spawn } = require("child_process");
|
|
const zip = spawn("zip", ["-j", "-q", "-", ...paths]);
|
|
zip.stdout.pipe(res);
|
|
let stderr = "";
|
|
zip.stderr.on("data", d => stderr += d.toString());
|
|
zip.on("close", code => {
|
|
if (code !== 0 && code !== 12) {
|
|
log("error", "server", `zip exit ${code}: ${stderr.slice(0, 200)}`);
|
|
}
|
|
});
|
|
// SIGTERM an zip nur wenn der Client wirklich disconnected
|
|
// (res.close vor res.end). req.on("close") feuert auch wenn
|
|
// der Request-Body durch ist — das wuerde zip vorzeitig killen.
|
|
res.on("close", () => {
|
|
if (!res.writableEnded && !zip.killed) zip.kill("SIGTERM");
|
|
});
|
|
});
|
|
return;
|
|
} else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
|
|
let body = "";
|
|
req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
|
|
req.on("end", () => {
|
|
try {
|
|
let paths = (JSON.parse(body || "{}").paths || []);
|
|
paths = paths
|
|
.map(p => path.resolve(String(p)))
|
|
.filter(p => p.startsWith("/shared/uploads/"));
|
|
const deleted = [];
|
|
const errors = [];
|
|
for (const p of paths) {
|
|
try {
|
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
deleted.push(p);
|
|
broadcast({ type: "file_deleted", path: p });
|
|
sendToRVS_raw({ type: "file_deleted", payload: { path: p }, timestamp: Date.now() });
|
|
try {
|
|
fs.appendFileSync("/shared/config/chat_backup.jsonl",
|
|
JSON.stringify({ type: "file_deleted", path: p, ts: Date.now(), by: "user" }) + "\n");
|
|
} catch {}
|
|
} catch (e) {
|
|
errors.push({ path: p, error: e.message });
|
|
}
|
|
}
|
|
log("info", "server", `Bulk-Delete: ${deleted.length} OK, ${errors.length} Fehler`);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, deleted, errors }));
|
|
} catch (err) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
}
|
|
});
|
|
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.startsWith("/api/files-versions?") && req.method === "GET") {
|
|
// Liste der git-Versionen einer Datei. Query: ?path=<rel-to-uploads>
|
|
const u = new URL("http://x" + req.url);
|
|
const rel = u.searchParams.get("path") || "";
|
|
if (!isPathSafe(rel)) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad" }));
|
|
return;
|
|
}
|
|
listVersionsForFile(rel)
|
|
.then(versions => {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, path: rel, versions }));
|
|
})
|
|
.catch(err => {
|
|
log("warn", "server", `files-versions failed: ${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/files-version-content?") && req.method === "GET") {
|
|
// Inhalt einer alten Version downloaden. Query: ?path=...&hash=<sha>
|
|
const u = new URL("http://x" + req.url);
|
|
const rel = u.searchParams.get("path") || "";
|
|
const hash = u.searchParams.get("hash") || "";
|
|
if (!isPathSafe(rel) || !/^[0-9a-f]{7,40}$/i.test(hash)) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
|
|
return;
|
|
}
|
|
getVersionContent(rel, hash)
|
|
.then(content => {
|
|
const base = path.basename(rel);
|
|
const stem = base.replace(/(\.[^.]+)?$/, "");
|
|
const ext = path.extname(base);
|
|
const shortHash = hash.slice(0, 7);
|
|
const downloadName = `${stem}@${shortHash}${ext}`;
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/octet-stream",
|
|
"Content-Disposition": `attachment; filename="${downloadName}"`,
|
|
"Content-Length": content.length,
|
|
});
|
|
res.end(content);
|
|
})
|
|
.catch(err => {
|
|
log("warn", "server", `files-version-content failed: ${err.message}`);
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
});
|
|
return;
|
|
} else if (req.url === "/api/files-version-restore" && req.method === "POST") {
|
|
// Eine alte Version als neue aktive Version setzen — non-destructive,
|
|
// erzeugt einen neuen "restore:"-Commit. Body: {path, hash}
|
|
let body = "";
|
|
req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });
|
|
req.on("end", () => {
|
|
let p, h;
|
|
try {
|
|
const parsed = JSON.parse(body || "{}");
|
|
p = String(parsed.path || "");
|
|
h = String(parsed.hash || "");
|
|
} catch (e) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: "bad json" }));
|
|
return;
|
|
}
|
|
if (!isPathSafe(p) || !/^[0-9a-f]{7,40}$/i.test(h)) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
|
|
return;
|
|
}
|
|
restoreVersion(p, h)
|
|
.then(() => {
|
|
log("info", "server", `Version restored: ${p} <- ${h.slice(0,7)}`);
|
|
// Datei hat sich geaendert — Browser-Listen invalidieren
|
|
broadcast({ type: "file_version_restored", path: p, hash: h });
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, path: p, hash: h }));
|
|
})
|
|
.catch(err => {
|
|
log("warn", "server", `restore failed: ${err.message}`);
|
|
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/chat-history-clear" && req.method === "POST") {
|
|
// Leert die Diagnostic-Anzeige-History (chat_backup.jsonl) UND broadcastet
|
|
// chat_cleared an alle RVS-Clients (App leert lokal). Brain's
|
|
// Rolling-Window (conversation.jsonl) ist davon unabhaengig — Caller
|
|
// sollte zusaetzlich /api/brain/conversation/reset triggern.
|
|
log("warn", "server", "HTTP /api/chat-history-clear");
|
|
try {
|
|
const file = "/shared/config/chat_backup.jsonl";
|
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
// Browser-Clients: leere chat_history
|
|
broadcast({ type: "chat_history", messages: [] });
|
|
// App via RVS: chat_cleared
|
|
sendToRVS_raw({
|
|
type: "chat_cleared",
|
|
payload: { ts: Date.now() },
|
|
timestamp: Date.now(),
|
|
});
|
|
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/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/, "");
|
|
// Uploads brauchen laenger als die 30s default — Memory-Anhang-Endpoints
|
|
// koennen bis zu 20 MB tragen, plus chat/distill-Calls dauern manchmal
|
|
// mehr als eine Minute.
|
|
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
|
|
const timeout = isUpload ? 120000 : 60000;
|
|
// Projekt-Mutationen (create/switch/end/archive/patch inkl. hidden) sollen
|
|
// alle Clients live aktualisieren. Wir broadcasten nach Erfolg ein
|
|
// project_changed an RVS — App + andere Diagnostic-Tabs laden dann neu,
|
|
// ohne Seiten-Refresh (spiegelt das bestehende ARIA-project_changed-Event).
|
|
const isProjectMutation =
|
|
/^\/projects\b/.test(targetPath) &&
|
|
(req.method === "POST" || req.method === "PATCH" || req.method === "DELETE");
|
|
const proxyReq = http.request({
|
|
host: "aria-brain",
|
|
port: 8080,
|
|
path: targetPath,
|
|
method: req.method,
|
|
headers: req.headers,
|
|
timeout,
|
|
}, (proxyRes) => {
|
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
proxyRes.pipe(res);
|
|
if (isProjectMutation && proxyRes.statusCode >= 200 && proxyRes.statusCode < 300) {
|
|
try {
|
|
// An App + Bridge (RVS echot NICHT an den Sender) …
|
|
sendToRVS_raw({ type: "project_changed",
|
|
payload: { reason: "diagnostic" },
|
|
timestamp: Date.now() });
|
|
// … und an die eigenen Browser-Tabs (die haengen am Diag-Server, nicht
|
|
// direkt am RVS, kriegen den RVS-Broadcast also nicht).
|
|
broadcast({ type: "project_changed", payload: { reason: "diagnostic" } });
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
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.startsWith("/api/chat-backup") && req.method === "GET") {
|
|
// Tail des chat_backup.jsonl — fuer Debug-Sessions (was hat ARIA wirklich
|
|
// gesagt/getan). ?lines=N (Default 200, Max 5000).
|
|
try {
|
|
const u = new URL(req.url, "http://localhost");
|
|
const lines = Math.max(1, Math.min(5000, parseInt(u.searchParams.get("lines") || "200", 10) || 200));
|
|
const file = "/shared/config/chat_backup.jsonl";
|
|
let raw = "";
|
|
try { raw = fs.readFileSync(file, "utf-8"); } catch {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ ok: true, file, lines: [] }));
|
|
}
|
|
const all = raw.split("\n").filter(l => l.trim());
|
|
const tail = all.slice(-lines);
|
|
const parsed = tail.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ ok: true, file, count: parsed.length, total: all.length, lines: parsed }));
|
|
} catch (e) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
}
|
|
} else if (req.url.startsWith("/api/agent-stream") && req.method === "GET") {
|
|
// Tail / paginierter Slice des persistierten agent_stream.jsonl.
|
|
// Modi:
|
|
// ?lines=N → letzte N Zeilen (Live-View Initial-Load)
|
|
// ?page=P&perPage=M → 1-indexed Pagination (Modal-Browser);
|
|
// page=1 = neueste Seite, hoehere Pages = aelter
|
|
try {
|
|
const u = new URL(req.url, "http://localhost");
|
|
const linesParam = u.searchParams.get("lines");
|
|
const pageParam = u.searchParams.get("page");
|
|
const perPageParam = u.searchParams.get("perPage");
|
|
const file = AGENT_STREAM_LOG;
|
|
let raw = "";
|
|
try { raw = fs.readFileSync(file, "utf-8"); } catch {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ ok: true, file, total: 0, lines: [] }));
|
|
}
|
|
const all = raw.split("\n").filter(l => l.trim());
|
|
let slice, page = 1, perPage = 0, pagesTotal = 1;
|
|
if (pageParam || perPageParam) {
|
|
perPage = Math.max(10, Math.min(5000, parseInt(perPageParam || "100", 10) || 100));
|
|
pagesTotal = Math.max(1, Math.ceil(all.length / perPage));
|
|
page = Math.max(1, Math.min(pagesTotal, parseInt(pageParam || "1", 10) || 1));
|
|
// page=1 = juengste Seite → vom Ende her slicen
|
|
const end = all.length - (page - 1) * perPage;
|
|
const start = Math.max(0, end - perPage);
|
|
slice = all.slice(start, end);
|
|
} else {
|
|
const lines = Math.max(1, Math.min(5000, parseInt(linesParam || "200", 10) || 200));
|
|
slice = all.slice(-lines);
|
|
}
|
|
const parsed = slice.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({
|
|
ok: true, file, total: all.length, count: parsed.length,
|
|
page, perPage, pagesTotal, lines: parsed,
|
|
}));
|
|
} catch (e) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
}
|
|
} 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));
|
|
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
|
|
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
|
// Aktuell bekannte Host-Agenten mitgeben.
|
|
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
|
// Aktuell bekannte Compute-Worker mitgeben (RVS replayt worker_hello nicht).
|
|
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
|
|
|
ws.on("message", (raw) => {
|
|
try {
|
|
const msg = JSON.parse(raw.toString());
|
|
|
|
if (msg.action === "test_gateway") {
|
|
// Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
|
|
// alte Browser-Sessions die noch den Button anzeigen nicht stumm
|
|
// ins Leere klicken. Neue Versionen kennen den Button nicht mehr.
|
|
traceStart("RVS", msg.text || "aria lebst du noch?");
|
|
sendToRVS(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, msg.projectId || "");
|
|
} else if (msg.action === "interject") {
|
|
// Zwischenruf: in den laufenden Turn schieben (kein Abbruch, keine
|
|
// Queue) → RVS interject → Bridge → Proxy /interject.
|
|
const t = String(msg.text || "");
|
|
if (t.trim()) {
|
|
sendToRVS_raw({ type: "interject", payload: { projectId: msg.projectId || "", text: t }, timestamp: Date.now() });
|
|
log("info", "server", "Zwischenruf an RVS (project=" + (msg.projectId || "(main)") + "): " + t.slice(0, 60));
|
|
}
|
|
} else if (msg.action === "disk_cleanup") {
|
|
handleDiskCleanup(ws, msg.variant === "aggressive" ? "aggressive" : "safe");
|
|
} else if (msg.action === "reconnect_gateway") {
|
|
connectGateway();
|
|
} else if (msg.action === "reconnect_rvs") {
|
|
connectRVS();
|
|
} else if (msg.action === "sat_list") {
|
|
// Browser will die aktuelle Satelliten-Liste.
|
|
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
|
} else if (msg.action === "host_list") {
|
|
// Browser will die aktuelle Host-Agenten-Liste.
|
|
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
|
} else if (msg.action === "host_forget") {
|
|
// Alten/umbenannten Agenten aus der Liste werfen. Meldet er sich wieder
|
|
// (host_hello), taucht er automatisch neu auf — das ist also gefahrlos.
|
|
const id = String(msg.hostId || "");
|
|
if (id && hosts.delete(id)) {
|
|
log("info", "host", `Agent '${id}' aus der Liste entfernt`);
|
|
broadcastHosts();
|
|
}
|
|
} else if (msg.action === "rooms_query") {
|
|
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
|
|
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
|
|
sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() });
|
|
} else if (msg.action === "sat_command") {
|
|
// Browser loest eine Satelliten-Aktion aus (z.B. APK auf Fire TV ausrollen).
|
|
// Die Antwort (sat_result) kommt ueber die rvsWs zurueck.
|
|
const params = msg.params && typeof msg.params === "object" ? msg.params : {};
|
|
// Fern-Einrichtung: der RVS-Zugang wird SERVERSEITIG ergaenzt — so muss
|
|
// niemand Host/Token im Browser eintippen (und das Token steht nicht im
|
|
// Browser-Formular). Gilt fuer adb.provision und adb.install mit Setup.
|
|
if (params.provision === true) {
|
|
delete params.provision;
|
|
params.rvs_host = RVS_HOST;
|
|
params.rvs_port = Number(RVS_PORT) || 443;
|
|
params.rvs_tls = RVS_TLS === "true";
|
|
params.rvs_token = RVS_TOKEN;
|
|
}
|
|
sendToRVS_raw({
|
|
type: "sat_command",
|
|
payload: {
|
|
satellite: String(msg.satellite || ""),
|
|
device: String(msg.device || ""),
|
|
action: String(msg.cmd || ""),
|
|
params,
|
|
requestId: String(msg.requestId || ""),
|
|
},
|
|
timestamp: Date.now(),
|
|
});
|
|
} else if (msg.action === "llm_openai_config") {
|
|
// Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
|
|
sendToRVS_raw({
|
|
type: "llm_openai_config",
|
|
payload: {
|
|
instanceId: String(msg.instanceId || ""),
|
|
apiKey: String(msg.apiKey || ""),
|
|
requestId: String(msg.requestId || ""),
|
|
},
|
|
timestamp: Date.now(),
|
|
});
|
|
} else if (msg.action === "agent_history_query") {
|
|
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
|
// antwortet mit agent_history_list (auf derselben rvsWs).
|
|
sendToRVS_raw({ type: "agent_history_query",
|
|
payload: { host: String(msg.host || "") }, timestamp: Date.now() });
|
|
} else if (msg.action === "worker_list") {
|
|
// Browser will die aktuelle Compute-Flotte.
|
|
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
|
} else if (msg.action === "sat_discover") {
|
|
// Browser triggert einen Geraete-Scan auf einem Satelliten.
|
|
sendToRVS_raw({ type: "sat_discover",
|
|
payload: { satellite: msg.satellite || "", force: true },
|
|
timestamp: Date.now() });
|
|
} else if (msg.action === "set_display_name") {
|
|
// Anzeigename fuer einen Satelliten oder Host-Agenten.
|
|
try {
|
|
setDisplayName(msg.kind, msg.id, msg.name);
|
|
if (msg.kind === "satellite") broadcastSatellites(); else broadcastHosts();
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({ type: "log", level: "error", source: "server",
|
|
message: "Umbenennen: " + err.message }));
|
|
}
|
|
} else if (msg.action === "sat_name_set") {
|
|
// Geraetename vergeben/entfernen. Der Satellit haengt ihn an die MAC,
|
|
// damit ein IP-Wechsel den Namen nicht verliert.
|
|
sendToRVS_raw({ type: "sat_name_set",
|
|
payload: { satellite: msg.satellite || "", mac: msg.mac || "",
|
|
ip: msg.ip || "", name: msg.name || "",
|
|
requestId: "dn_" + Date.now() },
|
|
timestamp: Date.now() });
|
|
} else if (msg.action === "sat_creds_list") {
|
|
sendToRVS_raw({ type: "sat_creds_list",
|
|
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
|
|
timestamp: Date.now() });
|
|
} else if (msg.action === "sat_creds_set") {
|
|
sendToRVS_raw({ type: "sat_creds_set",
|
|
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
|
|
creds: msg.creds || {}, requestId: "dc_" + Date.now() },
|
|
timestamp: Date.now() });
|
|
} else if (msg.action === "sat_creds_delete") {
|
|
sendToRVS_raw({ type: "sat_creds_delete",
|
|
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
|
|
type: msg.credType || "", requestId: "dc_" + Date.now() },
|
|
timestamp: Date.now() });
|
|
} 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, projectId: msg.projectId || "" },
|
|
timestamp: Date.now(),
|
|
});
|
|
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
|
} else if (msg.action === "cancel_request") {
|
|
// Laufende Anfrage abbrechen — ECHTER Cancel: RVS cancel_request (hard)
|
|
// an die Bridge, die den Proxy-/cancel-all Side-Channel anruft und den
|
|
// laufenden claude-Subprozess killt. Das alte `openclaw doctor --fix`
|
|
// zielte auf den Container aria-core, den es nicht mehr gibt — es
|
|
// beendete den Run nie (ARIA lief munter weiter).
|
|
log("warn", "server", "Anfrage abgebrochen — cancel_request (hard) an Bridge/Proxy");
|
|
pendingMessageTime = 0;
|
|
watchdogWarned = false;
|
|
watchdogFixAttempted = false;
|
|
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
|
broadcast({ type: "agent_activity", activity: "idle" });
|
|
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-cancel" }, timestamp: Date.now() });
|
|
} else if (msg.action === "aria_panic_stop") {
|
|
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
|
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
|
log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
|
|
pendingMessageTime = 0;
|
|
watchdogWarned = false;
|
|
watchdogFixAttempted = false;
|
|
if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
|
|
broadcast({ type: "agent_activity", activity: "idle" });
|
|
// RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
|
|
// den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
|
|
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
|
|
} 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 alle f5tts-Boxen (Broadcast) + zentrale Kopie loeschen.
|
|
// (Der eigene Broadcast kommt nicht zu uns zurueck, daher hier direkt.)
|
|
sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
|
|
if (msg.name) deleteCentralVoice(msg.name);
|
|
log("info", "server", `Voice-Delete '${msg.name}' an f5tts-Boxen + zentral geloescht`);
|
|
} else if (msg.action === "delete_chat_message") {
|
|
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
|
|
// + broadcastet chat_message_deleted via RVS.
|
|
const ts = Number(msg.ts);
|
|
if (!Number.isFinite(ts)) {
|
|
ws.send(JSON.stringify({ type: "log", level: "error", source: "server",
|
|
message: `delete_chat_message: ungueltiges ts=${msg.ts}` }));
|
|
return;
|
|
}
|
|
sendToRVS_raw({ type: "delete_message_request", payload: { ts }, timestamp: Date.now() });
|
|
log("info", "server", `delete_message_request ts=${ts} an 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;
|
|
}
|
|
// FLUX-Settings (Default-Modell + User-Keywords). flux-bridge nutzt
|
|
// fluxDefaultModel zum Hot-Swap, Brain liest die Keywords direkt aus
|
|
// /shared/config/voice_config.json fuer den System-Prompt.
|
|
if (msg.fluxDefaultModel !== undefined) {
|
|
voiceConfig.fluxDefaultModel = (msg.fluxDefaultModel === "schnell") ? "schnell" : "dev";
|
|
}
|
|
if (msg.fluxKeywordRaw !== undefined) {
|
|
voiceConfig.fluxKeywordRaw = String(msg.fluxKeywordRaw || "").trim().toLowerCase() || "flux";
|
|
}
|
|
if (msg.fluxKeywordSwitch !== undefined) {
|
|
voiceConfig.fluxKeywordSwitch = String(msg.fluxKeywordSwitch || "").trim().toLowerCase() || "fix";
|
|
}
|
|
// HuggingFace-Token fuer gated FLUX.1-dev. Wird per RVS an die
|
|
// flux-bridge gepusht, dort als HF_TOKEN env gesetzt vor dem
|
|
// naechsten from_pretrained. Leerer String = "kein Token" (statt
|
|
// 'behalt was du hattest'), damit Stefan ihn auch wieder loeschen
|
|
// kann.
|
|
if (msg.huggingfaceToken !== undefined) {
|
|
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
|
|
}
|
|
// Voice-ID Match-Threshold (0.30-0.70). Wird von der whisper-bridge
|
|
// ueber den config-Broadcast aufgenommen — Phase 3 nutzt's beim Gating.
|
|
if (msg.voiceIdThreshold !== undefined && !isNaN(msg.voiceIdThreshold)) {
|
|
const t = parseFloat(msg.voiceIdThreshold);
|
|
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
|
}
|
|
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
|
|
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
|
|
// config-Broadcast; aus = gar keine Pruefung.
|
|
if (msg.voiceIdEnabled !== undefined) {
|
|
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
|
|
}
|
|
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 === "llm_provision_model") {
|
|
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
|
|
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
|
targetInstance: msg.targetInstance || "",
|
|
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
|
}, timestamp: Date.now() });
|
|
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
|
} else if (msg.action === "llm_remove_model") {
|
|
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
|
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
|
log("info", "llm", `remove '${msg.key}' → ${msg.targetInstance || "?"}`);
|
|
} else if (msg.action === "llm_test") {
|
|
// Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response).
|
|
const reqId = "diagtest_" + Date.now();
|
|
sendToRVS_withResponse("llm_request", {
|
|
requestId: reqId,
|
|
messages: [{ role: "user", content: String(msg.text || "Sag kurz Hallo.") }],
|
|
max_tokens: 256, temperature: 0.5,
|
|
model: msg.model || "", targetInstance: msg.targetInstance || "",
|
|
}, "llm_response", ws, 120000); // 2min: erster Modell-Swap laedt das GGUF kalt (mehrere GB) — 15s reichen dann nicht
|
|
log("info", "llm", `Test-Chat → ${msg.model || "?"} @ ${msg.targetInstance || "(broadcast)"}`);
|
|
} else if (msg.action === "node_stats_stream_start" || msg.action === "node_stats_stream_stop"
|
|
|| msg.action === "node_stats_history_request" || msg.action === "node_stats_reset") {
|
|
// Auslastungs-Monitor (Stage E): an die Box (targetInstance) durchreichen.
|
|
sendToRVS_raw({ type: msg.action, payload: { targetInstance: msg.targetInstance || "" }, timestamp: Date.now() });
|
|
} 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);
|
|
} else if (msg.action === "test_model") {
|
|
handleTestModel(ws, msg.model);
|
|
} else if (msg.action === "check_proxy_patches") {
|
|
handleCheckProxyPatches(ws);
|
|
} else if (msg.action === "get_privacy_flags") {
|
|
handleGetPrivacyFlags(ws);
|
|
} else if (msg.action === "set_privacy_flags") {
|
|
handleSetPrivacyFlags(ws, msg.flags);
|
|
} else if (msg.action === "get_attachment_limit") {
|
|
handleGetAttachmentLimit(ws);
|
|
} else if (msg.action === "set_attachment_limit") {
|
|
handleSetAttachmentLimit(ws, msg.maxMb);
|
|
} else if (msg.action === "voice_id_status") {
|
|
// An whisper-bridge weiterleiten + Antwort an Browser zurueck
|
|
const reqId = `vid_${Date.now().toString(36)}`;
|
|
sendToRVS_withResponse("voice_id_status_request", { requestId: reqId },
|
|
"voice_id_status_response", ws);
|
|
} else if (msg.action === "voice_id_delete") {
|
|
const reqId = `viddel_${Date.now().toString(36)}`;
|
|
sendToRVS_withResponse("voice_id_delete_request", { requestId: reqId },
|
|
"voice_id_delete_response", ws);
|
|
}
|
|
// 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 || "");
|
|
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung
|
|
const answeredBy = String(obj.answeredBy || ""); // Quell-Badge (local/claude/fast-path)
|
|
if (obj.role === "user") {
|
|
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
|
|
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,
|
|
projectId,
|
|
});
|
|
}
|
|
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId, answeredBy });
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ── Laufen im Proxy die aktuellen Patches? ──────────────────────────
|
|
//
|
|
// Der Proxy kopiert proxy-patches/*.js beim START ueber den npm-Code (siehe
|
|
// docker-compose.yml, command:). Wer nach einem git pull nur `restart` macht —
|
|
// oder neu startet, BEVOR der Pull durch ist — laeuft still mit der alten
|
|
// Fassung weiter. Das faellt sonst nirgends auf: der Container ist "Up", die
|
|
// Oberflaeche sieht normal aus, nur wirkt die Aenderung nicht.
|
|
//
|
|
// Darum: Zeitstempel der Quelldateien gegen die eingespielten vergleichen.
|
|
const PROXY_PATCH_MAP = [
|
|
["routes.js", "server/routes.js"],
|
|
["manager.js", "subprocess/manager.js"],
|
|
["openai-to-cli.js", "adapter/openai-to-cli.js"],
|
|
["cli-to-openai.js", "adapter/cli-to-openai.js"],
|
|
];
|
|
|
|
async function handleCheckProxyPatches(clientWs) {
|
|
try {
|
|
const dist = "/usr/local/lib/node_modules/claude-max-api-proxy/dist";
|
|
const pairs = PROXY_PATCH_MAP
|
|
.map(([src, dst]) => `/proxy-patches/${src} ${dist}/${dst}`).join(" ");
|
|
// %Y = mtime als Unix-Zeit, %n = Dateiname
|
|
const out = await dockerExec("aria-proxy", `stat -c '%Y %n' ${pairs} 2>/dev/null || true`);
|
|
const mtime = {};
|
|
for (const line of String(out || "").split("\n")) {
|
|
const m = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
if (m) mtime[m[2]] = parseInt(m[1], 10);
|
|
}
|
|
const stale = [];
|
|
for (const [src, dst] of PROXY_PATCH_MAP) {
|
|
const a = mtime[`/proxy-patches/${src}`];
|
|
const b = mtime[`${dist}/${dst}`];
|
|
if (a == null || b == null) continue;
|
|
// Toleranz 5s: Quelle und Kopie entstehen beim Start kurz nacheinander.
|
|
if (a > b + 5) stale.push({ file: src, srcAt: a * 1000, activeAt: b * 1000 });
|
|
}
|
|
clientWs.send(JSON.stringify({ type: "proxy_patch_state", stale }));
|
|
} catch (err) {
|
|
// Kein Alarm bei einem Pruef-Fehler — sonst steht da eine Warnung, die
|
|
// gar nichts ueber die Patches aussagt.
|
|
clientWs.send(JSON.stringify({ type: "proxy_patch_state", stale: [], error: String((err && err.message) || err) }));
|
|
}
|
|
}
|
|
|
|
// ── Einstellungen: Datenschutz-Schalter von Claude Code ─────────────
|
|
//
|
|
// Drei Kanaele, ueber die Claude Code von sich aus Daten an Anthropic bzw. an
|
|
// einen Fehler-Dienst schickt. Sie liegen als Umgebungsvariablen vor und lassen
|
|
// sich in ~/.claude/settings.json unter "env" setzen — die Datei ist in den
|
|
// Proxy-Container gemountet, also koennen wir sie von hier aus pflegen, ohne
|
|
// die docker-compose.yml anzufassen.
|
|
//
|
|
// WICHTIG: gesetzt wird in settings.json. Steht dieselbe Variable ZUSAETZLICH
|
|
// als echte Container-Umgebungsvariable (docker-compose), gewinnt die — darum
|
|
// prueft "Pruefen" beides und sagt, was tatsaechlich im Prozess ankommt.
|
|
const PRIVACY_FLAGS = [
|
|
{ key: "DISABLE_ERROR_REPORTING",
|
|
label: "Fehlerberichte",
|
|
info: "Stacktraces an einen Fehler-Dienst. Bei Pro/Max ab CLI 2.1.198 standardmäßig AN." },
|
|
{ key: "DISABLE_TELEMETRY",
|
|
label: "Nutzungs-Metriken",
|
|
info: "Latenz/Zuverlässigkeit. Enthält laut Anthropic nie Code, Prompts oder Pfade." },
|
|
{ key: "DISABLE_FEEDBACK_COMMAND",
|
|
label: "/feedback-Versand",
|
|
info: "Sendet sonst auf Zuruf die ganze Unterhaltung inkl. Code — 5 Jahre Aufbewahrung." },
|
|
];
|
|
const CLAUDE_SETTINGS_PATH = "/root/.claude/settings.json";
|
|
|
|
function _shq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
|
|
|
async function handleGetPrivacyFlags(clientWs) {
|
|
try {
|
|
// 1) Was kommt im PROZESS wirklich an? Das ist die Wahrheit.
|
|
const envOut = await dockerExec("aria-proxy",
|
|
"env | grep -E '^(DISABLE_ERROR_REPORTING|DISABLE_TELEMETRY|DISABLE_FEEDBACK_COMMAND|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)=' || true");
|
|
const liveEnv = {};
|
|
for (const line of String(envOut || "").split("\n")) {
|
|
const i = line.indexOf("=");
|
|
if (i > 0) liveEnv[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
}
|
|
// 2) Was steht in settings.json?
|
|
const settingsRaw = await dockerExec("aria-proxy",
|
|
`cat ${CLAUDE_SETTINGS_PATH} 2>/dev/null || echo '{}'`);
|
|
let settingsEnv = {};
|
|
try { settingsEnv = (JSON.parse(settingsRaw || "{}").env) || {}; } catch (_) {}
|
|
|
|
const isOn = (v) => v === "1" || String(v).toLowerCase() === "true";
|
|
const flags = PRIVACY_FLAGS.map(f => {
|
|
const fromEnv = liveEnv[f.key];
|
|
const fromSettings = settingsEnv[f.key];
|
|
// Container-Env gewinnt gegen settings.json.
|
|
const effective = fromEnv !== undefined ? isOn(fromEnv) : isOn(fromSettings);
|
|
return {
|
|
key: f.key, label: f.label, info: f.info,
|
|
disabled: effective,
|
|
source: fromEnv !== undefined ? "container" : (fromSettings !== undefined ? "settings" : "none"),
|
|
};
|
|
});
|
|
const bulk = isOn(liveEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC);
|
|
clientWs.send(JSON.stringify({ type: "privacy_flags", flags, bulkDisabled: bulk }));
|
|
} catch (err) {
|
|
clientWs.send(JSON.stringify({ type: "privacy_flags", error: String((err && err.message) || err) }));
|
|
}
|
|
}
|
|
|
|
async function handleSetPrivacyFlags(clientWs, wanted) {
|
|
try {
|
|
const keys = PRIVACY_FLAGS.map(f => f.key);
|
|
const raw = await dockerExec("aria-proxy",
|
|
`cat ${CLAUDE_SETTINGS_PATH} 2>/dev/null || echo '{}'`);
|
|
let cfg = {};
|
|
try { cfg = JSON.parse(raw || "{}"); } catch (_) { cfg = {}; }
|
|
if (!cfg.env || typeof cfg.env !== "object") cfg.env = {};
|
|
for (const k of keys) {
|
|
if (wanted && wanted[k]) cfg.env[k] = "1";
|
|
else delete cfg.env[k];
|
|
}
|
|
if (Object.keys(cfg.env).length === 0) delete cfg.env;
|
|
|
|
// Atomar schreiben (temp + mv), damit eine laufende CLI nie eine halbe
|
|
// Datei liest.
|
|
const json = JSON.stringify(cfg, null, 2);
|
|
await dockerExec("aria-proxy",
|
|
`printf '%s' ${_shq(json)} > ${CLAUDE_SETTINGS_PATH}.tmp && mv ${CLAUDE_SETTINGS_PATH}.tmp ${CLAUDE_SETTINGS_PATH}`);
|
|
log("info", "server", `Datenschutz-Schalter gesetzt: ${JSON.stringify(wanted || {})}`);
|
|
clientWs.send(JSON.stringify({
|
|
type: "privacy_flags_saved",
|
|
info: "Gespeichert. Gilt für JEDE neue Anfrage — ein laufender Turn nutzt noch die alte Einstellung.",
|
|
}));
|
|
await handleGetPrivacyFlags(clientWs);
|
|
} catch (err) {
|
|
clientWs.send(JSON.stringify({ type: "privacy_flags_saved", error: String((err && err.message) || err) }));
|
|
}
|
|
}
|
|
|
|
// ── Einstellungen: Model-Test ───────────────────────────────────────
|
|
//
|
|
// Schickt eine winzige ECHTE Anfrage durch den kompletten Weg
|
|
// (Diagnostic -> Proxy -> extractModel -> claude CLI -> Anthropic) und meldet,
|
|
// welches Modell tatsaechlich geantwortet hat.
|
|
//
|
|
// Wichtig: wir fragen NICHT das Modell selbst ("welche Version bist du?") —
|
|
// darauf kann man sich nicht verlassen, Modelle kennen ihren eigenen
|
|
// Versionsstring oft nicht und raten. Die Wahrheit kommt aus modelUsage der
|
|
// CLI (Feld canonicalModel): das ist der Name, unter dem der Call wirklich
|
|
// lief. Bei einem Alias sieht man damit die aufgeloeste Version
|
|
// ("opus" -> "claude-opus-5-5"), bei einem ungueltigen Namen den Fehler.
|
|
function handleTestModel(clientWs, model) {
|
|
const m = String(model || "").trim();
|
|
if (!m) {
|
|
clientWs.send(JSON.stringify({
|
|
type: "model_test_result", error: "Kein Modell angegeben" }));
|
|
return;
|
|
}
|
|
const started = Date.now();
|
|
(async () => {
|
|
try {
|
|
const r = await fetch(`${PROXY_URL}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model: m,
|
|
// Kurz halten: der Test soll Sekunden kosten, nicht Minuten.
|
|
messages: [{ role: "user", content: "Antworte nur mit: OK" }],
|
|
max_tokens: 16,
|
|
}),
|
|
signal: AbortSignal.timeout(180000),
|
|
});
|
|
const d = await r.json().catch(() => null);
|
|
if (!r.ok || !d) {
|
|
throw new Error(`Proxy antwortete HTTP ${r.status}`);
|
|
}
|
|
const tookMs = Date.now() - started;
|
|
const reply = (d.choices && d.choices[0] && d.choices[0].message
|
|
&& d.choices[0].message.content) || "";
|
|
// Kein Modell in modelUsage ODER is_error => der Call kam nie
|
|
// zustande. Die CLI legt ihre Begruendung dann in den Text.
|
|
if (d.aria_error || !d.aria_model) {
|
|
clientWs.send(JSON.stringify({
|
|
type: "model_test_result",
|
|
requested: m,
|
|
error: (reply || "Modell hat nicht geantwortet").slice(0, 400),
|
|
tookMs,
|
|
}));
|
|
return;
|
|
}
|
|
clientWs.send(JSON.stringify({
|
|
type: "model_test_result",
|
|
requested: m,
|
|
actual: d.aria_model,
|
|
contextWindow: d.aria_context_window || 0,
|
|
maxOutput: d.aria_max_output || 0,
|
|
reply: reply.slice(0, 120),
|
|
tookMs,
|
|
}));
|
|
log("info", "server", `Model-Test: ${m} -> ${d.aria_model} (${tookMs}ms)`);
|
|
} catch (err) {
|
|
const msg = err && err.name === "TimeoutError"
|
|
? "Zeitüberschreitung (3 min) — Proxy oder CLI antwortet nicht"
|
|
: String((err && err.message) || err);
|
|
clientWs.send(JSON.stringify({
|
|
type: "model_test_result", requested: m, error: msg,
|
|
tookMs: Date.now() - started,
|
|
}));
|
|
}
|
|
})();
|
|
}
|
|
|
|
// ── Einstellungen: Groessen-Limit fuer Memory-Anhaenge ──────────────
|
|
// Steht in runtime.json als `memoryAttachmentMaxMb`. Das Brain liest es bei
|
|
// JEDEM Upload neu (memory_attachments.max_bytes()) — eine Aenderung greift
|
|
// also sofort, ohne aria-brain neu zu starten.
|
|
//
|
|
// Obergrenze mit Bedacht: der Anhang geht als base64-in-JSON zur App, die muss
|
|
// ihn komplett in den Speicher nehmen. Ueber ~64 MB laedt das Handy ihn nicht
|
|
// mehr (TAP_DL_MAX_BYTES in ChatScreen.tsx) — dann liegt er zwar sauber auf dem
|
|
// Server, ist mobil aber nur noch per Dateimanager erreichbar. Darum warnen wir
|
|
// darueber, verbieten es aber nicht.
|
|
const ATTACHMENT_LIMIT_SOFT_MB = 64;
|
|
const ATTACHMENT_LIMIT_MAX_MB = 1024;
|
|
|
|
function handleGetAttachmentLimit(clientWs) {
|
|
// Das Brain ist die Wahrheit — es kennt auch den ENV-Fallback, den der
|
|
// Diagnostic-Container nicht sieht.
|
|
const brain = process.env.BRAIN_URL || "http://aria-brain:8080";
|
|
const req = http.get(`${brain}/attachments/limit`, { timeout: 5000 }, (r) => {
|
|
let body = "";
|
|
r.on("data", (c) => { body += c; });
|
|
r.on("end", () => {
|
|
let maxMb = null;
|
|
try { maxMb = JSON.parse(body).maxMb; } catch (_) {}
|
|
clientWs.send(JSON.stringify({
|
|
type: "attachment_limit_info",
|
|
maxMb,
|
|
softMb: ATTACHMENT_LIMIT_SOFT_MB,
|
|
hardMb: ATTACHMENT_LIMIT_MAX_MB,
|
|
error: maxMb == null ? "Brain antwortet nicht verwertbar" : undefined,
|
|
}));
|
|
});
|
|
});
|
|
req.on("error", (err) => {
|
|
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
|
|
});
|
|
req.on("timeout", () => { req.destroy(new Error("Brain antwortet nicht")); });
|
|
}
|
|
|
|
function handleSetAttachmentLimit(clientWs, maxMb) {
|
|
const n = Number(maxMb);
|
|
if (!isFinite(n) || n <= 0) {
|
|
clientWs.send(JSON.stringify({
|
|
type: "attachment_limit_info", error: "Bitte eine Zahl groesser 0 angeben (MB)" }));
|
|
return;
|
|
}
|
|
if (n > ATTACHMENT_LIMIT_MAX_MB) {
|
|
clientWs.send(JSON.stringify({
|
|
type: "attachment_limit_info",
|
|
error: `Maximal ${ATTACHMENT_LIMIT_MAX_MB} MB — darueber reisst der WebSocket-Transport` }));
|
|
return;
|
|
}
|
|
try {
|
|
writeRuntimeConfig({ memoryAttachmentMaxMb: n });
|
|
log("info", "server", `Anhang-Limit gesetzt: ${n} MB`);
|
|
clientWs.send(JSON.stringify({
|
|
type: "attachment_limit_info",
|
|
maxMb: n,
|
|
softMb: ATTACHMENT_LIMIT_SOFT_MB,
|
|
hardMb: ATTACHMENT_LIMIT_MAX_MB,
|
|
info: n > ATTACHMENT_LIMIT_SOFT_MB
|
|
? `${n} MB gesetzt (greift sofort). Hinweis: ueber ${ATTACHMENT_LIMIT_SOFT_MB} MB `
|
|
+ `laedt die Handy-App Anhaenge nicht mehr herunter — sie liegen dann nur `
|
|
+ `noch auf dem Server.`
|
|
: `${n} MB gesetzt — greift sofort, kein Neustart noetig.`,
|
|
}));
|
|
} catch (err) {
|
|
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
});
|