Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9354283af | ||
|
|
bf1db6cbf9 | ||
|
|
ea159d4ed7 | ||
|
|
dd1e1ac458 | ||
|
|
ddd77c7ad4 | ||
|
|
7c3c932054 | ||
|
|
d6c7502810 | ||
|
|
dc77933014 | ||
|
|
6a6b320f37 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20501
|
||||
versionName "0.2.5.1"
|
||||
versionCode 20502
|
||||
versionName "0.2.5.2"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.1",
|
||||
"version": "0.2.5.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -1303,6 +1303,10 @@ const ChatScreen: React.FC = () => {
|
||||
sender: 'user',
|
||||
text: diagText,
|
||||
timestamp: message.timestamp,
|
||||
// projectId MUSS mit: die Chat-Ansicht filtert danach. Ohne das
|
||||
// landete die Nachricht im Hauptchat-Topf und war in jedem Projekt
|
||||
// unsichtbar — man sah nur ARIAs Antwort, nicht die eigene Frage.
|
||||
projectId: (message.payload.projectId as string) || '',
|
||||
}]));
|
||||
}
|
||||
return;
|
||||
@@ -1802,9 +1806,20 @@ const ChatScreen: React.FC = () => {
|
||||
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
||||
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
||||
if (ev.audioRequestId) {
|
||||
setMessages(prev => prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
// 'no-response' heisst: die Spracherkennung hat GAR NICHT geantwortet
|
||||
// (Worker neu gestartet / nicht erreichbar) — die Aufnahme ist also
|
||||
// wirklich verloren gegangen, Du hast nicht etwa nichts gesagt. Das
|
||||
// zeigen wir an, statt die Bubble stillschweigend zu entfernen; sonst
|
||||
// wirkt es, als waere die Nachricht nie gesendet worden.
|
||||
const lost = String(ev.reason || '').includes('no-response');
|
||||
setMessages(prev => lost
|
||||
? prev.map(m => (m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))
|
||||
? { ...m, text: '⚠️ Aufnahme ging verloren — die Spracherkennung war gerade nicht erreichbar. Bitte nochmal aufnehmen.' }
|
||||
: m)
|
||||
: prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
}
|
||||
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
|
||||
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
|
||||
|
||||
@@ -104,11 +104,7 @@ class RVSConnection {
|
||||
// Verbindungen + doppelte Events.
|
||||
if (this.ws) {
|
||||
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
|
||||
try {
|
||||
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
} catch (_) {}
|
||||
this.detachSocket(this.ws);
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
@@ -215,7 +211,12 @@ class RVSConnection {
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
|
||||
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
||||
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
|
||||
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
|
||||
// liefern und vortaeuschen, es sei alles in Ordnung.
|
||||
if (socket !== this.ws) return;
|
||||
try {
|
||||
const message: RVSMessage = JSON.parse(event.data as string);
|
||||
this.notifyMessageListeners(message);
|
||||
@@ -258,8 +259,7 @@ class RVSConnection {
|
||||
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
|
||||
this.clearTimers();
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
|
||||
try { this.ws.close(); } catch (_) {}
|
||||
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
|
||||
}
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
@@ -302,6 +302,25 @@ class RVSConnection {
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
|
||||
*
|
||||
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
|
||||
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
|
||||
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
|
||||
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
|
||||
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
|
||||
*/
|
||||
private detachSocket(ws: WebSocket): void {
|
||||
try {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null; // <- das fehlte
|
||||
ws.onclose = null; // verhindert doppelten Reconnect
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
|
||||
+79
-8
@@ -1549,14 +1549,18 @@ META_TOOLS = [
|
||||
"description": (
|
||||
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
|
||||
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
|
||||
"3. jellyfin_play. Laeuft die Jellyfin-App auf dem Zielgeraet noch nicht, "
|
||||
"vorher host_app_launch (query='Jellyfin') aufrufen."
|
||||
"3. jellyfin_play. Prueft selbst nach, ob die Wiedergabe wirklich "
|
||||
"angelaufen ist. GIB IMMER 'host' MIT (Name des Host-Agenten am "
|
||||
"Fernseher, z.B. 'Fire-TV Schlafzimmer'): dann starte ich Jellyfin bei "
|
||||
"einer eingeschlafenen Sitzung automatisch neu und lege nochmal nach, "
|
||||
"statt faelschlich Erfolg zu melden."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_id": {"type": "string", "description": "itemId aus jellyfin_search."},
|
||||
"device": {"type": "string", "description": "Geraete-/Sitzungsname aus jellyfin_devices (z.B. 'Fire TV')."},
|
||||
"host": {"type": "string", "description": "Host-Agent am Geraet (fuer automatischen Neustart bei toter Sitzung)."},
|
||||
"server": {"type": "string", "description": "Server-Name (optional)."},
|
||||
"resume": {"type": "boolean", "description": "Wenn moeglich fortsetzen statt von vorn (Default true)."},
|
||||
},
|
||||
@@ -2876,9 +2880,15 @@ class Agent:
|
||||
except Exception as exc:
|
||||
return None, f"Jellyfin nicht erreichbar: {exc}"
|
||||
|
||||
def _jellyfin_find_session(self, server: dict, device: str):
|
||||
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext)."""
|
||||
sessions, err = self._jellyfin_call(server, "/Sessions")
|
||||
def _jellyfin_find_session(self, server: dict, device: str, fresh_sec: int = 600):
|
||||
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext).
|
||||
|
||||
`activeWithinSeconds` filtert eingeschlafene Sitzungen aus: Jellyfin
|
||||
listet Clients noch eine Weile, nachdem die App laengst weg ist — ein
|
||||
Play-Befehl an so eine Karteileiche verpufft wortlos.
|
||||
"""
|
||||
sessions, err = self._jellyfin_call(
|
||||
server, "/Sessions", params={"activeWithinSeconds": fresh_sec})
|
||||
if err:
|
||||
return None, err
|
||||
d = (device or "").strip().lower()
|
||||
@@ -2895,6 +2905,27 @@ class Agent:
|
||||
f"Geraet GEOEFFNET sein — sonst vorher host_app_launch "
|
||||
f"(query='Jellyfin') aufrufen.")
|
||||
|
||||
def _jellyfin_playing(self, server: dict, sess: dict, item_id: str,
|
||||
tries: int = 4, wait: float = 1.5) -> bool:
|
||||
"""Prueft, ob auf der Sitzung wirklich etwas abgespielt wird.
|
||||
|
||||
Jellyfin quittiert einen Play-Befehl auch dann mit 204, wenn die Sitzung
|
||||
laengst tot ist — ohne diese Kontrolle meldet man faelschlich Erfolg.
|
||||
"""
|
||||
want = (item_id or "").strip()
|
||||
for _ in range(max(1, tries)):
|
||||
time.sleep(wait)
|
||||
sessions, err = self._jellyfin_call(server, "/Sessions", timeout=10.0)
|
||||
if err:
|
||||
continue
|
||||
for s in (sessions or []):
|
||||
if s.get("Id") != sess.get("Id"):
|
||||
continue
|
||||
now = s.get("NowPlayingItem") or {}
|
||||
if now and (not want or now.get("Id") == want):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _dispatch_jellyfin(self, name: str, arguments: dict) -> str:
|
||||
server = self._jellyfin_server(arguments.get("server") or "")
|
||||
if not server:
|
||||
@@ -2925,7 +2956,10 @@ class Agent:
|
||||
return f"Treffer auf {label}:\n" + "\n".join(lines)
|
||||
|
||||
if name == "jellyfin_devices":
|
||||
sessions, err = self._jellyfin_call(server, "/Sessions")
|
||||
# Nur Sitzungen, die zuletzt aktiv waren — sonst listet Jellyfin
|
||||
# auch laengst geschlossene Apps, an die kein Befehl mehr geht.
|
||||
sessions, err = self._jellyfin_call(
|
||||
server, "/Sessions", params={"activeWithinSeconds": 600})
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
out = []
|
||||
@@ -2967,8 +3001,45 @@ class Agent:
|
||||
params=play_params)
|
||||
if err:
|
||||
return f"FEHLER: {err}"
|
||||
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' gestartet"
|
||||
f"{note} (Server {label}).")
|
||||
|
||||
# Nachsehen, ob wirklich etwas laeuft. Eine eingeschlafene Sitzung
|
||||
# nimmt den Befehl wortlos an, ohne dass der Film startet — genau
|
||||
# der Fall "beim zweiten Mal ging es nicht".
|
||||
if self._jellyfin_playing(server, sess, item_id):
|
||||
# Ausdruecklich vermerken, dass NICHTS repariert werden musste —
|
||||
# sonst erzaehlt das Modell gern die Panne aus einem frueheren
|
||||
# Turn nach ("Session war eingeschlafen"), obwohl alles glatt lief.
|
||||
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' laeuft"
|
||||
f"{note} (Server {label}). Sitzung war aktiv, kein Neustart "
|
||||
f"noetig — erwaehne also KEINEN Neustart und KEINE "
|
||||
f"eingeschlafene Sitzung.")
|
||||
|
||||
# Sitzung war tot. Wenn wir den zugehoerigen Agenten kennen:
|
||||
# Jellyfin dort neu starten, frische Sitzung holen, einmal
|
||||
# nachlegen. Sonst ehrlich sagen, was zu tun ist.
|
||||
host = (arguments.get("host") or "").strip()
|
||||
if not host:
|
||||
return ("FEHLER: Der Befehl wurde angenommen, aber es laeuft nichts — "
|
||||
"die Jellyfin-Sitzung auf dem Geraet war eingeschlafen. "
|
||||
"Starte die Jellyfin-App neu (host_app_launch mit "
|
||||
"query='Jellyfin') und versuche es erneut. Tipp: Gib "
|
||||
"jellyfin_play den Parameter 'host' mit (Name des "
|
||||
"Host-Agenten), dann mache ich das automatisch.")
|
||||
self._dispatch_host("host_app_launch", {"host": host, "query": "Jellyfin"})
|
||||
time.sleep(6) # App braucht einen Moment, bis sie sich anmeldet
|
||||
sess2, err2 = self._jellyfin_find_session(server, device, fresh_sec=120)
|
||||
if err2:
|
||||
return (f"FEHLER: Jellyfin auf '{host}' neu gestartet, aber es meldet "
|
||||
f"sich keine Sitzung: {err2}")
|
||||
self._jellyfin_call(server, f"/Sessions/{sess2['Id']}/Playing",
|
||||
method="POST", params=play_params)
|
||||
if self._jellyfin_playing(server, sess2, item_id):
|
||||
return (f"OK — Wiedergabe laeuft auf '{sess2.get('DeviceName')}'"
|
||||
f"{note}. (Die Sitzung war eingeschlafen, ich habe Jellyfin "
|
||||
f"auf '{host}' neu gestartet.)")
|
||||
return (f"FEHLER: Auch nach dem Neustart von Jellyfin auf '{host}' startet "
|
||||
f"die Wiedergabe nicht. Laeuft die App auf dem Bildschirm? "
|
||||
f"Ggf. dort einmal anmelden.")
|
||||
|
||||
if name == "jellyfin_control":
|
||||
device = (arguments.get("device") or "").strip()
|
||||
|
||||
+60
-1
@@ -2278,6 +2278,7 @@
|
||||
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
|
||||
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
|
||||
if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; }
|
||||
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; }
|
||||
if (msg.type === 'agent_history_list') {
|
||||
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
|
||||
renderAgentHistory(msg.payload.events || []);
|
||||
@@ -4621,7 +4622,9 @@
|
||||
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
|
||||
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
|
||||
'</div>';
|
||||
'</div>' +
|
||||
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
|
||||
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
|
||||
}).join('');
|
||||
// Auslastungs-Button pro Node — Ziel bevorzugt die llm-Instanz (sieht alle
|
||||
// GPUs + Tokens), sonst irgendein online Worker des Node.
|
||||
@@ -4634,6 +4637,62 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── OpenAI-Endpunkt einer LLM-Box (fuer OpenHands, Continue, ...) ──
|
||||
// Der PORT kommt immer aus der .env der Box (Docker-Port-Mapping, steht beim
|
||||
// Container-Start fest) und wird hier nur angezeigt. Der API-Key ist hier
|
||||
// setzbar — ausser er steht in der .env, dann hat die Vorrang und das Feld
|
||||
// ist schreibgeschuetzt.
|
||||
function renderOpenAiEndpoint(w) {
|
||||
const id = 'oa-' + (w.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
|
||||
const port = w.openaiPort || '8081';
|
||||
const bind = w.openaiBind || '127.0.0.1';
|
||||
const lanOpen = bind === '0.0.0.0';
|
||||
const locked = !!w.openaiEnvLocked;
|
||||
const keyState = w.openaiKeySet
|
||||
? '<span style="color:#34C759;">Key gesetzt</span>'
|
||||
: '<span style="color:#FF9500;">kein Key — Endpunkt offen</span>';
|
||||
const warn = (lanOpen && !w.openaiKeySet)
|
||||
? '<div style="color:#FF6E6E;margin-top:4px;">⚠ Im LAN erreichbar OHNE Key — jeder im Netz kann das Modell nutzen.</div>'
|
||||
: '';
|
||||
const field = locked
|
||||
? '<input type="text" value="aus .env übernommen" disabled ' +
|
||||
'style="flex:1;background:#15151F;color:#666;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
|
||||
'<span style="color:#8888AA;font-size:10px;">🔒 .env hat Vorrang</span>'
|
||||
: '<input type="password" id="' + id + '" placeholder="API-Key setzen (leer = entfernen)" ' +
|
||||
'style="flex:1;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
|
||||
'<button class="btn secondary" onclick="saveOpenAiKey(\'' + escapeHtml(w.instanceId) + '\',\'' + id + '\')" ' +
|
||||
'style="padding:2px 8px;font-size:10px;">Speichern</button>';
|
||||
return '<div style="margin:2px 0 8px 18px;padding:6px 8px;background:#0D0D1A;border-radius:6px;font-size:11px;">' +
|
||||
'<div style="color:#8888AA;">OpenAI-Endpunkt (OpenHands & Co.): ' +
|
||||
'<code style="color:#FFD60A;">http://' + escapeHtml(bind === '0.0.0.0' ? '<box-ip>' : '127.0.0.1') +
|
||||
':' + escapeHtml(port) + '/v1</code> · ' + keyState + '</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center;margin-top:5px;">' + field + '</div>' +
|
||||
'<div id="' + id + '-msg" style="color:#8888AA;margin-top:4px;"></div>' +
|
||||
'<div style="color:#666;margin-top:3px;">Port und Erreichbarkeit kommen aus der <code>.env</code> der Box ' +
|
||||
'(<code>LLM_OPENAI_PORT</code>, <code>LLM_OPENAI_BIND</code>) — sie stehen beim Container-Start fest.</div>' +
|
||||
warn + '</div>';
|
||||
}
|
||||
|
||||
function saveOpenAiKey(instanceId, fieldId) {
|
||||
const el = document.getElementById(fieldId);
|
||||
const msg = document.getElementById(fieldId + '-msg');
|
||||
if (!el) return;
|
||||
openAiReqId = 'oa_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
|
||||
if (msg) msg.textContent = 'speichere …';
|
||||
send({ action: 'llm_openai_config', instanceId, apiKey: el.value, requestId: openAiReqId });
|
||||
}
|
||||
let openAiReqId = null;
|
||||
|
||||
function onOpenAiConfigResult(p) {
|
||||
if (!p || (openAiReqId && p.requestId && p.requestId !== openAiReqId)) return;
|
||||
const id = 'oa-' + String(p.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
|
||||
const msg = document.getElementById(id + '-msg');
|
||||
if (!msg) return;
|
||||
msg.innerHTML = p.ok
|
||||
? '<span style="color:#34C759;">✓ ' + escapeHtml(p.message || 'gespeichert') + '</span>'
|
||||
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(p.error || 'fehlgeschlagen') + '</span>';
|
||||
}
|
||||
|
||||
// Sprung von den Einstellungen zur Compute-Flotte (dort sitzt der
|
||||
// Auslastung-Button pro Node → Live nvidia-smi + Graphen).
|
||||
function gotoComputeFleet() {
|
||||
|
||||
@@ -656,6 +656,8 @@ function workerList() {
|
||||
return Array.from(workers.values()).map(w => ({
|
||||
instanceId: w.instanceId, service: w.service, node: w.node,
|
||||
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
|
||||
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
|
||||
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
|
||||
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
||||
}));
|
||||
}
|
||||
@@ -1180,6 +1182,8 @@ function connectRVS(forcePlain) {
|
||||
} else if (msg.type === "rooms_info") {
|
||||
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||
} else if (msg.type === "llm_openai_config_result") {
|
||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_result") {
|
||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||
broadcast({ type: "sat_result", payload: msg.payload || {} });
|
||||
@@ -1209,6 +1213,11 @@ function connectRVS(forcePlain) {
|
||||
instanceId: p.instanceId, service: p.service || "",
|
||||
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||
openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
|
||||
openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
|
||||
busy: !!prev.busy, last_seen: Date.now(),
|
||||
});
|
||||
broadcastWorkers();
|
||||
@@ -2964,6 +2973,17 @@ wss.on("connection", (ws) => {
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "llm_openai_config") {
|
||||
// Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
|
||||
sendToRVS_raw({
|
||||
type: "llm_openai_config",
|
||||
payload: {
|
||||
instanceId: String(msg.instanceId || ""),
|
||||
apiKey: String(msg.apiKey || ""),
|
||||
requestId: String(msg.requestId || ""),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "agent_history_query") {
|
||||
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
||||
// antwortet mit agent_history_list (auf derselben rvsWs).
|
||||
|
||||
@@ -113,6 +113,9 @@ const ALLOWED_TYPES = new Set([
|
||||
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
|
||||
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
|
||||
"worker_hello", "worker_ping", "worker_update", "worker_list",
|
||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
||||
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||
"llm_openai_config", "llm_openai_config_result",
|
||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||
"node_stats_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
|
||||
+38
-1
@@ -4,7 +4,7 @@
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
# ─── Welche Dienste startet DIESER Node? ──────────
|
||||
# Komma-getrennt aus: voxtral, whisper, f5tts, llm
|
||||
# Komma-getrennt aus: voxtral, whisper, f5tts, llm, openhands
|
||||
# Nur die aufgefuehrten Dienste starten bei `docker compose up -d`.
|
||||
# nur STT-Box (Voxtral) → COMPOSE_PROFILES=voxtral
|
||||
# nur STT-Box (Whisper) → COMPOSE_PROFILES=whisper
|
||||
@@ -13,6 +13,12 @@
|
||||
# Kleine Karte (wenig VRAM): Whisper + F5-TTS auf EINER GPU
|
||||
# → COMPOSE_PROFILES=whisper,f5tts
|
||||
# All-in-One, grosse Karte → COMPOSE_PROFILES=voxtral,f5tts,llm
|
||||
# LLM + OpenHands-Oberflaeche → COMPOSE_PROFILES=llm,openhands
|
||||
# 'openhands' ist REIN OPTIONAL (Standalone-Entwickler-Agent, s.u.). Wer es nicht
|
||||
# auffuehrt, bei dem wird der Container gar nicht erst angelegt — das Image wird
|
||||
# nicht einmal geladen. Fuer den normalen ARIA-Betrieb also einfach weglassen.
|
||||
# ACHTUNG: Profile brauchen compose v2 (`docker compose`, mit Leerzeichen). Das
|
||||
# alte `docker-compose` 1.x behandelt sie unzuverlaessig.
|
||||
# Hinweis: voxtral UND whisper zusammen NICHT sinnvoll — beide sind STT und
|
||||
# wuerden dieselbe Anfrage doppelt beantworten. Pro Node genau EINEN STT waehlen:
|
||||
# Voxtral-3B (~9 GB, beste Qualitaet) ODER Whisper (klein, passt neben F5-TTS).
|
||||
@@ -56,3 +62,34 @@ RVS_SNI=
|
||||
# WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic)
|
||||
# WHISPER_LANGUAGE=de
|
||||
# LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml
|
||||
|
||||
# ─── OpenAI-Endpunkt des lokalen LLM (optional) ───
|
||||
# llama-swap ist OpenAI-kompatibel (/v1/chat/completions, /v1/models). Damit
|
||||
# koennen Fremd-Tools direkt auf das lokale Modell: OpenHands, Continue,
|
||||
# Open WebUI, Aider ... Base-URL dann: http://<box>:<LLM_OPENAI_PORT>/v1
|
||||
#
|
||||
# LLM_OPENAI_BIND steuert, WER drankommt:
|
||||
# 127.0.0.1 nur diese Maschine (Default, sicher)
|
||||
# 0.0.0.0 das ganze LAN → dann UNBEDINGT LLM_OPENAI_KEY setzen!
|
||||
LLM_OPENAI_BIND=127.0.0.1
|
||||
LLM_OPENAI_PORT=8081
|
||||
# API-Key. Leer = Endpunkt ohne Authentifizierung (nur mit BIND=127.0.0.1 ok).
|
||||
# WICHTIG: Steht hier ein Wert, hat er VORRANG — das Feld ist dann in der ARIA-
|
||||
# Diagnostic schreibgeschuetzt. Leer lassen, wenn Du den Key lieber dort pro Box
|
||||
# setzen willst (er wird dann auf der Box unter /models/openai_key.json abgelegt).
|
||||
# Der Key wirkt, sobald ein Modell das naechste Mal geladen wird.
|
||||
LLM_OPENAI_KEY=
|
||||
|
||||
# ─── OpenHands (optional, eigenes Profil) ─────────
|
||||
# Standalone-Entwickler-Agent, der das lokale LLM nutzt. Start:
|
||||
# COMPOSE_PROFILES=llm,openhands docker compose up -d
|
||||
# Dann http://<box>:<OPENHANDS_PORT> aufrufen.
|
||||
# ACHTUNG: OpenHands startet eigene Arbeits-Container und bekommt dafuer den
|
||||
# Docker-Socket — das ist faktisch Root-Zugriff auf diese Maschine. Deshalb
|
||||
# BIND auf 127.0.0.1 lassen und von aussen per SSH-Tunnel zugreifen:
|
||||
# ssh -L 3000:127.0.0.1:3000 user@box -> lokal http://localhost:3000
|
||||
OPENHANDS_BIND=127.0.0.1
|
||||
OPENHANDS_PORT=3000
|
||||
# Wie OpenHands das lokale Modell anspricht (Container-intern, daher :8080):
|
||||
OPENHANDS_LLM_BASE_URL=http://llama-swap:8080/v1
|
||||
OPENHANDS_LLM_MODEL=openai/qwen3-8b
|
||||
|
||||
@@ -70,3 +70,83 @@ nvidia-smi # Host sieht die GPU
|
||||
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
|
||||
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
|
||||
```
|
||||
|
||||
## Lokales LLM von außen nutzen (OpenAI-Endpunkt)
|
||||
|
||||
`llama-swap` spricht **OpenAI-kompatibel** (`/v1/chat/completions`, `/v1/models`).
|
||||
Damit können Fremd-Tools direkt auf das lokale Modell: **OpenHands**, Continue,
|
||||
Open WebUI, Aider …
|
||||
|
||||
```
|
||||
Base-URL: http://<box>:<LLM_OPENAI_PORT>/v1
|
||||
Modell: qwen3-8b (oder ein anderer Key aus llama-swap/config.yaml)
|
||||
API-Key: LLM_OPENAI_KEY (falls gesetzt)
|
||||
```
|
||||
|
||||
In der `.env`:
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `LLM_OPENAI_BIND` | `127.0.0.1` = nur diese Maschine (**Default, sicher**) · `0.0.0.0` = ganzes LAN |
|
||||
| `LLM_OPENAI_PORT` | Host-Port (Default `8081`) |
|
||||
| `LLM_OPENAI_KEY` | API-Key; leer = **ohne Authentifizierung** |
|
||||
|
||||
> ⚠️ `LLM_OPENAI_BIND=0.0.0.0` **ohne** `LLM_OPENAI_KEY` heißt: jeder im Netz kann
|
||||
> das Modell benutzen. Entweder Key setzen oder auf localhost gebunden lassen.
|
||||
|
||||
**Wo wird was konfiguriert?**
|
||||
|
||||
- **Port und Erreichbarkeit** immer in der `.env`. Beides ist ein Docker-Port-Mapping
|
||||
und steht beim Container-Start fest — es lässt sich nicht aus der Ferne ändern.
|
||||
- **API-Key**: entweder in der `.env` **oder** — wenn dort leer — in der ARIA-Diagnostic
|
||||
unter *Compute-Flotte* bei der jeweiligen Box. Dort abgelegt landet er auf der Box
|
||||
unter `/models/openai_key.json` (Rechte 0600).
|
||||
- **Die `.env` hat immer Vorrang.** Steht `LLM_OPENAI_KEY` dort, ist das Feld in der
|
||||
Diagnostic **schreibgeschützt** (🔒-Hinweis).
|
||||
- Ein geänderter Key greift, sobald ein Modell das **nächste Mal geladen** wird
|
||||
(llama-server bekommt ihn per `--api-key`). Laufendes Modell also einmal entladen
|
||||
lassen (TTL) oder ein anderes Modell anfragen.
|
||||
|
||||
Prüfen:
|
||||
```bash
|
||||
curl -s http://localhost:8081/v1/models # ohne Key
|
||||
curl -s -H "Authorization: Bearer $LLM_OPENAI_KEY" \
|
||||
http://localhost:8081/v1/models # mit Key
|
||||
```
|
||||
|
||||
## OpenHands (optionales Profil)
|
||||
|
||||
OpenHands ist **rein optional** und liegt in einem eigenen Profil. Wer es nicht in
|
||||
`COMPOSE_PROFILES` aufführt, bei dem wird der Container **gar nicht erst angelegt** —
|
||||
das Image wird nicht einmal geladen. Für den normalen ARIA-Betrieb also einfach weglassen:
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=voxtral,f5tts,llm docker compose up -d # ohne OpenHands
|
||||
COMPOSE_PROFILES=llm,openhands docker compose up -d # mit OpenHands
|
||||
```
|
||||
|
||||
Dann `http://<box>:<OPENHANDS_PORT>` (Default `3000`) öffnen. Das lokale Modell ist
|
||||
schon vorbelegt (`OPENHANDS_LLM_BASE_URL`, `OPENHANDS_LLM_MODEL`) und in der
|
||||
Oberfläche änderbar.
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `OPENHANDS_BIND` | `127.0.0.1` (Default) oder `0.0.0.0` fürs LAN |
|
||||
| `OPENHANDS_PORT` | Host-Port (Default `3000`) |
|
||||
| `OPENHANDS_LLM_BASE_URL` | Default `http://llama-swap:8080/v1` (Container-intern — **nicht** der Host-Port) |
|
||||
| `OPENHANDS_LLM_MODEL` | Default `openai/qwen3-8b` |
|
||||
|
||||
> ⚠️ OpenHands startet eigene Arbeits-Container und bekommt dafür den **Docker-Socket**.
|
||||
> Das ist faktisch Root-Zugriff auf diese Maschine — wer die Oberfläche erreicht,
|
||||
> kann im Zweifel alles auf der Box.
|
||||
>
|
||||
> **Deshalb bleibt `OPENHANDS_BIND` auf `127.0.0.1`.** Von einem anderen Rechner
|
||||
> greifst Du per SSH-Tunnel zu, statt den Port ins LAN zu öffnen:
|
||||
> ```bash
|
||||
> ssh -L 3000:127.0.0.1:3000 user@box # dann lokal http://localhost:3000
|
||||
> ```
|
||||
> Das kostet nichts und ist die wirksamste Absicherung, solange der Socket
|
||||
> durchgereicht wird. Wer echte Isolation braucht: OpenHands in eine eigene VM,
|
||||
> oder einen rootless Docker-Daemon als Beiwagen statt des Host-Sockets.
|
||||
|
||||
Ohne das Profil ändert sich nichts: Wer nur ARIAs Box fahren will, startet OpenHands gar nicht.
|
||||
|
||||
@@ -139,6 +139,13 @@ services:
|
||||
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
|
||||
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
|
||||
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
|
||||
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
|
||||
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
|
||||
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
|
||||
# Zugriff aus dem LAN LLM_OPENAI_BIND=0.0.0.0 setzen — dann aber unbedingt
|
||||
# LLM_OPENAI_KEY vergeben, sonst kann jeder im Netz das Modell nutzen.
|
||||
ports:
|
||||
- "${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081}:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
|
||||
@@ -172,3 +179,33 @@ services:
|
||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── OpenHands (optional, Standalone) ────────────────
|
||||
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
|
||||
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
|
||||
# ARIAs Box fahren will, startet es gar nicht erst.
|
||||
# COMPOSE_PROFILES=llm,openhands docker compose up -d
|
||||
# Danach http://<box>:${OPENHANDS_PORT:-3000} und dort als Provider
|
||||
# "OpenAI-kompatibel" mit Base-URL http://llama-swap:8080/v1 eintragen
|
||||
# (aus dem Container heraus erreichbar), Modell z.B. qwen3-8b.
|
||||
openhands:
|
||||
image: docker.all-hands.dev/all-hands-ai/openhands:latest
|
||||
container_name: aria-openhands
|
||||
profiles: ["openhands"]
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest
|
||||
- LOG_ALL_EVENTS=true
|
||||
# Vorbelegung fuer das lokale Modell — in der Oberflaeche aenderbar.
|
||||
- LLM_BASE_URL=${OPENHANDS_LLM_BASE_URL:-http://llama-swap:8080/v1}
|
||||
- LLM_API_KEY=${LLM_OPENAI_KEY:-local}
|
||||
- LLM_MODEL=${OPENHANDS_LLM_MODEL:-openai/qwen3-8b}
|
||||
volumes:
|
||||
# OpenHands startet seine Arbeits-Container selbst -> braucht den Socket.
|
||||
# ACHTUNG: das gibt dem Container Kontrolle ueber den Docker-Host.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./openhands-state:/.openhands
|
||||
ports:
|
||||
- "${OPENHANDS_BIND:-127.0.0.1}:${OPENHANDS_PORT:-3000}:3000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -27,6 +27,8 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import time
|
||||
|
||||
import httpx
|
||||
@@ -94,6 +96,44 @@ BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
|
||||
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
|
||||
|
||||
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
|
||||
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
|
||||
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
|
||||
# Diagnostic gesetzt werden, ABER: steht er in der .env, hat die .env Vorrang
|
||||
# und das Feld ist in der Diagnostic schreibgeschuetzt.
|
||||
OPENAI_PORT = os.getenv("LLM_OPENAI_PORT", "8081").strip() or "8081"
|
||||
OPENAI_BIND = os.getenv("LLM_OPENAI_BIND", "127.0.0.1").strip() or "127.0.0.1"
|
||||
_OPENAI_KEY_ENV = os.getenv("LLM_OPENAI_KEY", "").strip()
|
||||
OPENAI_KEY_FILE = os.getenv("LLM_OPENAI_KEY_FILE", "/models/openai_key.json")
|
||||
|
||||
|
||||
def _openai_key() -> str:
|
||||
"""Gueltiger API-Key: .env gewinnt, sonst der in der Diagnostic gesetzte."""
|
||||
if _OPENAI_KEY_ENV:
|
||||
return _OPENAI_KEY_ENV
|
||||
try:
|
||||
with open(OPENAI_KEY_FILE) as f:
|
||||
return str((json.load(f) or {}).get("key") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _openai_key_save(key: str) -> bool:
|
||||
"""Speichert den in der Diagnostic gesetzten Key. Bei .env-Vorrang: nein."""
|
||||
if _OPENAI_KEY_ENV:
|
||||
return False
|
||||
try:
|
||||
os.makedirs(os.path.dirname(OPENAI_KEY_FILE), exist_ok=True)
|
||||
tmp = OPENAI_KEY_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump({"key": key.strip()}, f)
|
||||
os.replace(tmp, OPENAI_KEY_FILE)
|
||||
os.chmod(OPENAI_KEY_FILE, 0o600)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("OpenAI-Key speichern fehlgeschlagen: %s", exc)
|
||||
return False
|
||||
|
||||
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
|
||||
import node_stats
|
||||
STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json")
|
||||
@@ -144,6 +184,19 @@ def _generate_config() -> int:
|
||||
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
|
||||
"ttl": 3600,
|
||||
}
|
||||
# API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) —
|
||||
# llama-server prueft dann den Authorization-Header. Ohne Key bleibt der
|
||||
# Endpunkt offen; deshalb ist er per Default nur an localhost gebunden.
|
||||
key = _openai_key()
|
||||
for k, spec in list(models.items()):
|
||||
cmd = str((spec or {}).get("cmd") or "")
|
||||
if not cmd:
|
||||
continue
|
||||
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen
|
||||
if key:
|
||||
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
|
||||
models[k] = dict(spec, cmd=cmd)
|
||||
|
||||
out = dict(base)
|
||||
out["models"] = models
|
||||
try:
|
||||
@@ -270,6 +323,13 @@ async def _announce(ws) -> None:
|
||||
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
|
||||
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
|
||||
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
|
||||
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
|
||||
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
|
||||
"openaiPort": OPENAI_PORT,
|
||||
"openaiBind": OPENAI_BIND,
|
||||
"openaiKeySet": bool(_openai_key()),
|
||||
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
|
||||
})
|
||||
logger.info("worker_hello: models=%s", models)
|
||||
|
||||
@@ -355,6 +415,45 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
||||
})
|
||||
|
||||
|
||||
async def _handle_openai_config(ws, payload: dict) -> None:
|
||||
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
|
||||
|
||||
Der PORT wird hier bewusst NICHT geaendert: er ist ein Docker-Port-Mapping
|
||||
und steht beim Container-Start fest — dafuer ist die .env zustaendig.
|
||||
Steht der Key in der .env, gewinnt sie und wir lehnen ab.
|
||||
"""
|
||||
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
key = str(payload.get("apiKey") or "").strip()
|
||||
if _OPENAI_KEY_ENV:
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
|
||||
"error": "Der API-Key steht in der .env dieser Box — die hat Vorrang. "
|
||||
"Zum Aendern dort anpassen und die Box neu starten.",
|
||||
})
|
||||
return
|
||||
if not _openai_key_save(key):
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
|
||||
"error": "Key konnte nicht gespeichert werden (Schreibrechte auf /models?)",
|
||||
})
|
||||
return
|
||||
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
|
||||
_generate_config()
|
||||
logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)",
|
||||
"gesetzt" if key else "entfernt")
|
||||
await _send(ws, "llm_openai_config_result", {
|
||||
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
|
||||
"keySet": bool(key),
|
||||
"message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal "
|
||||
"geladen wird — bereits laufende Modelle einmal entladen lassen "
|
||||
"(TTL) oder ein anderes Modell anfragen."
|
||||
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
|
||||
})
|
||||
await _announce(ws)
|
||||
|
||||
|
||||
async def _handle_provision(ws, payload: dict) -> None:
|
||||
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
|
||||
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
|
||||
@@ -464,6 +563,9 @@ async def _run() -> None:
|
||||
# Auslastungs-Monitor (node_stats_*) abfangen.
|
||||
if await _stats.handle(ws, mtype, payload, _send):
|
||||
continue
|
||||
if mtype == "llm_openai_config":
|
||||
asyncio.create_task(_handle_openai_config(ws, payload))
|
||||
continue
|
||||
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
|
||||
continue
|
||||
if mtype == "llm_provision_model":
|
||||
|
||||
Reference in New Issue
Block a user