Compare commits

..
9 Commits
Author SHA1 Message Date
duffyduck b9354283af release: bump version to 0.2.5.2 2026-10-02 01:14:25 +02:00
duffyduckandClaude Opus 4.8 bf1db6cbf9 fix(app): Diagnostic-Nachrichten verschwanden in Projekten (projectId fehlte)
Stefan: 'die antwort kam auf der app, aber meine frage ist nicht in der app'.
Die App zeigt Diagnostic-Nachrichten durchaus an (sender=diagnostic -> als
User-Bubble), legte sie aber OHNE projectId ab. Die Chat-Ansicht filtert nach
Projekt (ChatScreen.tsx:2078) — damit war die Nachricht nur im Hauptchat
sichtbar und in jedem Projekt unsichtbar. ARIAs Antwort trug die projectId und
erschien korrekt; nur die eigene Frage fehlte. Jetzt wird die mitgeschickte
projectId uebernommen. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:11:18 +02:00
duffyduckandClaude Opus 4.8 ea159d4ed7 fix(app): Zombie-Verbindung — App zeigt 'getrennt', empfaengt aber weiter
Symptom (live beobachtet): die App steht auf 'getrennt' und nichts, was Stefan
schreibt oder spricht, erreicht den Server — ARIAs Antwort und TTS kommen aber
trotzdem in der App an.

Ursache: Beim Verwerfen einer Verbindung wurden nur onclose und onerror
entfernt, NICHT onmessage. Die aufgegebene Verbindung lieferte deshalb weiter
Nachrichten an die App-Listener (daher die ankommende Antwort), waehrend this.ws
auf die neue/tote zeigte — send() lehnte ab und der Zustand blieb
'disconnected'. Betraf beide Abriss-Pfade (Neu-Connect und TLS-Fallback).

Fix: detachSocket() klemmt eine Verbindung vollstaendig ab (onopen/onmessage/
onclose/onerror + close) und wird in beiden Pfaden genutzt. Zusaetzlich ignoriert
der onmessage-Handler Nachrichten, die nicht von der AKTUELLEN Verbindung
stammen — sonst taeuscht ein Zombie weiter Betrieb vor. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:08:37 +02:00
duffyduckandClaude Opus 4.8 dd1e1ac458 fix(app): verlorene Aufnahme sichtbar machen statt still zu loeschen
Beweis aus den Logs: die Whisper-Bridge startete MITTEN in der ersten Aufnahme
neu (stream.start 16:35:43 -> boot 16:36:02), damit war die Sitzung weg. Die App
fiel in den 3s-Fallback (reason=...no-response) und entfernte die Bubble
kommentarlos — fuer den Nutzer sah es aus, als waere nie etwas gesendet worden.

