Einstellungen → VPN → WireGuard → Server: Einwahl anlegen (Name, Kundennetz, LAN-IP der FritzBox); ARIA erzeugt beide Schluesselpaare + PSK, ⬇ liefert die Datei fuer die Gegenseite (FritzBox-Netzwerk-Kopplung oder Standard-WireGuard). Jede Einwahl = eigener VPN-Satellit-Container mit fester IP im Netz aria-vpn (10.213.213.0/24, ausserhalb von Dockers Pools). Neuer Verteiler (satellite/vpn_relay.py, aria-vpn-relay, Host-Netz, VPN_RELAY_PORT 51820): ordnet jedes Paket per WireGuard-mac1 bzw. Sitzungsnummer dem richtigen Container zu, ohne zu entschluesseln; Fremdes wird still verworfen. Anleitung NPM-Stream / ohne Nginx / ufw im ℹ des Server-Reiters und im satellite/README. Lokal Ende-zu-Ende getestet: zwei Kunden mit demselben Netz 192.168.178.0/24 gleichzeitig ueber einen Port, jeder erreicht nur sein eigenes LAN; kein Weg Kunde → ARIA, Kunde 1 → Einwahl 2, Container → Internet; fremder Schluessel und Muell am Verteiler ohne Antwort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4798 lines
219 KiB
JavaScript
4798 lines
219 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");
|
||
const vpn = require("./vpn");
|
||
|
||
// ── 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",
|
||
// Ausweich-Modell, wenn Anthropics Sicherheitsfilter das Hauptmodell blockt.
|
||
// Das Brain liest es PRO Vorfall aus derselben Datei — Aenderung greift ohne
|
||
// Neustart. Leer = automatisch (anderes Tier als das Hauptmodell).
|
||
"brainFallbackModel",
|
||
// 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";
|
||
}
|
||
// ── Quantisierung pro Repo ──────────────────────────────────────────
|
||
// Nicht jedes Repo hat eine Q4_K_M-Datei (unsloth z.B. "UD-Q4_K_XL"). Steht
|
||
// eine Quantisierung im Katalog, die es nicht gibt, beendet sich llama-server
|
||
// sofort und die Box meldet nur "500". Darum fragen wir pro Repo die Dateiliste
|
||
// ab und nehmen die beste vorhandene — dieselbe Reihenfolge wie im llm-adapter.
|
||
const QUANT_PREFERENCE = ["Q4_K_M", "UD-Q4_K_XL", "Q4_K_XL", "Q4_K_S", "IQ4_XS", "IQ4_NL", "Q4_0",
|
||
"Q5_K_M", "UD-Q5_K_XL", "Q6_K", "Q8_0"];
|
||
const QUANT_RE = /((?:UD-)?(?:IQ[1-4]_(?:XXS|XS|S|M|NL)|Q[2-8]_K(?:_(?:XL|L|M|S))?|Q[4-8]_[01]|BF16|F16|F32))/gi;
|
||
const SPLIT_RE = /-(\d{5})-of-(\d{5})\.gguf$/i;
|
||
function ggufQuant(p) {
|
||
const base = String(p).split("/").pop().replace(SPLIT_RE, ".gguf").replace(/\.gguf$/i, "");
|
||
const m = base.match(QUANT_RE);
|
||
return m ? m[m.length - 1].toUpperCase() : "";
|
||
}
|
||
function isMainGguf(p) {
|
||
const low = String(p).toLowerCase();
|
||
const base = low.split("/").pop();
|
||
return low.endsWith(".gguf") && !base.includes("mmproj") && !base.startsWith("mtp")
|
||
&& !("/" + low).includes("/mtp/") && !base.includes("imatrix");
|
||
}
|
||
// → { quant, sizeGB, quants } oder null (Repo nicht lesbar / kein Modell darin)
|
||
async function hfRepoQuant(repo, wanted = "Q4_K_M") {
|
||
try {
|
||
const r = await fetch(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1`,
|
||
{ headers: { "User-Agent": "aria-diagnostic" } });
|
||
if (!r.ok) return null;
|
||
const files = (await r.json()).filter(e => e.type === "file" && isMainGguf(e.path))
|
||
.map(e => ({ path: e.path, size: (e.lfs && e.lfs.size) || e.size || 0, quant: ggufQuant(e.path) }));
|
||
const have = new Set(files.map(f => f.quant).filter(Boolean));
|
||
const quant = have.has(String(wanted).toUpperCase()) ? String(wanted).toUpperCase()
|
||
: QUANT_PREFERENCE.find(q => have.has(q)) || "";
|
||
if (!quant) return null;
|
||
const match = files.filter(f => f.quant === quant);
|
||
const parts = match.some(f => SPLIT_RE.test(f.path)) ? match.filter(f => SPLIT_RE.test(f.path))
|
||
: [match.reduce((a, b) => (a.path.length <= b.path.length ? a : b))];
|
||
const bytes = parts.reduce((n, f) => n + f.size, 0);
|
||
return { quant, sizeGB: Math.round(bytes / 1e8) / 10, quants: [...have].sort() };
|
||
} catch { return null; }
|
||
}
|
||
|
||
// Holt populaere GGUF-Modelle von der HuggingFace-API und merged sie in den
|
||
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
|
||
// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
|
||
// eingetragen — auch bei schon vorhandenen HF-Eintraegen.
|
||
// "Von HuggingFace aktualisieren": mehrere Blickwinkel statt nur der
|
||
// meistgeladenen aller Zeiten (da gewinnen alte Modelle). Neu dazu:
|
||
// was gerade im Trend ist, Coder-Modelle und unzensierte Fassungen.
|
||
// Nur was eine Box laden kann (GGUF) und hoechstens LLM_CATALOG_MAX_GB.
|
||
const LLM_CATALOG_MAX_GB = Number(process.env.LLM_CATALOG_MAX_GB || 30);
|
||
const NON_LLM_RE = /image|audio|tts|speech|music|yue|whisper|diffusion|flux|sdxl|wan2|video|embed|rerank|ocr/i;
|
||
const HF_REFRESH_QUERIES = [
|
||
["beliebt", "search=GGUF&sort=downloads&direction=-1&limit=30"],
|
||
["im Trend", "filter=gguf&sort=trendingScore&direction=-1&limit=30"],
|
||
["Code", "search=coder&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||
["unzensiert", "search=uncensored&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||
["unzensiert", "search=heretic&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||
["unzensiert", "search=abliterated&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||
];
|
||
async function refreshLlmCatalogFromHF() {
|
||
const existing = loadLlmCatalog();
|
||
const byId = new Map(existing.map(m => [m.id, m]));
|
||
const fresh = new Map(); // repo → {cat, downloads}
|
||
for (const [cat, q] of HF_REFRESH_QUERIES) {
|
||
const list = await hfJson(`https://huggingface.co/api/models?${q}`);
|
||
for (const m of Array.isArray(list) ? list : []) {
|
||
const repo = m.id || m.modelId;
|
||
if (!repo || fresh.has(repo)) continue;
|
||
// Nur Sprachmodelle: Bild-, Audio-, Musik- und Sprach-Modelle kann die
|
||
// LLM-Box nicht als Chat-Modell laden.
|
||
const pt = String(m.pipeline_tag || "");
|
||
if (pt && !["text-generation", "image-text-to-text", "conversational"].includes(pt)) continue;
|
||
if (NON_LLM_RE.test(repo)) continue;
|
||
fresh.set(repo, { cat, downloads: m.downloads || 0 });
|
||
}
|
||
}
|
||
if (!fresh.size) throw new Error("HuggingFace hat nichts geliefert (erreichbar?)");
|
||
const known = new Set(existing.map(m => m.hfRepo));
|
||
for (const [repo, f] of fresh) {
|
||
if (known.has(repo)) continue;
|
||
const id = slugModelId(repo);
|
||
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
||
byId.set(id, { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
|
||
description: `HuggingFace · ${f.cat} · ${(f.downloads).toLocaleString("de")} Downloads`, source: "hf",
|
||
uncensored: UNCENSORED_RE.test(repo), added_at: new Date().toISOString().slice(0, 10), _new: true });
|
||
}
|
||
// Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
|
||
// gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
|
||
const toCheck = [...byId.values()].filter(e => e.source === "hf");
|
||
for (let i = 0; i < toCheck.length; i += 6) {
|
||
await Promise.all(toCheck.slice(i, i + 6).map(async e => {
|
||
const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
|
||
if (!q || (e._new && q.sizeGB > LLM_CATALOG_MAX_GB)) {
|
||
// Nicht ladbar oder fuer unsere Karten viel zu gross: neue nicht
|
||
// aufnehmen, alte markieren.
|
||
if (e._new) byId.delete(e.id); else if (!q) e.unusable = true;
|
||
return;
|
||
}
|
||
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
|
||
}));
|
||
}
|
||
// Werkzeug-Faehigkeit + Sicherheits-Scan nachtragen (wo noch keine Angabe),
|
||
// Beliebtheit bei jedem Abgleich frisch (aendert sich taeglich)
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const toEnrich = [...byId.values()].filter(e => e.hfRepo && (e.tools === undefined || e.safety === undefined
|
||
|| !e.popularity || e.popularity.at !== today));
|
||
for (let i = 0; i < toEnrich.length; i += 6) {
|
||
await Promise.all(toEnrich.slice(i, i + 6).map(async e => {
|
||
if (e.tools === undefined) e.tools = await hfToolSupport(e.hfRepo);
|
||
if (e.safety === undefined) e.safety = await hfRepoSafety(e.hfRepo);
|
||
if (!e.popularity || e.popularity.at !== today) e.popularity = await hfPopularity(e.hfRepo) || e.popularity;
|
||
}));
|
||
}
|
||
let added = 0;
|
||
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
|
||
// Aufraeumen: von HF uebernommen, vor > LLM_CATALOG_KEEP_DAYS Tagen, auf
|
||
// keiner Box (Platte/Karte), heute nicht mehr gelistet → raus. Kuratierte
|
||
// und von Hand hinzugefuegte bleiben immer.
|
||
const keepDays = Number(process.env.LLM_CATALOG_KEEP_DAYS || 30);
|
||
const onBox = new Set();
|
||
for (const w of workers.values()) {
|
||
for (const d of ((w.disk && w.disk.models) || [])) onBox.add(d.repo);
|
||
for (const sl of (w.slots || [])) if (sl.model) onBox.add(sl.model);
|
||
}
|
||
let removed = 0;
|
||
for (const [id, e] of [...byId]) {
|
||
const age = e.added_at ? (Date.now() - new Date(e.added_at).getTime()) / 864e5 : 0;
|
||
if (e.source === "hf" && !/Hand/.test(e.description || "") && age > keepDays
|
||
&& !fresh.has(e.hfRepo) && !onBox.has(e.hfRepo) && !onBox.has(e.id)) {
|
||
byId.delete(id); removed++;
|
||
}
|
||
}
|
||
const merged = Array.from(byId.values());
|
||
saveLlmCatalog(merged);
|
||
return { models: merged, added, removed };
|
||
}
|
||
// Einmal am Tag von selbst (LLM_CATALOG_REFRESH_HOURS, 0 = aus)
|
||
const LLM_CATALOG_REFRESH_HOURS = Number(process.env.LLM_CATALOG_REFRESH_HOURS ?? 24);
|
||
if (LLM_CATALOG_REFRESH_HOURS > 0) {
|
||
setInterval(() => {
|
||
refreshLlmCatalogFromHF()
|
||
.then(r => log("info", "llm", `LLM-Katalog automatisch aktualisiert: +${r.added}, -${r.removed}`))
|
||
.catch(err => log("warn", "llm", `LLM-Katalog automatisch: ${err.message}`));
|
||
}, LLM_CATALOG_REFRESH_HOURS * 3600 * 1000);
|
||
}
|
||
|
||
// ── Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort ─────────────────
|
||
const ARIA_TOOL_LOG_DIR = "/shared/logs/aria-tools";
|
||
function ariaProof(toTs, project) {
|
||
// Beginn = Stefans letzte Nachricht davor im selben Kontext (sonst 30 min)
|
||
let fromTs = toTs - 30 * 60 * 1000;
|
||
try {
|
||
const lines = fs.readFileSync("/shared/config/chat_backup.jsonl", "utf-8").split("\n");
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
if (!lines[i]) continue;
|
||
let e; try { e = JSON.parse(lines[i]); } catch { continue; }
|
||
if (!e || !(e.ts < toTs) || e.role !== "user") continue;
|
||
if ((e.project_id || "") !== project) continue;
|
||
fromTs = Math.max(fromTs, e.ts - 2000);
|
||
break;
|
||
}
|
||
} catch (_) { }
|
||
const days = new Set([new Date(fromTs).toISOString().slice(0, 10), new Date(toTs).toISOString().slice(0, 10)]);
|
||
const reqs = new Map();
|
||
for (const d of days) {
|
||
let txt = "";
|
||
try { txt = fs.readFileSync(`${ARIA_TOOL_LOG_DIR}/${d}.jsonl`, "utf-8"); } catch { continue; }
|
||
for (const line of txt.split("\n")) {
|
||
if (!line) continue;
|
||
let ev; try { ev = JSON.parse(line); } catch { continue; }
|
||
if (!ev.requestId) continue;
|
||
const r = reqs.get(ev.requestId) || { requestId: ev.requestId, project: null, start: 0, end: 0, tools: [], byId: {} };
|
||
if (ev.kind === "start") { r.start = ev.ts; r.project = ev.projectId || ""; r.model = ev.model || ""; }
|
||
else if (ev.kind === "end") r.end = ev.ts;
|
||
else if (ev.kind === "tool_use") {
|
||
const t = { id: ev.id, name: ev.name, input: ev.input, inputTruncatedBytes: ev.inputTruncatedBytes || 0, ts: ev.ts };
|
||
r.tools.push(t); if (ev.id) r.byId[ev.id] = t;
|
||
} else if (ev.kind === "tool_result") {
|
||
const t = ev.id && r.byId[ev.id];
|
||
if (t) { t.result = ev.content; t.isError = !!ev.isError; t.resultTruncatedBytes = ev.truncatedBytes || 0; t.doneTs = ev.ts; }
|
||
}
|
||
reqs.set(ev.requestId, r);
|
||
}
|
||
}
|
||
const hit = [...reqs.values()]
|
||
.filter(r => r.start && r.project === project && r.start >= fromTs && r.start <= toTs + 5000)
|
||
.sort((a, b) => a.start - b.start)
|
||
.map(({ byId, ...r }) => r);
|
||
const logSince = (() => { try { return fs.readdirSync(ARIA_TOOL_LOG_DIR).filter(f => /\.jsonl$/.test(f)).sort()[0]?.slice(0, 10) || ""; } catch { return ""; } })();
|
||
return { from: fromTs, to: toTs, requests: hit, toolCount: hit.reduce((n, r) => n + r.tools.length, 0), logSince };
|
||
}
|
||
|
||
// ── Gerade im Trend: ladbare GGUF-Modelle von HuggingFace ───────────
|
||
// Fuer das Fenster "Bestenlisten" im Modell-Katalog. "Trend" ist nicht
|
||
// "das Beste" — viele Community-Mischungen. Darum standardmaessig nur
|
||
// bekannte Anbieter. Ergebnis 30 min gecacht (HF nicht dauernd fragen).
|
||
const TRUSTED_GGUF_AUTHORS = new Set(["unsloth", "bartowski", "lmstudio-community", "ggml-org", "qwen",
|
||
"google", "mistralai", "microsoft", "meta-llama", "deepseek-ai", "ibm-granite", "nvidia", "openai",
|
||
"allenai", "huggingfacetb", "thudm", "zai-org", "moonshotai", "minimaxai", "tencent", "baidu"]);
|
||
const DISCOVER_CATS = {
|
||
chat: { label: "Allgemein / Chat", q: "pipeline_tag=text-generation" },
|
||
code: { label: "Programmieren", q: "search=coder" },
|
||
vision: { label: "Bilder verstehen", q: "pipeline_tag=image-text-to-text" },
|
||
};
|
||
const _discoverCache = new Map(); // key → {ts, items}
|
||
async function discoverGguf(cat, trusted) {
|
||
const c = DISCOVER_CATS[cat] || DISCOVER_CATS.chat;
|
||
const key = `${cat}|${trusted ? 1 : 0}`;
|
||
const hit = _discoverCache.get(key);
|
||
if (hit && Date.now() - hit.ts < 30 * 60 * 1000) return hit.items;
|
||
const r = await fetch(`https://huggingface.co/api/models?library=gguf&sort=trendingScore&direction=-1&limit=100&${c.q}`,
|
||
{ headers: { "User-Agent": "aria-diagnostic" } });
|
||
if (!r.ok) throw new Error(`HF API ${r.status}`);
|
||
let list = (await r.json()).filter(m => m && m.id);
|
||
if (trusted) list = list.filter(m => TRUSTED_GGUF_AUTHORS.has(String(m.id).split("/")[0].toLowerCase()));
|
||
list = list.slice(0, 18);
|
||
const items = [];
|
||
for (let i = 0; i < list.length; i += 6) {
|
||
await Promise.all(list.slice(i, i + 6).map(async m => {
|
||
const q = await hfRepoQuant(m.id, "Q4_K_M");
|
||
if (!q) return; // kein ladbares GGUF im Repo
|
||
items.push({ repo: m.id, author: String(m.id).split("/")[0], downloads: m.downloads || 0,
|
||
likes: m.likes || 0, trending: m.trendingScore || 0,
|
||
lastModified: m.lastModified || m.createdAt || "", quant: q.quant, sizeGB: q.sizeGB });
|
||
}));
|
||
}
|
||
items.sort((a, b) => b.trending - a.trending);
|
||
_discoverCache.set(key, { ts: Date.now(), items });
|
||
return items;
|
||
}
|
||
|
||
// Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden")
|
||
// Link oder "org/name" → "org/name" (huggingface.co/org/name/tree/main … geht auch)
|
||
function hfRepoFromInput(input) {
|
||
const t = String(input || "").trim().replace(/^https?:\/\/(www\.)?huggingface\.co\//i, "").replace(/[?#].*$/, "");
|
||
const m = t.match(/^([\w.-]+)\/([\w.-]+)/);
|
||
return m ? `${m[1]}/${m[2]}` : "";
|
||
}
|
||
// Repo ohne GGUF (z.B. nur safetensors): GGUF-Fassungen desselben Modells
|
||
// auf HuggingFace suchen — meist von mradermacher, bartowski, unsloth & Co.
|
||
async function hfGgufAlternatives(repo) {
|
||
const name = String(repo).split("/")[1] || "";
|
||
if (!name) return [];
|
||
try {
|
||
const r = await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(name)}&filter=gguf&sort=downloads&direction=-1&limit=12`,
|
||
{ headers: { "User-Agent": "aria-diagnostic" } });
|
||
if (!r.ok) return [];
|
||
return (await r.json()).filter(m => m.id && m.id !== repo)
|
||
.map(m => ({ repo: m.id, downloads: m.downloads || 0, likes: m.likes || 0 })).slice(0, 8);
|
||
} catch { return []; }
|
||
}
|
||
|
||
// ── ℹ im Modell-Katalog: was kann das Modell, ist es zensiert? ──────
|
||
// HuggingFace liefert Grundmodell, Lizenz und Schlagworte, aber keine
|
||
// verstaendliche Beschreibung. Darum: kurze deutsche Texte je Modellfamilie
|
||
// (erkannt am Namen) + die Fakten von HF + Anfang der Modellkarte.
|
||
const MODEL_FAMILIES = [
|
||
["qwen3-coder", "Qwen3-Coder (Alibaba)", "Aufs Programmieren spezialisiert: Code schreiben, verstehen, umbauen, mit Werkzeugen arbeiten (Agenten). Die 30B-A3B-Fassung ist ein MoE-Modell: pro Wort rechnen nur ~3 Mrd. Parameter — schnell für seine Größe."],
|
||
["foundation-sec", "Foundation-Sec (Cisco Foundation AI)", "Auf IT-Sicherheit trainiert: Schwachstellen, Angriffstechniken (MITRE ATT&CK), Logs und Warnmeldungen auswerten. Die reine „Foundation-Sec-8B“ ist ein Basismodell (setzt nur Text fort) — zum Chatten die Instruct- oder Reasoning-Fassung nehmen."],
|
||
["vulnllm", "VulnLLM-R", "Sucht Sicherheitslücken in Quellcode und begründet Schritt für Schritt (Reasoning), warum eine Stelle verwundbar ist."],
|
||
["dolphin", "Dolphin (Cognitive Computations)", "Bewusst ohne Ablehnungen trainiert: macht, was man verlangt, und lässt sich stark über den Systemprompt steuern. Gut für Aufgaben, die große Anbieter verweigern — man ist aber selbst verantwortlich, was man damit tut."],
|
||
["hermes", "Hermes (Nous Research)", "Sehr gutes Befolgen von Anweisungen, Rollenspiel und Werkzeug-Aufrufe (Function Calling), wenige Ablehnungen. Hermes 4 kann zusätzlich „denken“ (Reasoning)."],
|
||
["lexi", "Lexi (Orenguteng)", "Unzensierter Llama-Ableger für allgemeinen Chat, beantwortet auch Fragen, die das Original ablehnt."],
|
||
["gemma", "Gemma (Google)", "Allround-Modell mit gutem Deutsch; neuere Fassungen verstehen auch Bilder. Eher vorsichtig, lehnt heikle Themen ab."],
|
||
["qwen", "Qwen (Alibaba)", "Starke Allround-Familie: Wissen, Logik, Mathe, Code, viele Sprachen inkl. Deutsch; neuere Fassungen mit Denkmodus. Standard-Fassungen haben Schutzfilter."],
|
||
["llama", "Llama (Meta)", "Bewährtes Allround-Modell und Basis für sehr viele Ableger; ordentliches Deutsch, solide Werkzeug-Aufrufe."],
|
||
["mistral", "Mistral (Mistral AI, Frankreich)", "Gut in europäischen Sprachen, effizient; Mistral Small 24B ist für seine Größe sehr stark."],
|
||
["deepseek", "DeepSeek", "Stark in Logik, Mathe und Code, oft mit ausführlichem Denkweg (Reasoning)."],
|
||
["phi", "Phi (Microsoft)", "Klein und schnell, erstaunlich gut in Logik für die Größe; eher Englisch."],
|
||
];
|
||
const UNCENSORED_RE = /uncensor|abliterat|heretic|obliterat|dolphin|lexi|unfiltered|nsfw|no.?refus/i;
|
||
async function hfJson(url) {
|
||
try {
|
||
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
||
return r.ok ? await r.json() : null;
|
||
} catch { return null; }
|
||
}
|
||
async function hfReadmeIntro(repo) {
|
||
try {
|
||
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
|
||
if (!r.ok) return "";
|
||
let t = await r.text();
|
||
t = t.replace(/^---[\s\S]*?\n---\n/, ""); // YAML-Kopf
|
||
const paras = t.split(/\n\s*\n/).map(p => p.trim())
|
||
.filter(p => p && !/^(#|!\[|<|\||```|\[!\[|>|-{3,})/.test(p) && p.replace(/[^A-Za-z]/g, "").length > 60)
|
||
// Absaetze, die fast nur Links sind (Website:, Twitter: …), ueberspringen
|
||
.filter(p => p.replace(/https?:\/\/\S+/g, "").replace(/[^A-Za-z]/g, "").length > 80);
|
||
return paras.slice(0, 2).join("\n\n").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").slice(0, 900);
|
||
} catch { return ""; }
|
||
}
|
||
// Ganze Modellkarte als Text (ohne YAML/Bilder/HTML), fuer die Zusammenfassung
|
||
async function hfReadmeText(repo, max = 7000) {
|
||
try {
|
||
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
|
||
if (!r.ok) return "";
|
||
return (await r.text()).replace(/^---[\s\S]*?\n---\n/, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||
.replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").slice(0, max);
|
||
} catch { return ""; }
|
||
}
|
||
// Deutsche Kurzbeschreibung vom LOKALEN Modell aus der Modellkarte — einmal
|
||
// erzeugt, dann im Katalog gespeichert (summary_de). So hat jedes neue Modell
|
||
// eine, ohne dass jemand Texte pflegt.
|
||
async function llmModelSummary(repo) {
|
||
repo = hfRepoFromInput(repo);
|
||
const cat = loadLlmCatalog();
|
||
const entry = cat.find(m => m.hfRepo === repo);
|
||
if (entry && entry.summary_de) return { summary: entry.summary_de, by: entry.summary_by || "", cached: true };
|
||
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
|
||
let base = (meta.cardData || {}).base_model;
|
||
base = Array.isArray(base) ? base[0] : base || "";
|
||
const card = [await hfReadmeText(base) , await hfReadmeText(repo, 3000)].filter(Boolean).join("\n\n---\n\n");
|
||
if (!card.trim()) return { summary: "", error: "Keine Modellkarte auf HuggingFace." };
|
||
const tags = (meta.tags || []).filter(t => !/^(base_model|license|region|endpoints)/.test(t)).slice(0, 25).join(", ");
|
||
const res = await rvsRequest("llm_request", {
|
||
requestId: "modelinfo_" + Date.now(),
|
||
messages: [
|
||
{ role: "system", content: "Du fasst Modellkarten von KI-Sprachmodellen fuer einen Laien auf Deutsch zusammen. "
|
||
+ "Nur was in der Karte steht, nichts erfinden. 3 bis 5 kurze Saetze: wofuer das Modell gedacht ist, worin es "
|
||
+ "besonders gut ist, ob es Ablehnungen/Schutzfilter entfernt hat (uncensored/abliterated), und Besonderheiten "
|
||
+ "(Denkmodus, Werkzeuge, Sprachen, Groesse). Keine Ueberschrift, kein Markdown." },
|
||
{ role: "user", content: `Modell: ${repo}\nGrundmodell: ${base || "-"}\nSchlagworte: ${tags}\n\nModellkarte:\n${card}` },
|
||
],
|
||
max_tokens: 400, temperature: 0.2, model: "", targetInstance: "",
|
||
}, "llm_response", 180000);
|
||
const text = String(res.content || res.text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
||
if (!res.ok || !text) return { summary: "", error: res.error || "Das lokale Modell hat nicht geantwortet." };
|
||
const by = res.model || "lokales Modell";
|
||
if (entry) {
|
||
entry.summary_de = text; entry.summary_by = by; entry.summary_at = new Date().toISOString();
|
||
saveLlmCatalog(cat);
|
||
}
|
||
return { summary: text, by, cached: false };
|
||
}
|
||
|
||
async function llmModelInfo(repo) {
|
||
repo = hfRepoFromInput(repo);
|
||
if (!repo) throw new Error("ungueltiges Repo");
|
||
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
|
||
const card = meta.cardData || {};
|
||
let base = card.base_model;
|
||
base = Array.isArray(base) ? base[0] : base || "";
|
||
const baseMeta = base ? (await hfJson(`https://huggingface.co/api/models/${base}`) || {}) : {};
|
||
const tags = [...new Set([...(meta.tags || []), ...((card.tags) || [])])]
|
||
.filter(t => !/^(base_model|license|region|endpoints|autotrain|text-generation-inference|safetensors|transformers|gguf|conversational|imatrix)/.test(t));
|
||
const hay = [repo, base, ...tags].join(" ").toLowerCase();
|
||
const fams = [];
|
||
for (const [key, name, text] of MODEL_FAMILIES) {
|
||
// Ganzes Wort am Anfang ("phi" nicht in "dolphin" finden)
|
||
const re = new RegExp("(^|[^a-z0-9])" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||
if (name && re.test(hay) && !fams.some(f => f.name === name)) fams.push({ name, text });
|
||
}
|
||
// Allgemeine Familien (Qwen/Llama …) nur, wenn keine speziellere passt
|
||
const special = fams.filter(f => !/^(Qwen|Llama|Mistral|Gemma) \(/.test(f.name));
|
||
const families = special.length ? [...special, ...fams.filter(f => !special.includes(f)).slice(0, 1)] : fams.slice(0, 1);
|
||
const unc = UNCENSORED_RE.exec(hay);
|
||
return {
|
||
repo, base, author: meta.author || repo.split("/")[0], license: card.license || "",
|
||
downloads: meta.downloads || 0, likes: meta.likes || 0,
|
||
created: meta.createdAt || "", updated: meta.lastModified || "",
|
||
tags: tags.slice(0, 20), families,
|
||
uncensored: !!unc,
|
||
tools: await hfToolSupport(repo),
|
||
safety: await hfRepoSafety(repo),
|
||
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
|
||
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
|
||
introFrom: base || repo,
|
||
};
|
||
}
|
||
|
||
// Kann das Modell Werkzeuge? Kennzeichen: die Chat-Vorlage im GGUF (HF liefert
|
||
// sie unter expand[]=gguf) kennt ein Werkzeug-Format, oder Schlagworte wie
|
||
// function-calling/tool-use. Zeigt nur, OB das Format da ist, nicht wie gut.
|
||
const TOOLS_TEMPLATE_RE = /\btools\b|tool_call|<tool|function_call/;
|
||
const TOOLS_TAG_RE = /function.?call|tool.?use|tool.?calling|agent/i;
|
||
async function hfToolSupport(repo) {
|
||
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=gguf&expand[]=tags`);
|
||
if (!d) return null;
|
||
const tpl = String(((d.gguf || {}).chat_template) || "");
|
||
if (TOOLS_TEMPLATE_RE.test(tpl)) return true;
|
||
if ((d.tags || []).some(t => TOOLS_TAG_RE.test(t))) return true;
|
||
return tpl ? false : null; // keine Vorlage = unbekannt
|
||
}
|
||
|
||
// ── Sicherheit eines Repos ─────────────────────────────────────────
|
||
// Die Box laedt NUR die GGUF-Datei (llama.cpp -hf / paralleler Download per
|
||
// sha256) — Skripte, Pickle o.ae. daneben werden nie geholt oder ausgefuehrt.
|
||
// Liegen sie trotzdem im Repo, ist das ein Warnsignal fuer den Anbieter.
|
||
// HuggingFace scannt jede Datei (securityFileStatus) — das Ergebnis zeigen wir.
|
||
const EXTRA_KNOWN_GGUF_AUTHORS = new Set(["mradermacher", "thebloke", "maziyarpanahi", "nousresearch",
|
||
"dphn", "cognitivecomputations", "fdtn-ai", "quantfactory", "lmstudio-ai"]);
|
||
function knownGgufAuthor(a) { // Funktion: TRUSTED_GGUF_AUTHORS steht weiter unten in der Datei
|
||
return TRUSTED_GGUF_AUTHORS.has(a) || EXTRA_KNOWN_GGUF_AUTHORS.has(a);
|
||
}
|
||
const RISKY_FILE_RE = /\.(py|sh|bash|bat|cmd|ps1|exe|dll|so|dylib|pkl|pickle|pt|pth|ckpt|joblib|npy|zip|tar|gz|7z|jar|js|vbs)$|(^|\/)pytorch_model[^/]*\.bin$/i;
|
||
// Beliebtheit: HF hat keine Sterne — nur Downloads (30 Tage / gesamt) und Likes.
|
||
async function hfPopularity(repo) {
|
||
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=downloads&expand[]=likes&expand[]=downloadsAllTime`);
|
||
if (!d) return null;
|
||
return { downloads: d.downloads || 0, downloadsAll: d.downloadsAllTime || 0, likes: d.likes || 0,
|
||
at: new Date().toISOString().slice(0, 10) };
|
||
}
|
||
|
||
async function hfRepoSafety(repo) {
|
||
const author = String(repo).split("/")[0].toLowerCase();
|
||
const out = { known: knownGgufAuthor(author), flagged: [], risky: [], scanned: 0 };
|
||
const tree = await hfJson(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1&expand=true`);
|
||
for (const f of Array.isArray(tree) ? tree : []) {
|
||
if (f.type !== "file") continue;
|
||
const st = f.securityFileStatus || {};
|
||
const subs = Object.values(st).filter(v => v && typeof v === "object").map(v => String(v.status || ""));
|
||
const all = [String(st.status || ""), ...subs].map(x => x.toLowerCase());
|
||
if (all.some(x => /unsafe|suspicious|malicious|caution|infected/.test(x))) out.flagged.push({ path: f.path, status: all.filter(Boolean).join(", ") });
|
||
else if (all.includes("safe")) out.scanned++;
|
||
if (RISKY_FILE_RE.test(f.path)) out.risky.push(f.path);
|
||
}
|
||
out.risky = out.risky.slice(0, 20);
|
||
return out;
|
||
}
|
||
|
||
async function addHfRepoToCatalog(repo, description = "") {
|
||
repo = hfRepoFromInput(repo);
|
||
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
|
||
const q = await hfRepoQuant(repo, "Q4_K_M");
|
||
if (!q) {
|
||
const err = new Error("In diesem Repo gibt es kein GGUF — das braucht die Box (llama.cpp).");
|
||
err.alternatives = await hfGgufAlternatives(repo);
|
||
throw err;
|
||
}
|
||
const cat = loadLlmCatalog();
|
||
let id = slugModelId(repo);
|
||
const same = cat.find(m => m.hfRepo === repo);
|
||
if (same) return { models: cat, id: same.id, existed: true };
|
||
while (cat.some(m => m.id === id)) id += "-2";
|
||
cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants,
|
||
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf",
|
||
tools: await hfToolSupport(repo), uncensored: UNCENSORED_RE.test(repo),
|
||
safety: await hfRepoSafety(repo), popularity: await hfPopularity(repo),
|
||
added_at: new Date().toISOString().slice(0, 10) });
|
||
saveLlmCatalog(cat);
|
||
return { models: cat, id, existed: false };
|
||
}
|
||
|
||
// ── 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}`;
|
||
}
|
||
|
||
// JSON.stringify-Replacer fuer Log-Zeilen: Werte von Geheimnis-Schluesseln
|
||
// (HF-Token, Passwoerter, API-Keys) kommen nie ins Log.
|
||
const SECRET_KEY_RE = /token(?!s)|secret|passw|apikey|api_key|credential/i;
|
||
function redactSecrets(key, value) {
|
||
return key && value && SECRET_KEY_RE.test(key) && typeof value !== "object" ? "***" : value;
|
||
}
|
||
|
||
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, kind: s.kind || "lan",
|
||
online: (now - (s.last_seen || 0)) < 300000,
|
||
}));
|
||
}
|
||
|
||
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
|
||
const credsRevealWaiters = new Map();
|
||
|
||
function vpnRvs() {
|
||
return { host: RVS_HOST, port: RVS_PORT, tls: RVS_TLS, tlsFallback: RVS_TLS_FALLBACK,
|
||
token: RVS_TOKEN, sni: process.env.RVS_SNI || "",
|
||
publicPort: process.env.VPN_PUBLIC_PORT || "443" };
|
||
}
|
||
|
||
async function broadcastVpn() {
|
||
try { broadcast({ type: "vpn_list", ...(await vpn.list()) }); }
|
||
catch (e) { log("warn", "vpn", "Liste: " + e.message); }
|
||
}
|
||
|
||
async function handleVpn(ws, msg) {
|
||
const reply = (body) => { try { ws.send(JSON.stringify({ type: "vpn_result", requestId: msg.requestId || "", action: msg.action, ...body })); } catch { /* weg */ } };
|
||
try {
|
||
switch (msg.action) {
|
||
case "vpn_list":
|
||
ws.send(JSON.stringify({ type: "vpn_list", ...(await vpn.list()) }));
|
||
return;
|
||
case "vpn_check":
|
||
reply({ ok: true, check: vpn.checkConf(msg.conf) });
|
||
return;
|
||
case "vpn_get":
|
||
reply({ ok: true, connection: vpn.getConf(String(msg.id || "")) });
|
||
return;
|
||
case "vpn_profile":
|
||
reply({ ok: true, profile: vpn.profile(String(msg.id || ""), msg.type === "fritz" ? "fritz" : "standard", vpnRvs()) });
|
||
return;
|
||
case "vpn_info":
|
||
reply({ ok: true, info: await vpn.info(String(msg.id || "")) });
|
||
return;
|
||
case "vpn_save":
|
||
reply({ ok: true, ...(await vpn.save(msg, vpnRvs())) });
|
||
log("info", "vpn", `Verbindung gespeichert: ${String(msg.name || "").slice(0, 60)}`);
|
||
break;
|
||
case "vpn_toggle":
|
||
reply({ ok: true, ...(await vpn.toggle(String(msg.id || ""), !!msg.enabled, vpnRvs())) });
|
||
log("info", "vpn", `Verbindung ${msg.id} ${msg.enabled ? "an" : "aus"}`);
|
||
break;
|
||
case "vpn_delete":
|
||
reply({ ok: true, ...(await vpn.remove(String(msg.id || ""))) });
|
||
log("info", "vpn", `Verbindung ${msg.id} geloescht`);
|
||
break;
|
||
case "vpn_settings":
|
||
reply({ ok: true, settings: vpn.setSettings(msg.settings || {}) });
|
||
break;
|
||
default:
|
||
reply({ ok: false, error: "unbekannte Aktion" });
|
||
return;
|
||
}
|
||
broadcastVpn();
|
||
} catch (e) {
|
||
reply({ ok: false, error: e.message });
|
||
broadcastVpn();
|
||
}
|
||
}
|
||
|
||
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,
|
||
// LLM-Karten (Belegung, Zustand, VRAM). Fehlte hier — die Box schickte sie,
|
||
// aber diese Feldliste liess sie nie zum Browser durch ("meldet keine Karten").
|
||
slots: Array.isArray(w.slots) ? w.slots : null,
|
||
knownModels: w.knownModels || [],
|
||
disk: w.disk || null, // LLM-Box: freier Platz + heruntergeladene Modelle
|
||
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();
|
||
// Beim RVS vorstellen (Liste "Wer ist im Raum?")
|
||
try {
|
||
ws.send(JSON.stringify({ type: "client_hello", payload: {
|
||
kind: "Diagnostic", name: require("os").hostname(), detail: "ARIA-Diagnostic" },
|
||
timestamp: Date.now() }));
|
||
} catch (_) {}
|
||
|
||
// 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,
|
||
kind: p.kind || "lan",
|
||
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 === "room_clients") {
|
||
// Antwort des RVS: wer ist in unserem Raum → an den Browser
|
||
broadcast({ type: "room_clients", payload: msg.payload || {} });
|
||
} 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 === "llm_runtime_result" || msg.type === "llm_runtime_progress") {
|
||
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||
} else if (msg.type === "sat_result") {
|
||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
|
||
const p = msg.payload || {};
|
||
const asker = credsRevealWaiters.get(p.requestId);
|
||
if (asker) {
|
||
credsRevealWaiters.delete(p.requestId);
|
||
if (asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: "sat_result", payload: p }));
|
||
} else if (!(p.result && typeof p.result === "object" && "kennwort" in p.result)) { // Kennwort ohne Fragenden: verwerfen
|
||
broadcast({ type: "sat_result", payload: p });
|
||
}
|
||
} 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.
|
||
// Mit Klartext-Passwoertern (Auge im Formular) NUR an den Browser, der
|
||
// gefragt hat — nie an alle offenen Diagnostic-Tabs.
|
||
const p = msg.payload || {};
|
||
if (p.revealed) {
|
||
const asker = credsRevealWaiters.get(p.requestId);
|
||
credsRevealWaiters.delete(p.requestId);
|
||
if (asker && asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: msg.type, payload: p }));
|
||
} else {
|
||
broadcast({ type: msg.type, payload: p });
|
||
}
|
||
} 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] : []),
|
||
// Nur llm: pro LLM-Karte das Modell + Zustand. "models" ist dann nur
|
||
// noch die Auswahl der GELADENEN Modelle; "knownModels" das, was man
|
||
// in eine Karte laden koennte.
|
||
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
|
||
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []),
|
||
disk: p.disk || prev.disk || null,
|
||
// 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;
|
||
// LLM-Boxen schicken mit jedem Ping die Karten-Belegung — Laden,
|
||
// Entladen und Neustarts sind so nach spaetestens 10 s sichtbar.
|
||
if (Array.isArray(p.slots)) w.slots = p.slots;
|
||
if (Array.isArray(p.models)) w.models = p.models;
|
||
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).
|
||
const p = msg.payload || {};
|
||
if (p.key && p.instanceId && !p.removed && !p.unloaded) {
|
||
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
|
||
if (job) broadcast({ type: "llm_provision_progress", payload: job });
|
||
}
|
||
broadcast({ type: "llm_provision_result", payload: p });
|
||
} else if (msg.type === "llm_provision_progress") {
|
||
// Download-Fortschritt einer Box (Prozent, Tempo, Restzeit, dann
|
||
// "laedt in die Karte" und "geladen"). Merken fuer spaeter geoeffnete Tabs.
|
||
const job = llmJobUpdate((msg.payload || {}).instanceId, (msg.payload || {}).key, msg.payload || {});
|
||
if (job) broadcast({ type: "llm_provision_progress", payload: job });
|
||
} 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, redactSecrets).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", () => {});
|
||
}
|
||
|
||
// Wie sendToRVS_withResponse, aber als Promise fuer den Server selbst:
|
||
// eigene Verbindung, wartet auf expectType mit passender requestId.
|
||
function rvsRequest(sendType, sendPayload, expectType, timeoutMs = 60000) {
|
||
return new Promise((resolve) => {
|
||
if (!RVS_HOST || !RVS_TOKEN) return resolve({ ok: false, error: "RVS nicht konfiguriert" });
|
||
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
||
const ws2 = new WebSocket(`${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`);
|
||
const done = (v) => { clearTimeout(t); try { ws2.close(); } catch (_) {} resolve(v); };
|
||
const t = setTimeout(() => done({ ok: false, error: "Zeitüberschreitung" }), timeoutMs);
|
||
ws2.on("open", () => ws2.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })));
|
||
ws2.on("message", (raw) => {
|
||
try {
|
||
const r = JSON.parse(raw.toString());
|
||
if (r.type === expectType && (r.payload || {}).requestId === sendPayload.requestId) done(r.payload || {});
|
||
} catch {}
|
||
});
|
||
ws2.on("error", (e) => done({ ok: false, error: e.message }));
|
||
});
|
||
}
|
||
|
||
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 ────────────────
|
||
|
||
// ── Modell-Downloads auf LLM-Boxen ──────────────────────────────────
|
||
// Letzter bekannter Stand pro (Box, Modell). Im Speicher reicht: das Fenster
|
||
// soll zeigen, was gerade laeuft und was eben fertig wurde, keine Historie.
|
||
const llmJobs = new Map();
|
||
function llmJobUpdate(instanceId, key, patch) {
|
||
if (!instanceId || !key) return null;
|
||
const id = `${instanceId}\u0000${key}`;
|
||
const job = { ...(llmJobs.get(id) || { instanceId, key, startedAt: Date.now() }), ...patch,
|
||
instanceId, key, updatedAt: Date.now() };
|
||
// Ein neuer Auftrag fuer dasselbe Modell faengt frisch an.
|
||
if (patch.phase === "queued") { delete job.error; delete job.percent; delete job.bytes; job.startedAt = Date.now(); }
|
||
llmJobs.set(id, job);
|
||
// Alte Abgeschlossene nicht ewig mitschleppen.
|
||
if (llmJobs.size > 30) {
|
||
const done = [...llmJobs.entries()].filter(([, j]) => j.phase === "ready" || j.phase === "error")
|
||
.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
||
while (llmJobs.size > 30 && done.length) llmJobs.delete(done.shift()[0]);
|
||
}
|
||
return job;
|
||
}
|
||
|
||
const htmlPath = path.join(__dirname, "index.html");
|
||
const mailHtmlPath = path.join(__dirname, "email.html");
|
||
|
||
// ── ARIA E-Mail: Admin-Weg zur Konfiguration ────────────────────────
|
||
// Die Brain-Endpunkte /email/config + /email/test verlangen den Header
|
||
// X-Aria-Admin. Der Schluessel liegt in ./aria-data/brain/data — hier unter
|
||
// /brain/data eingehaengt, in ARIAs Container (proxy) NICHT.
|
||
//
|
||
// Haken: ARIAs Container erreicht die Diagnostic ueber das Docker-Netz.
|
||
// Wuerde die Diagnostic den Schluessel blind anhaengen, koennte ARIA per curl
|
||
// ueber diesen Umweg doch die Whitelist aendern. Darum: Anfragen, die aus
|
||
// ihrem Container kommen, bekommen den Schluessel nicht.
|
||
const MAIL_ADMIN_KEY_FILE = "/brain/data/email_admin.key";
|
||
const dnsPromises = require("dns").promises;
|
||
|
||
async function requestFromAriaContainer(req) {
|
||
const ip = String(req.socket.remoteAddress || "").replace(/^::ffff:/, "");
|
||
for (const host of ["aria-proxy", "proxy"]) {
|
||
try {
|
||
const addrs = await dnsPromises.lookup(host, { all: true });
|
||
if (addrs.some(a => a.address === ip)) return true;
|
||
} catch (_) { /* Name unbekannt → naechster */ }
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function mailAdminKey() {
|
||
try { return fs.readFileSync(MAIL_ADMIN_KEY_FILE, "utf-8").trim(); } catch (_) { return ""; }
|
||
}
|
||
|
||
async function handleMailAdmin(req, res, brainPath) {
|
||
const reply = (code, obj) => {
|
||
res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
||
res.end(JSON.stringify(obj));
|
||
};
|
||
if (await requestFromAriaContainer(req)) {
|
||
log("warn", "server", `E-Mail-Konfiguration: Zugriff aus ARIAs Container abgewiesen (${req.method} ${brainPath})`);
|
||
return reply(403, { detail: "Die E-Mail-Konfiguration aendert nur Stefan in der Diagnostic." });
|
||
}
|
||
const key = mailAdminKey();
|
||
if (!key) return reply(503, { detail: "Brain hat noch keinen Admin-Schluessel angelegt — laeuft aria-brain?" });
|
||
let body = "";
|
||
req.on("data", c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
|
||
req.on("end", () => {
|
||
const proxyReq = http.request({
|
||
host: "aria-brain", port: 8080, path: brainPath, method: req.method,
|
||
headers: { "Content-Type": "application/json", "X-Aria-Admin": key,
|
||
...(body ? { "Content-Length": Buffer.byteLength(body) } : {}) },
|
||
timeout: 60000,
|
||
}, (pr) => {
|
||
let out = "";
|
||
pr.on("data", c => out += c);
|
||
pr.on("end", () => {
|
||
res.writeHead(pr.statusCode || 502, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
||
res.end(out);
|
||
});
|
||
});
|
||
proxyReq.on("error", (err) => reply(503, { detail: "Brain nicht erreichbar: " + err.message }));
|
||
if (body) proxyReq.write(body);
|
||
proxyReq.end();
|
||
});
|
||
}
|
||
|
||
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 === "/email.html") {
|
||
// ARIAs Postfach in eigenem Fenster (Button in Einstellungen → ARIA E-Mail).
|
||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
||
res.end(fs.readFileSync(mailHtmlPath, "utf-8"));
|
||
} else if (req.url === "/api/email-admin/config" && (req.method === "GET" || req.method === "POST")) {
|
||
handleMailAdmin(req, res, "/email/config");
|
||
return;
|
||
} else if (req.url === "/api/email-admin/test" && req.method === "POST") {
|
||
handleMailAdmin(req, res, "/email/test");
|
||
return;
|
||
} 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, removed: r.removed }));
|
||
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.startsWith("/api/aria-proof") && req.method === "GET") {
|
||
// Belege zu einer ARIA-Antwort: alle Werkzeug-Aufrufe (Bash/ssh, Read, …),
|
||
// die ARIA im Proxy zwischen Stefans Frage und dieser Antwort gemacht hat.
|
||
// Quelle: /shared/logs/aria-tools/<tag>.jsonl (proxy-patches/routes.js).
|
||
const u = new URL(req.url, "http://x");
|
||
const to = Number(u.searchParams.get("ts")) || 0;
|
||
const project = u.searchParams.get("project") || "";
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
if (!to) { res.end(JSON.stringify({ ok: false, error: "ts fehlt" })); return; }
|
||
try { res.end(JSON.stringify({ ok: true, ...ariaProof(to, project) })); }
|
||
catch (e) { res.end(JSON.stringify({ ok: false, error: e.message })); }
|
||
} else if (req.url.startsWith("/api/llm-discover") && req.method === "GET") {
|
||
const u = new URL(req.url, "http://x");
|
||
discoverGguf(u.searchParams.get("cat") || "chat", u.searchParams.get("trusted") !== "0")
|
||
.then(items => {
|
||
const inCat = new Set(loadLlmCatalog().map(m => m.hfRepo));
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ ok: true, items: items.map(i => ({ ...i, inCatalog: inCat.has(i.repo) })) }));
|
||
})
|
||
.catch(err => {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ ok: false, error: err.message, items: [] }));
|
||
});
|
||
} else if (req.url.startsWith("/api/llm-model-info") && req.method === "GET") {
|
||
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
|
||
llmModelInfo(repo)
|
||
.then(info => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...info })); })
|
||
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
|
||
} else if (req.url.startsWith("/api/llm-model-summary") && req.method === "GET") {
|
||
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
|
||
llmModelSummary(repo)
|
||
.then(r => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: !!r.summary, ...r })); })
|
||
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
|
||
} else if (req.url === "/api/llm-catalog/add" && req.method === "POST") {
|
||
let body = "";
|
||
req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); });
|
||
req.on("end", () => {
|
||
let repo = "", manual = false;
|
||
try { const b = JSON.parse(body || "{}"); repo = b.hfRepo; manual = !!b.manual; } catch {}
|
||
addHfRepoToCatalog(repo, manual ? "Von Hand hinzugefügt" : "")
|
||
.then(r => {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ ok: true, ...r }));
|
||
if (!r.existed) log("info", "llm", `Katalog: ${repo} aus den HF-Trends übernommen (${r.id})`);
|
||
})
|
||
.catch(err => {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ ok: false, error: err.message, alternatives: err.alternatives || [] }));
|
||
});
|
||
});
|
||
} 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",
|
||
});
|
||
// E-Mail-Passwort + Admin-Schluessel NICHT ins Backup: ein Export
|
||
// wandert auf andere Rechner, in Cloud-Ordner, an Kumpels. Nach einem
|
||
// Import traegt Stefan das Passwort neu ein, der Schluessel entsteht
|
||
// beim Brain-Start von selbst.
|
||
const tar = spawn("tar", ["czf", "-", "-C", "/brain",
|
||
"--exclude=./data/email_secret.json",
|
||
"--exclude=./data/email_admin.key", "."]);
|
||
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 === "room_clients_query") {
|
||
// Nur ueber die feste Verbindung — die Antwort kommt auf ihr zurueck
|
||
if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
|
||
rvsWs.send(JSON.stringify({ type: "room_clients_query", payload: {}, timestamp: Date.now() }));
|
||
} else {
|
||
ws.send(JSON.stringify({ type: "room_clients", payload: { error: "Diagnostic ist nicht mit dem RVS verbunden", clients: [] } }));
|
||
}
|
||
} 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;
|
||
}
|
||
if (String(msg.cmd || "") === "fritzbox.backup_password" && msg.requestId) {
|
||
credsRevealWaiters.set(String(msg.requestId), ws);
|
||
setTimeout(() => credsRevealWaiters.delete(String(msg.requestId)), 60000);
|
||
}
|
||
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 (typeof msg.action === "string" && msg.action.startsWith("vpn_")) {
|
||
// VPN-Satelliten (Einstellungen → VPN). Antworten nur an DIESEN Browser —
|
||
// vpn_get enthaelt den privaten Schluessel. Aenderungen → Liste an alle.
|
||
handleVpn(ws, msg);
|
||
} else if (msg.action === "llm_runtime") {
|
||
// llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen.
|
||
const op = String(msg.op || "status");
|
||
if (!["status", "check", "update", "rollback"].includes(op)) return;
|
||
sendToRVS_raw({
|
||
type: "llm_runtime",
|
||
payload: {
|
||
instanceId: String(msg.instanceId || ""),
|
||
op,
|
||
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") {
|
||
const requestId = "dc_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
|
||
if (msg.reveal) {
|
||
credsRevealWaiters.set(requestId, ws);
|
||
setTimeout(() => credsRevealWaiters.delete(requestId), 30000);
|
||
}
|
||
sendToRVS_raw({ type: "sat_creds_list",
|
||
payload: { satellite: msg.satellite || "", ip: msg.ip || "", reveal: !!msg.reveal, requestId },
|
||
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 === "get_llm_jobs") {
|
||
ws.send(JSON.stringify({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } }));
|
||
} else if (msg.action === "clear_llm_jobs") {
|
||
// Nur Abgeschlossene wegraeumen — laufende Downloads bleiben sichtbar.
|
||
for (const [k, j] of llmJobs) if (j.phase === "ready" || j.phase === "error") llmJobs.delete(k);
|
||
broadcast({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } });
|
||
} else if (msg.action === "llm_provision_model") {
|
||
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
|
||
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
|
||
// sieht man das im Download-Fenster, statt dass einfach nichts passiert.
|
||
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
|
||
{ phase: "queued", requestedAt: Date.now(), downloadOnly: !!msg.downloadOnly });
|
||
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
|
||
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,
|
||
cpuMoe: msg.cpuMoe ?? 0, // MoE: 0 Karte, -1 alle Experten in den RAM, -2 automatisch verteilen
|
||
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
|
||
downloadOnly: !!msg.downloadOnly, // nur auf die Platte, in keine Karte
|
||
}, timestamp: Date.now() });
|
||
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
||
} else if (msg.action === "llm_unload_model") {
|
||
// LLM-Karte leeren (das GGUF bleibt auf der Box).
|
||
sendToRVS_raw({ type: "llm_unload_model", payload: {
|
||
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
|
||
log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`);
|
||
} else if (msg.action === "llm_remove_model") {
|
||
// cancel = laufenden Download abbrechen, deleteFiles = Dateien von der Platte
|
||
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
||
targetInstance: msg.targetInstance || "", key: msg.key, hfRepo: msg.hfRepo || "",
|
||
cancel: !!msg.cancel, deleteFiles: !!msg.deleteFiles }, timestamp: Date.now() });
|
||
log("info", "llm", `${msg.cancel ? "abbrechen" : msg.deleteFiles ? "Dateien loeschen" : "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 === "set_fallback_model") {
|
||
handleSetFallbackModel(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)
|
||
const model = String(obj.model || ""); // welches Claude-Modell
|
||
const modelReason = String(obj.modelReason || ""); // einstellung/pro-chat/ausweich
|
||
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, model, modelReason });
|
||
}
|
||
|
||
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,
|
||
fallbackModel: (cfg.brainFallbackModel || "").trim(),
|
||
info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten",
|
||
}));
|
||
} catch (err) {
|
||
clientWs.send(JSON.stringify({ type: "model_info", error: err.message }));
|
||
}
|
||
}
|
||
|
||
// Ausweich-Modell fuer Anthropics Sicherheitsfilter. Leerer String =
|
||
// automatisch (das Brain nimmt dann ein anderes Tier als das Hauptmodell).
|
||
// Anders als brainModel braucht das KEINEN Neustart: safeguard.py liest die
|
||
// Datei erst, wenn der Filter wirklich zuschlaegt.
|
||
function handleSetFallbackModel(clientWs, model) {
|
||
try {
|
||
const val = typeof model === "string" ? model.trim() : "";
|
||
writeRuntimeConfig({ brainFallbackModel: val });
|
||
log("info", "server", `Ausweich-Model gesetzt: ${val || "(automatisch)"}`);
|
||
const cfg = readRuntimeConfig();
|
||
clientWs.send(JSON.stringify({
|
||
type: "model_info",
|
||
model: (cfg.brainModel || "").trim() || "claude-sonnet-4",
|
||
fallbackModel: val,
|
||
info: val
|
||
? `Ausweich-Model: ${val} — greift sofort, kein Neustart noetig`
|
||
: "Ausweich-Model: automatisch (anderes Tier als das Hauptmodell)",
|
||
}));
|
||
} 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();
|
||
});
|