Jetzt wird bei 'no-response' die Platzhalter-Bubble durch einen Hinweis ersetzt
('Aufnahme ging verloren — Spracherkennung war nicht erreichbar'). Der stille
no-speech-Fall (Du hast wirklich nichts gesagt) bleibt wie bisher. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 17:08:42 +02:00
duffyduckandClaude Opus 4.8 ddd77c7ad4 docs(xtts): openhands-Profil in .env.example und README sichtbar machen
Das Profil existierte bereits in der compose-Datei, tauchte aber nicht in der
Profil-Liste der .env.example auf — man konnte also nicht sehen, dass es waehlbar
(und per Default AUS) ist. Jetzt dort gelistet, mit dem Hinweis, dass ohne
Eintrag gar kein Container angelegt wird, plus Warnung, dass Profile compose v2
brauchen (das alte docker-compose 1.x behandelt sie unzuverlaessig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:40:14 +02:00
duffyduckandClaude Opus 4.8 7c3c932054 docs(xtts): OpenHands — SSH-Tunnel statt LAN-Port als Absicherung
Der Host-Socket bleibt (bewusste Entscheidung fuer den Heimbetrieb). Dafuer der
wirksamste kostenlose Schutz explizit dokumentiert: OPENHANDS_BIND bleibt auf
127.0.0.1, Zugriff von anderen Rechnern per 'ssh -L 3000:127.0.0.1:3000'. Dazu
der Hinweis, was echte Isolation waere (eigene VM / rootless Daemon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:34:14 +02:00
duffyduckandClaude Opus 4.8 d6c7502810 feat(xtts): OpenAI-Endpunkt fuers lokale LLM + OpenHands-Profil
llama-swap ist bereits OpenAI-kompatibel, war aber nur im Compose-Netz erreichbar
(kein ports-Mapping). Jetzt nutzbar fuer OpenHands, Continue, Open WebUI & Co.

- compose: llama-swap veroeffentlicht 8080 als
  ${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081} — sicher per Default
  (nur localhost), fuer LAN bewusst BIND=0.0.0.0 setzen.
- llm-adapter: API-Key wird in JEDEN llama-server-Befehl der generierten
  llama-swap-Config eingehaengt (--api-key, shlex-gequotet). Quelle: .env
  (Vorrang) sonst /models/openai_key.json (0600). Neuer Handler
  llm_openai_config setzt ihn zur Laufzeit und lehnt bei .env-Vorrang ab.
  worker_hello meldet Port/Bind/keySet/envLocked — den Key NIE (Broadcast!).
- Diagnostic: pro LLM-Box Endpunkt-Zeile mit Base-URL, Key-Status und Eingabe.
  Bei .env-Vorrang ist das Feld schreibgeschuetzt (🔒). Warnung, wenn im LAN
  offen ohne Key. Der PORT wird nur ANGEZEIGT: er ist ein Docker-Port-Mapping
  und steht beim Container-Start fest — dafuer ist die .env zustaendig.
- rvs: llm_openai_config(_result) in ALLOWED_TYPES (sonst still verworfen).
- Neues Profil 'openhands' (COMPOSE_PROFILES=llm,openhands) mit Port/Bind/Modell
  aus der .env, lokales LLM vorbelegt. Ohne Profil aendert sich nichts.
- .env.example + README: alle Variablen, Sicherheitshinweise (offener Endpunkt,
  Docker-Socket bei OpenHands), curl-Test und wann der Key greift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:30:37 +02:00
duffyduckandClaude Opus 4.8 dc77933014 fix(jellyfin): Erfolgsmeldung sagt ausdruecklich, dass kein Neustart noetig war
Nachgeprueft: der Selbstheilungs-Fix lief korrekt — die Wiedergabe startete auf
Anhieb (Nachkontrolle positiv), es gab KEINEN App-Neustart (kein host_app_launch
in der Agent-Historie). ARIA behauptete trotzdem 'Session war eingeschlafen, ich
hab Jellyfin neu gestartet' — nacherzaehlt aus dem vorherigen Turn, wo das
tatsaechlich noetig war.

Die Erfolgsmeldung stellt jetzt klar, dass nichts repariert werden musste, damit
das Modell die alte Panne nicht weitererzaehlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:01:54 +02:00
duffyduckandClaude Opus 4.8 6a6b320f37 fix(jellyfin): eingeschlafene Sitzung erkennen und selbst neu aufbauen
Beim zweiten Film ging nichts mehr ('Session abgelaufen') — ARIA musste Jellyfin
von Hand neu starten. Ursache: Jellyfin listet Clients noch, wenn die App laengst
weg ist, und quittiert den Play-Befehl auch dann mit Erfolg. Es lief nur nichts.

- /Sessions wird jetzt mit activeWithinSeconds gefiltert (Play + devices) —
  Karteileichen tauchen gar nicht erst als Ziel auf.
- _jellyfin_playing(): nach dem Play-Befehl wird nachgesehen, ob NowPlayingItem
  wirklich gesetzt ist (bis ~6s). Ohne das meldet man faelschlich Erfolg.
- Laeuft nichts und ist 'host' gesetzt: Jellyfin wird ueber den Host-Agenten neu
  gestartet (host_app_launch), frische Sitzung geholt, einmal nachgelegt — also
  genau der Handgriff, den ARIA vorher manuell machen musste. Ohne 'host' gibt es
  eine klare Handlungsanweisung statt eines stillen Fehlschlags.
- Tool-Beschreibung draengt auf 'host', damit die Selbstheilung greift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 21:50:43 +02:00
12 changed files with 466 additions and 23 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+18 -3
View File
@@ -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
+26 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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 &amp; Co.): ' +
'<code style="color:#FFD60A;">http://' + escapeHtml(bind === '0.0.0.0' ? '&lt;box-ip&gt;' : '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() {
+20
View File
@@ -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).
+3
View File
@@ -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
View File
@@ -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
+80
View File
@@ -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.
+37
View File
@@ -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
+102
View File
@@ -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":