Compare commits
24
Commits
d684bbb5f0
...
v0.2.5.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf9e93c2a | ||
|
|
db2055ff3b | ||
|
|
5a59ea3930 | ||
|
|
7b4a1f4429 | ||
|
|
2267f0a726 | ||
|
|
8cc85c1fe1 | ||
|
|
88036bcad3 | ||
|
|
35436bff3c | ||
|
|
b82df7dfca | ||
|
|
3b63831c86 | ||
|
|
7da552f249 | ||
|
|
79a901089b | ||
|
|
f8ae2f4613 | ||
|
|
32d34f39cd | ||
|
|
f0d6ae63a0 | ||
|
|
9be14eef5e | ||
|
|
901cd1c16b | ||
|
|
c26b27f09f | ||
|
|
60f2d2e087 | ||
|
|
a1aa90177d | ||
|
|
ae79d9e8f6 | ||
|
|
23feb3ba2d | ||
|
|
89cb3a765b | ||
|
|
864237ff2c |
@@ -73,3 +73,10 @@ GITEA_USER=duffyduck
|
||||
# Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de)
|
||||
# Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren.
|
||||
RVS_UPDATE_HOST=
|
||||
|
||||
# ── LLM-Cluster ───────────────────────────────────────────────────────
|
||||
# Die ARIA-Bridge verteilt Anfragen an die lokalen Modelle aller KI-Boxen
|
||||
# (llm_cluster_* ueber RVS). Wie lange sie auf eine Antwort wartet, bevor sie
|
||||
# eine andere Box versucht bzw. aufgibt:
|
||||
#CLUSTER_LLM_TIMEOUT_S=600
|
||||
# OpenAI-kompatible Programme (OpenHands & Co.): xtts/openai_rvs_wrapper
|
||||
|
||||
@@ -544,6 +544,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
|
||||
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
|
||||
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
|
||||
- **LLM-Cluster: Programme fragen ein Modell, der Cluster waehlt die Karte** — die ARIA-Bridge ([`bridge/llm_cluster.py`](bridge/llm_cluster.py)) kennt alle Boxen und Karten. Programme schicken `llm_cluster_request` ueber RVS (ohne Box/Karte), die Bridge waehlt unter allen Karten mit diesem Modell die mit den wenigsten laufenden Anfragen, und faellt eine Box mitten in der Anfrage aus (kein Ping mehr), geht sie einmal an eine andere. `llm_cluster_models` liefert jedes Modell **einmal** samt `ctx` (kleinstes Kontextfenster unter seinen Karten). Fehler kommen als Fehler (`model_unavailable` …), nie als Antwort-Text. ARIA selbst nutzt denselben Router (mit ihrer kurzen 30-s-Frist). Fuer Programme, die nur OpenAI koennen: [`xtts/openai_rvs_wrapper/`](xtts/openai_rvs_wrapper/) — kleiner Container NEBEN dem Programm, fuer das der Cluster dann `http://127.0.0.1:8099/v1` ist; Hilfsseite auf `/` zeigt Status und Modelle; braucht nur eine ausgehende Verbindung zum RVS (getestet mit dem offiziellen OpenAI-SDK). Schnittstelle: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md)
|
||||
- **Lokale Modelle: ein Modell pro Karte, Clients waehlen nur aus** — Schnittstelle fuer Drittprogramme: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md). Jede LLM-Karte einer Box (`LLM_GPUS=1,2` in der `.env`, alt `LLM_GPU=1`) haelt genau ein Modell, dauerhaft geladen; welches, legt nur die Diagnostic fest (Modell-Katalog → „Laden" → Ziel-**Karte**, in der Flotte pro Karte „Entladen"). Karten, die nicht fuers LLM vorgesehen sind, erscheinen dort gar nicht. Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port — sehen nur die geladenen Modelle aller Boxen und laden **nie** etwas: eine Anfrage fuer ein anderes Modell wird abgelehnt (`model_not_loaded` bzw. HTTP 404), nichts wird verdraengt. Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen (gemessen: zwei parallel in 4,5 s statt 10 s nacheinander). Technik: der llm-adapter schreibt eine llama-swap-Config nur aus den belegten Karten — pro Modell `CUDA_VISIBLE_DEVICES=<Karte>` (mit `CUDA_DEVICE_ORDER=PCI_BUS_ID`, damit die Nummern wie bei nvidia-smi zaehlen), `ttl: 0`, eine Gruppe mit `swap: false` und Preload beim Start; llama-swap laeuft mit `--watch-config` (die frueher aufgerufenen Reload-Endpunkte gab es nicht). Laden oder Entladen startet die anderen Karten der Box kurz neu. Schlaegt das Laden fehl, wird die Karte auf ihr vorheriges Modell zurueckgesetzt. Bestehende Boxen: das bisherige Standardmodell (`LLM_MODEL`) landet beim ersten Start auf der ersten LLM-Karte
|
||||
- **Lokale Modelle laden mit Fortschritt** (Modell-Katalog → „Laden", Knopf **⬇ Downloads**): echte Prozentanzeige mit Tempo und Restzeit, danach „laedt in die Karte" und am Ende „✓ in Karte N (Name) geladen" samt belegtem VRAM. llama.cpp zieht das GGUF per `-hf` selbst und meldet nichts; der llm-adapter fragt deshalb vorab bei Hugging Face, welche Dateien mit welcher Groesse und sha256 kommen, und misst genau diese im HF-Cache-Layout (`/models/models--org--repo/blobs/<sha256>.downloadInProgress`, nach Abschluss ohne Endung) — klappt auch bei gesplitteten Modellen und parallelen Downloads; liegt es schon im Cache, geht es direkt ans Laden. Der Warmup wartet jetzt bis zu `LLM_WARMUP_TIMEOUT_S` (1900 s) statt 60 s — vorher meldete ein mehrere GB grosses Modell „Fehler", waehrend llama-swap weiterlud. Ein Auftrag, auf den die Box nicht antwortet, erscheint nach 30 s als Warnung statt still zu verschwinden. Pro LLM-Box laeuft **ein** Modell auf ihrer LLM-Karte (`LLM_GPU`), ein anderes zu laden wechselt dorthin. *Wichtig:* `llm_provision_*` fehlte bis hierhin in der RVS-Allow-List — „Laden" hat seit Stage D nie eine Box erreicht
|
||||
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
|
||||
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
|
||||
- **Modell pro Chat**: jeder Chat (Hauptchat und jedes Projekt) kann sein eigenes Modell haben, ohne die Einstellung fuer alle umzustellen. Reihenfolge: **Einstellung** (`brainModel`) < **pro Chat** (`/shared/config/context_models.json`, gesetzt per 🧠-Auswahl neben „+ Projekt") < **Ausweich** (Sicherheitsfilter, s.u.). Angezeigt wird immer, was der **naechste Turn wirklich nutzt**: in den Kontext-Karten, in der Auswahl (orange „↪ läuft auf …" plus **„↩ zurück zu …"**, wenn gerade ausgewichen wird) und an jeder ARIA-Bubble — dort das Modell, das tatsaechlich geantwortet hat (aus `modelUsage` der CLI, also die aufgeloeste Version statt des Alias). Als Grundeinstellung zaehlt das Modell, mit dem das Brain **laeuft**, nicht der Dateiwert — ein ausstehender Neustart nach Aenderung von `brainModel` faellt so auf. In der **App** dasselbe hinter der Quell-Anzeige (Einstellungen, Default aus): Modell-Chip in der Kopfzeile, Tippen oeffnet die Auswahl fuer diesen Chat, die Bubbles zeigen das Modell statt nur „Claude"
|
||||
@@ -822,17 +825,31 @@ Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
|
||||
|
||||
```bash
|
||||
cd rvs
|
||||
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
|
||||
cp .env.example .env # Modus waehlen (COMPOSE_PROFILES) + Werte eintragen
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Zwei Modi** — in der `rvs/.env` per `COMPOSE_PROFILES`:
|
||||
|
||||
| Modus | `.env` | Wer macht TLS | Apps/Satelliten verbinden auf |
|
||||
|---|---|---|---|
|
||||
| **mit Caddy** | `COMPOSE_PROFILES=caddy` + `PUBLIC_URL` | Caddy im Stack (Let's Encrypt) | `PUBLIC_URL:CADDY_HTTPS_PORT` |
|
||||
| **ohne Caddy** | `COMPOSE_PROFILES=` (leer) | dein Reverse-Proxy (nginx, Traefik …) | Name + Port deines Proxys (meist 443) |
|
||||
|
||||
**Stack:**
|
||||
- `caddy` (TLS-Terminator + Let's Encrypt, lauscht auf 80+443)
|
||||
- `rvs` (WebSocket Relay + OAuth-Callback HTTP, nur intern auf Port 3000)
|
||||
- `rvs` (WebSocket Relay + OAuth-Callback HTTP). Unverschluesselter Port
|
||||
`RVS_HTTP_BIND:RVS_HTTP_PORT` (Default `127.0.0.1:3000`) — fuer den Reverse-Proxy.
|
||||
- `caddy` (nur Modus Caddy: TLS + Let's Encrypt auf 80 + `CADDY_HTTPS_PORT`)
|
||||
|
||||
Caddy holt automatisch ein Zertifikat fuer `PUBLIC_URL` via HTTP-01-Challenge.
|
||||
ACME-State persistent in `./data/caddy/` (gitignored) — kein Rate-Limit-Drama
|
||||
bei Container-Restart. WebSocket-Upgrades reicht Caddy transparent durch.
|
||||
Dafuer muss **Port 80 von aussen auf diesen Rechner** gehen — sonst schlaegt die
|
||||
Erneuerung still fehl und das Zertifikat laeuft nach 90 Tagen ab (Apps und
|
||||
Satelliten verbinden dann nicht mehr). ACME-State persistent in `./data/caddy/`
|
||||
(gitignored). WebSocket-Upgrades reicht Caddy transparent durch.
|
||||
|
||||
> **Bestehende Installation aktualisieren:** Caddy startet nur noch mit
|
||||
> `COMPOSE_PROFILES=caddy` in der `rvs/.env`. Wer Caddy weiter nutzt, traegt das
|
||||
> dort ein (sonst laeuft nach `git pull` + `up -d` kein TLS mehr).
|
||||
|
||||
**Features:**
|
||||
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
|
||||
@@ -850,10 +867,71 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
|
||||
|
||||
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
|
||||
|
||||
**Ohne Caddy / eigener TLS-Terminator:** Wenn Du schon einen Reverse-Proxy
|
||||
(nginx/Traefik) davor hast, kommentier den `caddy`-Service in der
|
||||
`rvs/docker-compose.yml` aus und gib `rvs` wieder einen `ports`-Block
|
||||
(z.B. `["3000:3000"]`). Dein Reverse-Proxy macht dann TLS und reicht weiter.
|
||||
**Ohne Caddy — eigener Reverse-Proxy:** in der `rvs/.env`
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=
|
||||
RVS_HTTP_BIND=0.0.0.0 # Proxy auf anderem Rechner; gleicher Rechner: 127.0.0.1
|
||||
RVS_HTTP_PORT=3000
|
||||
```
|
||||
|
||||
Steht der Proxy auf einem anderen Rechner, den Port per Firewall **nur fuer den
|
||||
Proxy** freigeben.
|
||||
|
||||
**Strecke Proxy → RVS verschluesseln:** `RVS_HTTP_TLS=true` — der RVS spricht
|
||||
dann HTTPS auf `RVS_HTTP_PORT`, mit einem selbstsignierten Zertifikat (beim
|
||||
ersten Start in `rvs/data/rvs-tls/` erzeugt; eigenes `rvs.crt`/`rvs.key` darf
|
||||
dort liegen). Im Proxy als Ziel `https://<ip>:<port>`, das Zertifikat dabei
|
||||
**nicht** pruefen (nginx: Default; Nginx Proxy Manager: Scheme `https`,
|
||||
„Websockets Support" an). Nur ohne Caddy — der spricht den RVS per HTTP an.
|
||||
|
||||
Umstellen von Caddy:
|
||||
`docker compose rm -sf caddy && docker compose up -d`.
|
||||
|
||||
> ⚠️ **WebSocket nicht vergessen!** Der RVS ist fast nur WebSocket. Ohne
|
||||
> WebSocket-Weiterleitung laedt im Browser zwar eine Seite (404 o.ae.), aber
|
||||
> App, Bridge, Satelliten und KI-Boxen bekommen **keine Verbindung** — ohne
|
||||
> klare Fehlermeldung. Beim Neueinrichten des Proxys immer pruefen:
|
||||
>
|
||||
> - **Nginx Proxy Manager:** im Proxy Host den Schalter **„Websockets Support"**
|
||||
> einschalten (bei `RVS_HTTP_TLS=true` zusaetzlich Scheme **`https`**).
|
||||
> - **nginx von Hand:** `proxy_http_version 1.1;` plus die beiden Header
|
||||
> `Upgrade` / `Connection "upgrade"` (siehe Beispiel unten) und lange
|
||||
> Timeouts — sonst trennt nginx die Verbindung nach 60 s Stille.
|
||||
> - **Test:** `curl -i -H "Connection: Upgrade" -H "Upgrade: websocket"
|
||||
> -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGVzdHRlc3R0ZXN0dGVzdA=="
|
||||
> "https://<dein-name>/?token=test"` muss mit **`101 Switching Protocols`**
|
||||
> antworten. Kommt 200/400/404, fehlt die WebSocket-Weiterleitung.
|
||||
|
||||
nginx-Beispiel (WebSocket braucht HTTP/1.1 + Upgrade-Header und lange Timeouts):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl; # Handys nehmen bevorzugt IPv6 — mit AAAA-Eintrag Pflicht
|
||||
server_name rvs.example.de;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/rvs.example.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/rvs.example.de/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://192.168.1.10:3000; # RVS_HTTP_BIND-IP : RVS_HTTP_PORT (https:// bei RVS_HTTP_TLS=true)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 3600s; # WebSocket bleibt lange offen
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
client_max_body_size 0; # APK-Download, Uploads
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apps, Satelliten, KI-Boxen, Host-Agenten und die Bridge (`RVS_HOST`/`RVS_PORT`
|
||||
in deren `.env`) dann auf den Namen und Port des Proxys umstellen.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+21
-3
@@ -6,15 +6,16 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
|
||||
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet, View } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { BottomTabBar, createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ViewModeToggle from './src/components/ViewModeToggle';
|
||||
import SecurityLock from './src/components/SecurityLock';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
@@ -138,6 +139,15 @@ const App: React.FC = () => {
|
||||
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
|
||||
<NavigationContainer theme={DarkTheme}>
|
||||
<Tab.Navigator
|
||||
tabBar={(props) => (
|
||||
// Tab-Leiste + Schloss-Symbol rechts neben dem Zahnrad
|
||||
<View style={styles.tabBarRow}>
|
||||
<View style={styles.tabBarTabs}>
|
||||
<BottomTabBar {...props} />
|
||||
</View>
|
||||
<SecurityLock onOpenSettings={() => props.navigation.navigate('Einstellungen')} />
|
||||
</View>
|
||||
)}
|
||||
screenOptions={({ route }) => ({
|
||||
headerStyle: styles.header,
|
||||
headerTitleStyle: styles.headerTitle,
|
||||
@@ -205,10 +215,18 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
tabBar: {
|
||||
tabBarRow: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#12122A',
|
||||
borderTopColor: '#1E1E2E',
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
tabBarTabs: {
|
||||
flex: 1,
|
||||
},
|
||||
tabBar: {
|
||||
backgroundColor: '#12122A',
|
||||
borderTopWidth: 0,
|
||||
height: 60,
|
||||
paddingBottom: 6,
|
||||
paddingTop: 4,
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20505
|
||||
versionName "0.2.5.5"
|
||||
versionCode 20508
|
||||
versionName "0.2.5.8"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
add(PhoneCallPackage())
|
||||
add(BackgroundAudioPackage())
|
||||
add(ShareIntentPackage())
|
||||
add(TlsTrustPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
@@ -42,6 +43,8 @@ class MainApplication : Application(), ReactApplication {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
SoLoader.init(this, false)
|
||||
// Vor der ersten Verbindung: Zertifikatspruefung (abgelaufen erlaubt?)
|
||||
TlsTrust.install(this)
|
||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
||||
load()
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.modules.network.NetworkingModule
|
||||
import com.facebook.react.modules.websocket.WebSocketModule
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertPathValidator
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.CertificateExpiredException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.PKIXParameters
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Date
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* Zertifikatspruefung fuer RVS-Verbindung (WebSocket) und fetch.
|
||||
*
|
||||
* Normal pruefen wir wie Android selbst. Mit dem Schalter "Abgelaufenes
|
||||
* Zertifikat akzeptieren" wird GENAU EIN Fehler toleriert: das Ablaufdatum.
|
||||
* Kette und Aussteller werden dann zum Zeitpunkt kurz vor dem Ablauf geprueft
|
||||
* (war das Zertifikat damals gueltig, ist es echt), der Hostname prueft OkHttp
|
||||
* ohnehin separat. Ein fremdes oder selbstgebautes Zertifikat kommt also auch
|
||||
* mit Schalter nicht durch — sonst koennte sich jeder dazwischenhaengen.
|
||||
*
|
||||
* Nebenbei merkt sich das Modul, wie die letzte Pruefung ausging, damit die
|
||||
* App das Schloss-Symbol richtig zeigen kann.
|
||||
*/
|
||||
object TlsTrust {
|
||||
private const val PREFS = "aria_tls"
|
||||
private const val KEY_ALLOW_EXPIRED = "allow_expired"
|
||||
|
||||
// ok | expired_accepted | expired | invalid | unknown
|
||||
@Volatile var lastStatus: String = "unknown"
|
||||
@Volatile var lastDetail: String = ""
|
||||
@Volatile var lastNotAfter: Long = 0
|
||||
@Volatile var lastSubject: String = ""
|
||||
@Volatile var allowExpired: Boolean = false
|
||||
private set
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun install(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
allowExpired = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_ALLOW_EXPIRED, false)
|
||||
try {
|
||||
val tm = ExpiryTolerantTrustManager(defaultTrustManager())
|
||||
val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
|
||||
val factory = ssl.socketFactory
|
||||
WebSocketModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
|
||||
NetworkingModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
|
||||
} catch (e: Exception) {
|
||||
// Lieber normale Pruefung als gar keine Verbindung
|
||||
android.util.Log.w("TlsTrust", "Eigene Zertifikatspruefung nicht aktiv: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAllowExpired(value: Boolean) {
|
||||
allowExpired = value
|
||||
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
?.edit()?.putBoolean(KEY_ALLOW_EXPIRED, value)?.apply()
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(null as KeyStore?)
|
||||
return tmf.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||
}
|
||||
|
||||
private fun remember(status: String, chain: Array<X509Certificate>?, detail: String = "") {
|
||||
lastStatus = status
|
||||
lastDetail = detail
|
||||
val leaf = chain?.firstOrNull()
|
||||
lastNotAfter = leaf?.notAfter?.time ?: 0
|
||||
lastSubject = leaf?.subjectX500Principal?.name ?: ""
|
||||
}
|
||||
|
||||
private fun isExpired(chain: Array<X509Certificate>): Boolean =
|
||||
chain.any { c ->
|
||||
try { c.checkValidity(); false } catch (_: CertificateExpiredException) { true } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
/** Kette gegen die System-Zertifizierungsstellen pruefen — zum Zeitpunkt
|
||||
* kurz vor dem fruehesten Ablauf in der Kette. */
|
||||
private fun chainValidBeforeExpiry(chain: Array<X509Certificate>) {
|
||||
val ks = KeyStore.getInstance("AndroidCAStore").apply { load(null) }
|
||||
val params = PKIXParameters(ks).apply {
|
||||
isRevocationEnabled = false
|
||||
date = Date(chain.minOf { it.notAfter.time } - 60_000)
|
||||
}
|
||||
// Mitgeschickte Wurzel (selbstsigniert) gehoert nicht in den Pfad — aber NIE
|
||||
// das Server-Zertifikat selbst: ein leerer Pfad gilt als gueltig, ein
|
||||
// selbstgebautes Zertifikat kaeme sonst durch.
|
||||
if (chain.isEmpty()) throw CertificateException("Leere Zertifikatskette")
|
||||
val path = listOf(chain[0]) + chain.drop(1).filter { it.subjectX500Principal != it.issuerX500Principal }
|
||||
val certPath = CertificateFactory.getInstance("X.509").generateCertPath(path)
|
||||
CertPathValidator.getInstance("PKIX").validate(certPath, params)
|
||||
}
|
||||
|
||||
private class ExpiryTolerantTrustManager(private val inner: X509TrustManager) : X509ExtendedTrustManager() {
|
||||
private val ext = inner as? X509ExtendedTrustManager
|
||||
|
||||
private fun check(chain: Array<X509Certificate>, run: () -> Unit) {
|
||||
try {
|
||||
run()
|
||||
remember("ok", chain)
|
||||
} catch (e: CertificateException) {
|
||||
if (!isExpired(chain)) {
|
||||
remember("invalid", chain, e.message ?: "")
|
||||
throw e
|
||||
}
|
||||
if (!allowExpired) {
|
||||
remember("expired", chain, e.message ?: "")
|
||||
throw e
|
||||
}
|
||||
try {
|
||||
chainValidBeforeExpiry(chain)
|
||||
} catch (inner: Exception) {
|
||||
// Abgelaufen UND sonst nicht in Ordnung → nicht akzeptieren
|
||||
remember("invalid", chain, inner.message ?: "")
|
||||
throw e
|
||||
}
|
||||
remember("expired_accepted", chain)
|
||||
}
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) =
|
||||
check(chain) { inner.checkServerTrusted(chain, authType) }
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
|
||||
check(chain) {
|
||||
if (ext != null) ext.checkServerTrusted(chain, authType, socket)
|
||||
else inner.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
|
||||
check(chain) {
|
||||
if (ext != null) ext.checkServerTrusted(chain, authType, engine)
|
||||
else inner.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
|
||||
inner.checkClientTrusted(chain, authType)
|
||||
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
|
||||
if (ext != null) ext.checkClientTrusted(chain, authType, socket) else inner.checkClientTrusted(chain, authType)
|
||||
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
|
||||
if (ext != null) ext.checkClientTrusted(chain, authType, engine) else inner.checkClientTrusted(chain, authType)
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = inner.acceptedIssuers
|
||||
}
|
||||
}
|
||||
|
||||
class TlsTrustModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "TlsTrust"
|
||||
|
||||
@ReactMethod
|
||||
fun setAllowExpired(value: Boolean, promise: Promise) {
|
||||
TlsTrust.saveAllowExpired(value)
|
||||
promise.resolve(value)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getStatus(promise: Promise) {
|
||||
val m = Arguments.createMap()
|
||||
m.putString("status", TlsTrust.lastStatus)
|
||||
m.putString("detail", TlsTrust.lastDetail)
|
||||
m.putDouble("notAfter", TlsTrust.lastNotAfter.toDouble())
|
||||
m.putString("subject", TlsTrust.lastSubject)
|
||||
m.putBoolean("allowExpired", TlsTrust.allowExpired)
|
||||
promise.resolve(m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class TlsTrustPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(TlsTrustModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.5",
|
||||
"version": "0.2.5.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Schloss-Symbol in der Tab-Leiste (neben dem Zahnrad): zeigt, ob die
|
||||
* Verbindung zu ARIA verschluesselt ist.
|
||||
*
|
||||
* 🔒 verschluesselt, alles in Ordnung
|
||||
* 🔒 + ! Problem mit dem Zertifikat (abgelaufen / abgelehnt)
|
||||
* 🔒 durchgestrichen keine Verschluesselung (Fallback oder so eingestellt)
|
||||
*
|
||||
* Antippen erklaert in einfachen Worten, was das Symbol bedeutet.
|
||||
* Einmal pro App-Start gibt's ausserdem einen Hinweis, wenn das Zertifikat
|
||||
* abgelaufen ist — damit es nicht unbemerkt bleibt.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import rvs, { SecurityState } from '../services/rvs';
|
||||
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const fmtDate = (ms: number) =>
|
||||
ms ? new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit' }) : 'unbekannt';
|
||||
|
||||
const SecurityLock: React.FC<Props> = ({ onOpenSettings }) => {
|
||||
const [sec, setSec] = useState<SecurityState>(rvs.getSecurity());
|
||||
const [open, setOpen] = useState(false);
|
||||
const alerted = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => rvs.onSecurityChange(setSec), []);
|
||||
|
||||
// Einmaliger Hinweis pro App-Start
|
||||
useEffect(() => {
|
||||
if (sec.kind === 'expired' && !alerted.current.has('expired')) {
|
||||
alerted.current.add('expired');
|
||||
Alert.alert('Zertifikat abgelaufen',
|
||||
`Die Verbindung zu ARIA ist weiterhin verschlüsselt, aber das Sicherheits-Zertifikat `
|
||||
+ `des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Bitte dem Admin Bescheid geben.`);
|
||||
} else if (sec.kind === 'cert_error' && sec.certStatus === 'expired' && !alerted.current.has('cert_error')) {
|
||||
alerted.current.add('cert_error');
|
||||
Alert.alert('Keine Verbindung — Zertifikat abgelaufen',
|
||||
`Das Sicherheits-Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen, `
|
||||
+ `deshalb verbindet sich die App nicht.\n\nIn den Einstellungen unter „Verbindung“ kann man `
|
||||
+ `abgelaufene Zertifikate vorübergehend erlauben.`,
|
||||
[{ text: 'OK' }, { text: 'Einstellungen', onPress: onOpenSettings }]);
|
||||
}
|
||||
}, [sec, onOpenSettings]);
|
||||
|
||||
const warn = sec.kind === 'expired' || sec.kind === 'cert_error';
|
||||
const crossed = sec.kind === 'fallback' || sec.kind === 'plain';
|
||||
if (sec.kind === 'unknown') return <View style={styles.slot} />;
|
||||
|
||||
let title = '';
|
||||
let text = '';
|
||||
if (sec.kind === 'secure') {
|
||||
title = 'Verbindung verschlüsselt';
|
||||
text = 'Alles in Ordnung. Die Verbindung zu ARIA ist verschlüsselt und das Zertifikat des Servers ist gültig.';
|
||||
} else if (sec.kind === 'expired') {
|
||||
title = 'Zertifikat abgelaufen';
|
||||
text = `Die Verbindung ist verschlüsselt, aber das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} `
|
||||
+ 'abgelaufen. Die App akzeptiert es, weil das in den Einstellungen so erlaubt ist. '
|
||||
+ 'Geprüft wird trotzdem, dass es wirklich das Zertifikat dieses Servers ist.\n\n'
|
||||
+ 'Das Zertifikat sollte erneuert werden.';
|
||||
} else if (sec.kind === 'cert_error') {
|
||||
title = 'Keine Verbindung — Zertifikat-Problem';
|
||||
text = sec.certStatus === 'expired'
|
||||
? `Das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Deshalb verbindet sich die App nicht.\n\n`
|
||||
+ 'In den Einstellungen unter „Verbindung“ kann man abgelaufene Zertifikate vorübergehend erlauben.'
|
||||
: 'Das Zertifikat des Servers ist ungültig oder gehört nicht zu diesem Server. Zur Sicherheit verbindet sich '
|
||||
+ 'die App nicht.' + (sec.reason ? `\n\nGrund: ${sec.reason}` : '');
|
||||
} else if (sec.kind === 'fallback') {
|
||||
title = 'Nicht verschlüsselt (Notlösung)';
|
||||
text = 'Die verschlüsselte Verbindung hat nicht geklappt. Die App ist deshalb ersatzweise UNVERSCHLÜSSELT '
|
||||
+ 'verbunden (TLS-Fallback).'
|
||||
+ (sec.certStatus === 'expired' ? `\n\nGrund: Das Zertifikat ist seit ${fmtDate(sec.notAfter)} abgelaufen.`
|
||||
: sec.certStatus === 'invalid' ? '\n\nGrund: Das Zertifikat des Servers ist ungültig.'
|
||||
: '\n\nGrund: Die verschlüsselte Verbindung kam nicht zustande.');
|
||||
} else if (sec.kind === 'plain') {
|
||||
title = 'Nicht verschlüsselt';
|
||||
text = 'Für diese Verbindung ist keine Verschlüsselung eingestellt. Das ist nur im eigenen Heimnetz in Ordnung.';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity style={styles.slot} onPress={() => setOpen(true)} accessibilityLabel={title}>
|
||||
<View>
|
||||
<Text style={[styles.lock, crossed && styles.lockDim]}>{'🔒'}</Text>
|
||||
{crossed && <View style={styles.strike} />}
|
||||
{warn && <Text style={styles.bang}>!</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
|
||||
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={() => setOpen(false)}>
|
||||
<View style={styles.box}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.text}>{text}</Text>
|
||||
|
||||
<View style={styles.legend}>
|
||||
<Text style={styles.legendRow}>{'🔒'} verschlüsselt, alles in Ordnung</Text>
|
||||
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendBang}>!</Text> Problem mit dem Zertifikat</Text>
|
||||
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendStrike}>/</Text> nicht verschlüsselt</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttons}>
|
||||
{(sec.kind === 'cert_error' || sec.kind === 'expired' || sec.kind === 'fallback') && (
|
||||
<TouchableOpacity style={styles.btn} onPress={() => { setOpen(false); onOpenSettings(); }}>
|
||||
<Text style={styles.btnText}>Einstellungen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={[styles.btn, styles.btnPrimary]} onPress={() => setOpen(false)}>
|
||||
<Text style={styles.btnText}>OK</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
slot: { width: 52, alignItems: 'center', justifyContent: 'center' },
|
||||
lock: { fontSize: 20 },
|
||||
lockDim: { opacity: 0.7 },
|
||||
strike: {
|
||||
position: 'absolute', left: -3, right: -3, top: '50%', height: 2.5,
|
||||
backgroundColor: '#FF3B30', transform: [{ rotate: '-45deg' }],
|
||||
},
|
||||
bang: {
|
||||
position: 'absolute', right: -8, top: -6, minWidth: 15, height: 15, borderRadius: 8,
|
||||
backgroundColor: '#FF9F0A', color: '#000', fontSize: 11, fontWeight: '900',
|
||||
textAlign: 'center', lineHeight: 15, overflow: 'hidden',
|
||||
},
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
|
||||
box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
|
||||
title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700', marginBottom: 10 },
|
||||
text: { color: '#C8C8D8', fontSize: 14, lineHeight: 20 },
|
||||
legend: { marginTop: 16, paddingTop: 12, borderTopWidth: 1, borderTopColor: '#2A2A3E' },
|
||||
legendRow: { color: '#9090A8', fontSize: 13, marginVertical: 2 },
|
||||
legendBang: { color: '#FF9F0A', fontWeight: '900' },
|
||||
legendStrike: { color: '#FF3B30', fontWeight: '900' },
|
||||
buttons: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16, gap: 10 },
|
||||
btn: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#2A2A3E' },
|
||||
btnPrimary: { backgroundColor: '#0096FF' },
|
||||
btnText: { color: '#FFFFFF', fontWeight: '600' },
|
||||
});
|
||||
|
||||
export default SecurityLock;
|
||||
@@ -178,6 +178,14 @@ const SettingsScreen: React.FC = () => {
|
||||
const winDims = useWindowDimensions();
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const [manualToken, setManualToken] = useState('');
|
||||
// Abgelaufenes Server-Zertifikat trotzdem akzeptieren (Kette + Hostname werden weiter geprueft)
|
||||
const [allowExpiredCert, setAllowExpiredCert] = useState(false);
|
||||
useEffect(() => { rvs.getAllowExpiredCert().then(setAllowExpiredCert); }, []);
|
||||
const handleAllowExpiredCert = useCallback(async (value: boolean) => {
|
||||
setAllowExpiredCert(value);
|
||||
await rvs.setAllowExpiredCert(value);
|
||||
rvs.connect(true); // gleich mit der neuen Regel neu verbinden
|
||||
}, []);
|
||||
const [manualHost, setManualHost] = useState('');
|
||||
const [manualPort, setManualPort] = useState('8765');
|
||||
const [currentMode, setCurrentMode] = useState('normal');
|
||||
@@ -1450,6 +1458,24 @@ const SettingsScreen: React.FC = () => {
|
||||
<TouchableOpacity style={styles.connectButton} onPress={connectManually}>
|
||||
<Text style={styles.connectButtonText}>Verbinden</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={[styles.toggleRow, {marginTop: 16, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>Abgelaufenes Zertifikat akzeptieren</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Notlösung, wenn das Zertifikat des Servers abgelaufen ist. Die Verbindung bleibt
|
||||
verschlüsselt, und es wird weiter geprüft, dass es wirklich das Zertifikat dieses
|
||||
Servers ist — nur das Ablaufdatum wird ignoriert. Wieder ausschalten, sobald das
|
||||
Zertifikat erneuert ist.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={allowExpiredCert}
|
||||
onValueChange={handleAllowExpiredCert}
|
||||
trackColor={{ false: '#2A2A3E', true: '#FF9F0A' }}
|
||||
thumbColor={allowExpiredCert ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Verbindungslog === */}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
const TlsTrust: any = NativeModules.TlsTrust;
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
@@ -27,6 +30,29 @@ export interface ConnectionConfig {
|
||||
useTLS: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie sicher ist die Verbindung? Fuer das Schloss-Symbol in der Tab-Leiste.
|
||||
* secure — verschluesselt, Zertifikat in Ordnung
|
||||
* expired — verschluesselt, aber das Zertifikat ist abgelaufen (per Schalter erlaubt)
|
||||
* cert_error— keine Verbindung, weil das Zertifikat abgelehnt wurde
|
||||
* fallback — verbunden OHNE Verschluesselung, weil TLS fehlschlug
|
||||
* plain — verbunden ohne Verschluesselung, so konfiguriert
|
||||
* unknown — (noch) nichts Genaues
|
||||
*/
|
||||
export type SecurityKind = 'secure' | 'expired' | 'cert_error' | 'fallback' | 'plain' | 'unknown';
|
||||
|
||||
export interface SecurityState {
|
||||
kind: SecurityKind;
|
||||
/** Pruefergebnis der App: ok | expired_accepted | expired | invalid | unknown */
|
||||
certStatus: string;
|
||||
/** Ablaufdatum des Server-Zertifikats (ms), 0 = unbekannt */
|
||||
notAfter: number;
|
||||
/** Bei fallback/cert_error: warum TLS nicht ging */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
type SecurityCallback = (state: SecurityState) => void;
|
||||
|
||||
type MessageCallback = (message: RVSMessage) => void;
|
||||
type StateCallback = (state: ConnectionState) => void;
|
||||
|
||||
@@ -64,6 +90,10 @@ class RVSConnection {
|
||||
private logListeners: LogCallback[] = [];
|
||||
private connectionLog: ConnectionLogEntry[] = [];
|
||||
private usingTLSFallback: boolean = false;
|
||||
private securityListeners: SecurityCallback[] = [];
|
||||
private security: SecurityState = { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' };
|
||||
/** Letzter Zertifikats-Fehler beim wss://-Versuch (bleibt bis wss:// wieder klappt) */
|
||||
private certFailure: { status: string; notAfter: number; detail: string } | null = null;
|
||||
|
||||
// --- Konfiguration ---
|
||||
|
||||
@@ -216,6 +246,8 @@ class RVSConnection {
|
||||
const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : '';
|
||||
this.log('info', `Verbunden${tlsInfo}`);
|
||||
this.setState('connected');
|
||||
this.updateSecurityOnOpen(useTLS);
|
||||
this.sendClientHello();
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.startHeartbeat();
|
||||
};
|
||||
@@ -239,6 +271,10 @@ class RVSConnection {
|
||||
this.clearTimers();
|
||||
this.ws = null;
|
||||
this.setState('disconnected');
|
||||
this.setSecurity(this.certFailure
|
||||
? { kind: 'cert_error', certStatus: this.certFailure.status, notAfter: this.certFailure.notAfter,
|
||||
reason: this.certFailure.detail }
|
||||
: { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' });
|
||||
|
||||
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
|
||||
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
|
||||
@@ -258,6 +294,7 @@ class RVSConnection {
|
||||
this.ws.onerror = (error) => {
|
||||
const errorMsg = (error as any)?.message || 'Unbekannter Fehler';
|
||||
this.log('error', `WebSocket-Fehler: ${errorMsg}`);
|
||||
if (useTLS) this.checkCertFailure(errorMsg);
|
||||
|
||||
// TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln
|
||||
if (this.config?.useTLS && !this.usingTLSFallback) {
|
||||
@@ -287,6 +324,95 @@ class RVSConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/** Beim RVS vorstellen — Diagnostic zeigt so, welches Handy im Raum ist. */
|
||||
private sendClientHello(): void {
|
||||
try {
|
||||
const c: any = (Platform as any).constants || {};
|
||||
const device = [c.Manufacturer, c.Model].filter(Boolean).join(' ') || Platform.OS;
|
||||
let version = '';
|
||||
try { version = require('../../package.json').version || ''; } catch (_) {}
|
||||
this.send('client_hello', { kind: 'App', name: device,
|
||||
detail: `Android ${c.Release || Platform.Version}`, version });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// --- Verbindungs-Sicherheit (Schloss-Symbol) ---
|
||||
|
||||
getSecurity(): SecurityState {
|
||||
return this.security;
|
||||
}
|
||||
|
||||
onSecurityChange(callback: SecurityCallback): () => void {
|
||||
this.securityListeners.push(callback);
|
||||
return () => {
|
||||
this.securityListeners = this.securityListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
private setSecurity(next: SecurityState): void {
|
||||
const cur = this.security;
|
||||
if (cur.kind === next.kind && cur.certStatus === next.certStatus
|
||||
&& cur.notAfter === next.notAfter && cur.reason === next.reason) return;
|
||||
this.security = next;
|
||||
this.securityListeners.forEach(cb => cb(next));
|
||||
}
|
||||
|
||||
private async nativeCertStatus(): Promise<{ status: string; notAfter: number; detail: string }> {
|
||||
try {
|
||||
const r = await TlsTrust?.getStatus();
|
||||
return { status: r?.status || 'unknown', notAfter: Number(r?.notAfter) || 0, detail: r?.detail || '' };
|
||||
} catch (_) {
|
||||
return { status: 'unknown', notAfter: 0, detail: '' };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateSecurityOnOpen(viaTLS: boolean): Promise<void> {
|
||||
if (viaTLS) {
|
||||
this.certFailure = null;
|
||||
const c = await this.nativeCertStatus();
|
||||
if (c.status === 'expired_accepted') {
|
||||
this.log('warn', `Zertifikat abgelaufen (${new Date(c.notAfter).toLocaleDateString()}) — per Einstellung akzeptiert`);
|
||||
}
|
||||
this.setSecurity({ kind: c.status === 'expired_accepted' ? 'expired' : 'secure',
|
||||
certStatus: c.status, notAfter: c.notAfter, reason: '' });
|
||||
} else if (this.config?.useTLS) {
|
||||
// wss:// ging nicht, ws:// schon → unverschluesselt per Fallback
|
||||
this.setSecurity({ kind: 'fallback', certStatus: this.certFailure?.status || 'unknown',
|
||||
notAfter: this.certFailure?.notAfter || 0, reason: this.certFailure?.detail || '' });
|
||||
} else {
|
||||
this.setSecurity({ kind: 'plain', certStatus: 'unknown', notAfter: 0, reason: '' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Lag der wss://-Fehler am Zertifikat? Dann merken (fuer Schloss + Hinweis). */
|
||||
private async checkCertFailure(errorMsg: string): Promise<void> {
|
||||
if (!/cert|trust anchor|chain validation|handshake|ssl/i.test(errorMsg)) return;
|
||||
const c = await this.nativeCertStatus();
|
||||
if (c.status !== 'expired' && c.status !== 'invalid') return;
|
||||
this.certFailure = { status: c.status, notAfter: c.notAfter, detail: c.detail || errorMsg };
|
||||
this.log('error', c.status === 'expired'
|
||||
? `Server-Zertifikat abgelaufen seit ${new Date(c.notAfter).toLocaleString()}`
|
||||
: 'Server-Zertifikat ungueltig');
|
||||
if (this.state !== 'connected') {
|
||||
this.setSecurity({ kind: 'cert_error', certStatus: c.status, notAfter: c.notAfter,
|
||||
reason: this.certFailure.detail });
|
||||
} else if (this.security.kind === 'fallback') {
|
||||
// ws://-Fallback war schneller verbunden als diese Pruefung — Grund nachtragen
|
||||
this.setSecurity({ ...this.security, certStatus: c.status, notAfter: c.notAfter,
|
||||
reason: this.certFailure.detail });
|
||||
}
|
||||
}
|
||||
|
||||
/** Schalter "Abgelaufenes Zertifikat akzeptieren" */
|
||||
async getAllowExpiredCert(): Promise<boolean> {
|
||||
try { return !!(await TlsTrust?.getStatus())?.allowExpired; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
async setAllowExpiredCert(value: boolean): Promise<void> {
|
||||
try { await TlsTrust?.setAllowExpired(value); } catch (_) {}
|
||||
this.log('info', `Abgelaufene Zertifikate ${value ? 'werden akzeptiert' : 'werden abgelehnt'}`);
|
||||
}
|
||||
|
||||
/** Reconnect mit exponentiellem Backoff planen */
|
||||
private scheduleReconnect(): void {
|
||||
this.log('info', `Reconnect in ${this.reconnectDelay / 1000}s...`);
|
||||
|
||||
@@ -21,5 +21,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY aria_bridge.py .
|
||||
COPY modes.py .
|
||||
COPY llm_cluster.py .
|
||||
|
||||
CMD ["python", "aria_bridge.py"]
|
||||
|
||||
+70
-52
@@ -41,6 +41,7 @@ import websockets
|
||||
from faster_whisper import WhisperModel
|
||||
from openwakeword.model import Model as WakeWordModel
|
||||
|
||||
from llm_cluster import LlmCluster
|
||||
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
||||
|
||||
|
||||
@@ -72,6 +73,20 @@ logging.basicConfig(
|
||||
logger = logging.getLogger("aria-bridge")
|
||||
|
||||
|
||||
# Schluessel, deren Werte nie ins Log duerfen (HF-Token, Passwoerter, API-Keys).
|
||||
_SECRET_KEY_RE = re.compile(r"token(?!s)|secret|passw|apikey|api_key|credential", re.I)
|
||||
|
||||
|
||||
def _redact(obj):
|
||||
"""Kopie von obj fuer Log-Zeilen: Geheimnis-Werte werden zu '***'."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: ("***" if v and _SECRET_KEY_RE.search(str(k)) else _redact(v))
|
||||
for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_redact(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
# ── TCP-Keepalive Helper ────────────────────────────────────
|
||||
#
|
||||
# Aktiviert TCP-Level Keepalive auf einer websockets-Verbindung mit
|
||||
@@ -705,7 +720,7 @@ class ARIABridge:
|
||||
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s flux=%s",
|
||||
self.tts_enabled, self.xtts_voice or "default",
|
||||
self._f5tts_config or "defaults",
|
||||
self._flux_config or "defaults")
|
||||
_redact(self._flux_config) or "defaults")
|
||||
except Exception as e:
|
||||
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
||||
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
||||
@@ -806,6 +821,9 @@ class ARIABridge:
|
||||
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf
|
||||
# llm_response.
|
||||
self._pending_llm: dict[str, asyncio.Future] = {}
|
||||
# LLM-Cluster: ein Eingang fuer alle lokalen Modelle aller Boxen
|
||||
# (Auswahl nach Last, Ausfall-Ersatz). Siehe llm_cluster.py.
|
||||
self._llm_cluster = LlmCluster(self)
|
||||
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
|
||||
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
|
||||
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
|
||||
@@ -1826,7 +1844,7 @@ class ARIABridge:
|
||||
"payload": payload,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
logger.info("[rvs] Persistierte Config broadcastet: %s", payload)
|
||||
logger.info("[rvs] Persistierte Config broadcastet: %s", _redact(payload))
|
||||
except Exception as e:
|
||||
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e)
|
||||
|
||||
@@ -2254,6 +2272,13 @@ class ARIABridge:
|
||||
self.ws_rvs = ws
|
||||
retry_delay = 2
|
||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||
# Beim RVS vorstellen (Diagnostic → "Wer ist im Raum?")
|
||||
try:
|
||||
await ws.send(json.dumps({"type": "client_hello", "payload": {
|
||||
"kind": "Bridge", "name": socket.gethostname(),
|
||||
"detail": "ARIA-Bridge"}, "timestamp": int(time.time() * 1000)}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# TCP-Keepalive auf dem unterliegenden Socket aktivieren —
|
||||
# damit NAT-Tabellen-Verfall oder "halb-tote" Verbindungen
|
||||
@@ -2453,6 +2478,9 @@ class ARIABridge:
|
||||
|
||||
msg_type = message.get("type", "")
|
||||
payload = message.get("payload", {})
|
||||
if msg_type.startswith("llm_cluster_") and isinstance(payload, dict):
|
||||
if await self._llm_cluster.handle_rvs(msg_type, payload):
|
||||
return
|
||||
|
||||
if msg_type == "chat":
|
||||
# Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!)
|
||||
@@ -2706,7 +2734,7 @@ class ARIABridge:
|
||||
config_data.update(getattr(self, "_debug_log_config", {}))
|
||||
with open("/shared/config/voice_config.json", "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
|
||||
logger.info("[rvs] Voice-Config gespeichert: %s", _redact(config_data))
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] Config speichern fehlgeschlagen: %s", e)
|
||||
return
|
||||
@@ -3774,6 +3802,9 @@ class ARIABridge:
|
||||
# Fallback auf [model] fuer alte Adapter ohne models-Feld.
|
||||
"models": [m for m in _models if m] if isinstance(_models, list)
|
||||
else ([payload.get("model")] if payload.get("model") else []),
|
||||
# Karten-Belegung (neuer llm-adapter): dann ist "models" nur
|
||||
# noch das GELADENE — Clients laden nichts nach.
|
||||
**({"slots": payload["slots"]} if isinstance(payload.get("slots"), list) else {}),
|
||||
"busy": bool(prev.get("busy", False)),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
@@ -3819,6 +3850,13 @@ class ARIABridge:
|
||||
"model": "", "busy": False, "last_seen": 0.0,
|
||||
}
|
||||
w["busy"] = bool(payload.get("busy", False))
|
||||
# LLM-Boxen melden mit jedem Ping, welche Modelle GELADEN sind.
|
||||
# Nach "Karte leeren" in der Diagnostic sonst bis zum naechsten
|
||||
# hello (30 s) veraltet — ARIA wuerde ein entladenes Modell anfragen.
|
||||
if isinstance(payload.get("models"), list):
|
||||
w["models"] = payload["models"]
|
||||
if isinstance(payload.get("slots"), list):
|
||||
w["slots"] = payload["slots"]
|
||||
w["last_seen"] = time.time()
|
||||
return
|
||||
|
||||
@@ -4242,55 +4280,28 @@ class ARIABridge:
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return {"ok": False, "error": "messages leer/ungueltig"}
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending_llm[request_id] = future
|
||||
try:
|
||||
req_payload = {
|
||||
"requestId": request_id,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if stop:
|
||||
req_payload["stop"] = stop
|
||||
if tools:
|
||||
req_payload["tools"] = tools
|
||||
if model:
|
||||
req_payload["model"] = model
|
||||
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
|
||||
# das gewaehlte Modell fahren kann; None → Broadcast wie bisher.
|
||||
# Mehrere Boxen mit demselben Modell → Round-Robin (pro Projekt verteilt).
|
||||
llm_target = self._pick_worker("llm", model=model or None)
|
||||
if llm_target:
|
||||
req_payload["targetInstance"] = llm_target
|
||||
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)",
|
||||
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
|
||||
model or "-", llm_target or "(broadcast)")
|
||||
ok = await self._send_to_rvs({
|
||||
"type": "llm_request",
|
||||
"payload": req_payload,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
if not ok:
|
||||
return {"ok": False, "error": "llm_request konnte nicht gesendet werden"}
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — AI-Box nicht erreichbar?"}
|
||||
if not isinstance(result, dict) or not result.get("ok"):
|
||||
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
|
||||
return {"ok": False, "error": err or "llm-adapter Fehler"}
|
||||
return {
|
||||
"ok": True,
|
||||
"content": result.get("content", ""),
|
||||
"tool_calls": result.get("tool_calls"),
|
||||
"model": result.get("model"),
|
||||
"elapsedMs": result.get("elapsedMs"),
|
||||
}
|
||||
finally:
|
||||
self._pending_llm.pop(request_id, None)
|
||||
# Derselbe Router wie fuer Fremdprogramme: Box nach Last waehlen, bei
|
||||
# Ausfall einmal anderswo versuchen, ehrliche Absage, wenn das Modell
|
||||
# nirgends geladen ist (statt Broadcast + Timeout).
|
||||
res = await self._llm_cluster.route({
|
||||
"model": model or "", "messages": messages, "max_tokens": max_tokens,
|
||||
"temperature": temperature, "stop": stop, "tools": tools,
|
||||
}, timeout=self._LLM_TIMEOUT_S)
|
||||
if not res.get("ok"):
|
||||
err = res.get("error") or "llm-adapter Fehler"
|
||||
if res.get("errorCode") == "model_unavailable":
|
||||
err += " Laden: Diagnostic → Einstellungen → LLM-Karten."
|
||||
return {"ok": False, "error": err, "errorCode": res.get("errorCode")}
|
||||
logger.info("[cluster] ARIA ← %s (Karte %s, %s ms, Versuche %s)",
|
||||
res.get("node") or res.get("instanceId"), res.get("gpu", "?"),
|
||||
res.get("elapsedMs"), res.get("attempts"))
|
||||
return {
|
||||
"ok": True,
|
||||
"content": res.get("content", ""),
|
||||
"tool_calls": res.get("tool_calls"),
|
||||
"model": res.get("model"),
|
||||
"elapsedMs": res.get("elapsedMs"),
|
||||
}
|
||||
|
||||
async def _flux_generate(self, prompt: str, width: int, height: int,
|
||||
steps: Optional[int], guidance: Optional[float],
|
||||
@@ -5234,6 +5245,13 @@ class ARIABridge:
|
||||
})
|
||||
return out
|
||||
|
||||
def _llm_boxes_with_slots(self) -> list:
|
||||
"""Online-LLM-Boxen, die schon Karten-Belegung melden (neuer Adapter)."""
|
||||
now = time.time()
|
||||
return [w for w in self._workers.values()
|
||||
if w.get("service") == "llm" and isinstance(w.get("slots"), list)
|
||||
and (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S]
|
||||
|
||||
def _pick_worker(self, service: str, model: Optional[str] = None) -> Optional[str]:
|
||||
"""Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin
|
||||
ueber die freien). Gibt die instanceId oder None. Fuer Stage-3-Routing
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""LLM-Cluster: ein Eingang fuer alle lokalen Sprachmodelle aller KI-Boxen.
|
||||
|
||||
Clients waehlen ein MODELL, nicht Box oder Karte. Wo es laeuft, entscheidet
|
||||
dieser Router:
|
||||
|
||||
* Auswahl — unter allen online Boxen, die das Modell GELADEN haben
|
||||
(Karten-Belegung aus worker_hello/worker_ping), die mit den
|
||||
wenigsten laufenden Anfragen; bei Gleichstand reihum.
|
||||
Ist nur eine Karte da oder alles frei, landet alles dort —
|
||||
"verteilen, wenn noetig, sonst nicht".
|
||||
* Ausfall — verschwindet eine Box mitten in der Anfrage (kein Ping mehr)
|
||||
oder antwortet nicht, geht die Anfrage EINMAL an eine andere
|
||||
Box mit demselben Modell. Fehler des Modells selbst (z.B.
|
||||
Kontext zu lang) werden nicht wiederholt.
|
||||
* Fehler — kommen als FEHLER zurueck (ok=false / HTTP-Status), nie als
|
||||
normale Antwort. Ein Client wuerde einen Fehlertext sonst
|
||||
als Aussage der KI in seinen Verlauf schreiben — genau das
|
||||
hat bei ARIA schon mal einen ganzen Chat vergiftet.
|
||||
|
||||
Eingang ist der RVS:
|
||||
|
||||
llm_cluster_models_request → llm_cluster_models
|
||||
llm_cluster_request → llm_cluster_response
|
||||
|
||||
Programme brauchen nur eine ausgehende Verbindung zum RVS, egal in welchem
|
||||
Netz sie stehen. Werkzeuge, die nur "OpenAI-kompatibel" koennen (OpenHands
|
||||
& Co.), bekommen den OpenAI-RVS-Wrapper neben sich gestellt
|
||||
(xtts/openai_rvs_wrapper) — fuer sie ist der Cluster dann 127.0.0.1.
|
||||
|
||||
Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("aria-bridge.llm-cluster")
|
||||
|
||||
# Diese errorCodes heissen "die BOX war das Problem" → anderswo nochmal versuchen.
|
||||
# Alles andere (Modell-Fehler, z.B. Kontext zu lang) waere ueberall gleich.
|
||||
RETRY_CODES = {"timeout", "box_offline", "send_failed", "model_not_loaded"}
|
||||
MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
class LlmCluster:
|
||||
def __init__(self, bridge):
|
||||
self.b = bridge
|
||||
self.inflight: dict[str, int] = {} # instanceId → laufende Anfragen (von uns)
|
||||
self._rr: dict[str, int] = {}
|
||||
self.timeout = float(os.getenv("CLUSTER_LLM_TIMEOUT_S", "600"))
|
||||
|
||||
# ── Flotte ──────────────────────────────────────────────────────────
|
||||
|
||||
def _online_llm(self) -> list:
|
||||
now = time.time()
|
||||
return [w for w in self.b._workers.values()
|
||||
if w.get("service") == "llm"
|
||||
and (now - w.get("last_seen", 0)) < self.b.WORKER_OFFLINE_S]
|
||||
|
||||
def _is_online(self, instance_id: str) -> bool:
|
||||
return any(w.get("instanceId") == instance_id for w in self._online_llm())
|
||||
|
||||
def models(self) -> list:
|
||||
"""Jedes geladene Modell GENAU EINMAL, egal auf wie vielen Karten.
|
||||
ctx = das kleinste Kontextfenster unter seinen Karten (darauf muss sich
|
||||
ein Client verlassen koennen, egal wohin der Router verteilt)."""
|
||||
out: dict[str, dict] = {}
|
||||
for w in self._online_llm():
|
||||
slots = w.get("slots")
|
||||
if isinstance(slots, list):
|
||||
for sl in slots:
|
||||
m = sl.get("model")
|
||||
if not m or m not in (w.get("models") or []):
|
||||
continue
|
||||
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
|
||||
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
|
||||
"gpu": sl.get("gpu", ""), "gpuName": sl.get("gpuName", ""),
|
||||
"state": sl.get("state", "")})
|
||||
c = int(sl.get("ctx") or 0)
|
||||
if c and (not e["ctx"] or c < e["ctx"]):
|
||||
e["ctx"] = c
|
||||
else:
|
||||
# Box mit altem Adapter (ohne Karten): nur die Modell-Liste
|
||||
for m in (w.get("models") or []):
|
||||
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
|
||||
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
|
||||
"gpu": "", "gpuName": "", "state": ""})
|
||||
for e in out.values():
|
||||
e["available"] = len(e["cards"])
|
||||
return sorted(out.values(), key=lambda e: e["model"])
|
||||
|
||||
def candidates(self, model: str) -> list:
|
||||
return [w for w in self._online_llm() if model in (w.get("models") or [])]
|
||||
|
||||
def pick(self, model: str, exclude=()) -> Optional[str]:
|
||||
"""Die Box mit den wenigsten laufenden Anfragen; Gleichstand reihum."""
|
||||
cands = [w["instanceId"] for w in self.candidates(model) if w["instanceId"] not in exclude]
|
||||
if not cands:
|
||||
return None
|
||||
low = min(self.inflight.get(c, 0) for c in cands)
|
||||
tied = sorted(c for c in cands if self.inflight.get(c, 0) == low)
|
||||
i = self._rr.get(model, 0) % len(tied)
|
||||
self._rr[model] = i + 1
|
||||
return tied[i]
|
||||
|
||||
# ── Anfrage an eine bestimmte Box ───────────────────────────────────
|
||||
|
||||
async def _send_to(self, target: str, req: dict, deadline: float) -> dict:
|
||||
"""llm_request an GENAU diese Box, Antwort abwarten. Bricht frueh ab,
|
||||
wenn die Box unterwegs offline geht — dann kann route() woanders hin."""
|
||||
rid = str(uuid.uuid4())
|
||||
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||
self.b._pending_llm[rid] = fut
|
||||
self.inflight[target] = self.inflight.get(target, 0) + 1
|
||||
try:
|
||||
payload = {k: v for k, v in req.items() if v is not None}
|
||||
payload.update(requestId=rid, targetInstance=target)
|
||||
ok = await self.b._send_to_rvs({"type": "llm_request", "payload": payload,
|
||||
"timestamp": int(time.time() * 1000)})
|
||||
if not ok:
|
||||
return {"ok": False, "errorCode": "send_failed", "error": "RVS: Anfrage nicht gesendet"}
|
||||
while True:
|
||||
left = deadline - time.time()
|
||||
if left <= 0:
|
||||
return {"ok": False, "errorCode": "timeout",
|
||||
"error": f"Keine Antwort von {target} in der Frist"}
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(fut), timeout=min(5.0, left))
|
||||
except asyncio.TimeoutError:
|
||||
if not self._is_online(target):
|
||||
return {"ok": False, "errorCode": "box_offline",
|
||||
"error": f"{target} ist waehrend der Anfrage offline gegangen"}
|
||||
finally:
|
||||
self.inflight[target] = max(0, self.inflight.get(target, 1) - 1)
|
||||
self.b._pending_llm.pop(rid, None)
|
||||
|
||||
async def route(self, req: dict, timeout: Optional[float] = None) -> dict:
|
||||
"""Eine Anfrage an den Cluster. req: model, messages, max_tokens,
|
||||
temperature, stop, tools. Antwort: wie llm_response der Box, plus
|
||||
attempts; bei Fehler errorCode + error (+ available).
|
||||
timeout: Gesamtfrist inkl. Ausfall-Ersatz. ARIA nutzt eine kurze (lokal
|
||||
ist ihr Schnellweg — dauert es zu lange, nimmt sie Claude)."""
|
||||
model = (req.get("model") or "").strip()
|
||||
messages = req.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return {"ok": False, "errorCode": "bad_request", "error": "messages fehlt oder ist leer"}
|
||||
available = [m["model"] for m in self.models()]
|
||||
if not model:
|
||||
if len(available) == 1:
|
||||
model = available[0] # eindeutig — dann darf es fehlen
|
||||
else:
|
||||
return {"ok": False, "errorCode": "bad_request", "available": available,
|
||||
"error": "model fehlt. Geladen: " + (", ".join(available) or "nichts")}
|
||||
body = {"model": model, "messages": messages,
|
||||
"max_tokens": int(req.get("max_tokens") or 512),
|
||||
"temperature": float(req["temperature"]) if req.get("temperature") is not None else 0.7,
|
||||
"stop": req.get("stop") or None, "tools": req.get("tools") or None}
|
||||
limit = timeout or self.timeout
|
||||
deadline = time.time() + limit
|
||||
tried: list = []
|
||||
last: dict = {}
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
target = self.pick(model, exclude=tried)
|
||||
if not target:
|
||||
break
|
||||
tried.append(target)
|
||||
t0 = time.time()
|
||||
last = await self._send_to(target, body, deadline)
|
||||
if last.get("ok"):
|
||||
last["attempts"] = len(tried)
|
||||
return last
|
||||
logger.warning("[cluster] %s auf %s: %s (%s)%s", model, target, last.get("errorCode") or "Fehler",
|
||||
(last.get("error") or "")[:120], " — versuche andere Box" if
|
||||
last.get("errorCode") in RETRY_CODES else "")
|
||||
if last.get("errorCode") not in RETRY_CODES or time.time() >= deadline:
|
||||
break
|
||||
if not tried:
|
||||
return {"ok": False, "errorCode": "model_unavailable", "available": available,
|
||||
"error": (f"Modell {model!r} ist gerade auf keiner KI-Box im Cluster geladen. "
|
||||
f"Geladen: {', '.join(available) or 'nichts'}.")}
|
||||
last.setdefault("errorCode", "model_error")
|
||||
last["attempts"] = len(tried)
|
||||
if last["errorCode"] in RETRY_CODES and len(tried) >= 1:
|
||||
others = [c for c in self.candidates(model) if c["instanceId"] not in tried]
|
||||
if not others:
|
||||
last["error"] = (last.get("error") or "") + (
|
||||
" — keine weitere Box mit diesem Modell erreichbar.")
|
||||
return last
|
||||
|
||||
# ── RVS-Eingang ─────────────────────────────────────────────────────
|
||||
|
||||
async def handle_rvs(self, mtype: str, payload: dict) -> bool:
|
||||
if mtype == "llm_cluster_models_request":
|
||||
await self.b._send_to_rvs({"type": "llm_cluster_models", "payload": {
|
||||
"requestId": payload.get("requestId", ""), "models": self.models()},
|
||||
"timestamp": int(time.time() * 1000)})
|
||||
return True
|
||||
if mtype == "llm_cluster_request":
|
||||
asyncio.create_task(self._rvs_request(payload))
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _rvs_request(self, payload: dict) -> None:
|
||||
rid = payload.get("requestId", "")
|
||||
res = await self.route(payload)
|
||||
out = {"requestId": rid, "ok": bool(res.get("ok"))}
|
||||
for k in ("content", "tool_calls", "model", "node", "gpu", "instanceId", "usage",
|
||||
"elapsedMs", "attempts", "error", "errorCode", "available",
|
||||
"promptTokens", "ctx"):
|
||||
if res.get(k) is not None:
|
||||
out[k] = res[k]
|
||||
await self.b._send_to_rvs({"type": "llm_cluster_response", "payload": out,
|
||||
"timestamp": int(time.time() * 1000)})
|
||||
+452
-34
@@ -103,6 +103,7 @@
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85);
|
||||
z-index:1000; justify-content:center; align-items:center; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
@keyframes llmdlpulse { 0%,100% { opacity:.15; } 50% { opacity:.45; } }
|
||||
.modal-box { background:#0D0D1A; border:1px solid #FFD60A44; border-radius:10px; width:90vw; max-width:860px;
|
||||
max-height:90vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:10px 14px;
|
||||
@@ -167,7 +168,7 @@
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;">
|
||||
<span id="service-status-icon" style="font-size:18px;line-height:1;">⏳</span>
|
||||
<div id="service-status-list" style="flex:1;display:flex;flex-direction:column;gap:6px;"></div>
|
||||
<button id="service-status-close" onclick="document.getElementById('service-status-banner').style.display='none'" style="background:none;border:none;color:#666680;font-size:16px;cursor:pointer;padding:0;line-height:1;display:none;">×</button>
|
||||
<button id="service-status-close" onclick="dismissServiceStatus()" title="Schließen" style="background:none;border:none;color:#8888AA;font-size:18px;cursor:pointer;padding:0 2px;line-height:1;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -838,11 +839,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LLM-Karten: pro Karte ein Dropdown — welches Modell dort liegt -->
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
|
||||
<h2 style="margin:0;">LLM-Karten</h2>
|
||||
<button class="btn secondary" onclick="openLlmDownloads()" style="padding:4px 10px;font-size:11px;">⬇ Downloads</button>
|
||||
</div>
|
||||
<div class="card" style="max-width:1100px;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0 0 10px;line-height:1.5;">
|
||||
Jede Grafikkarte, die auf einer KI-Box fürs LLM vorgesehen ist (<code>LLM_GPUS</code> in deren
|
||||
<code>.env</code>), hält genau ein Modell — dauerhaft geladen. Hier legst du fest, welches.
|
||||
ARIA und angebundene Programme wählen nur unter diesen Modellen.
|
||||
„— leer —" entlädt die Karte. Beim Wechsel starten die anderen Karten derselben Box kurz neu.
|
||||
</p>
|
||||
<div id="llm-card-settings" style="display:flex;flex-direction:column;gap:8px;">
|
||||
<span style="color:#6a6a88;">Keine LLM-Box online.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modell-Katalog: GGUF-Modelle herunterladen/aktivieren (Stage D) -->
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
|
||||
<h2 style="margin:0;">Modell-Katalog</h2>
|
||||
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
|
||||
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
|
||||
</div>
|
||||
<div class="card" style="max-width:1100px;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
|
||||
@@ -1367,6 +1388,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wer ist im Raum? -->
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h2 style="margin:0;">Verbunden mit diesem Raum</h2>
|
||||
<button class="btn secondary" onclick="requestRoomClients()" style="padding:4px 12px;">Aktualisieren</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8888AA;margin:6px 0 8px;">
|
||||
Alle Verbindungen, die gerade mit dem RVS-Token oben im selben Raum sind
|
||||
(so wie der RVS sie sieht). Die Art erkennt der RVS an der Vorstellung der
|
||||
Clients — „unbekannt" ist ein Programm, das sich (noch) nicht vorgestellt hat.
|
||||
</div>
|
||||
<div class="card" style="max-width:900px;">
|
||||
<div id="room-clients" style="font-size:12px;color:#8888AA;">(Aktualisieren klicken)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App-Onboarding via QR-Code -->
|
||||
<div class="settings-section">
|
||||
<h2>App-Onboarding (QR-Code)</h2>
|
||||
@@ -1859,6 +1896,26 @@
|
||||
</div>
|
||||
|
||||
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
|
||||
<!-- Modell-Downloads auf LLM-Boxen: Fortschritt, dann „in die Karte geladen" -->
|
||||
<div class="modal-overlay" id="llm-dl-modal" onclick="if(event.target===this)closeLlmDownloads()">
|
||||
<div class="modal-box" style="max-width:720px;">
|
||||
<div class="modal-header">
|
||||
<h3>⬇ Modell-Downloads</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn secondary" onclick="send({action:'clear_llm_jobs'})" title="Fertige und fehlgeschlagene aus der Liste nehmen" style="padding:3px 10px;font-size:11px;">Erledigte entfernen</button>
|
||||
<button class="modal-close" onclick="closeLlmDownloads()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
|
||||
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
|
||||
Jede LLM-Karte hält genau <strong style="color:#8888AA;">ein</strong> Modell, dauerhaft. Welche
|
||||
Karten dem LLM gehören, steht in der .env der Box (<code>LLM_GPUS</code>). Laden ersetzt, was auf
|
||||
der Karte lag; die anderen Karten der Box starten dabei kurz neu. Clients (ARIA, Fremdprogramme,
|
||||
OpenHands) wählen nur unter den geladenen Modellen und laden nie selbst.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="info-modal">
|
||||
<div class="modal-box" style="max-width:640px;">
|
||||
<div class="modal-header">
|
||||
@@ -2313,6 +2370,8 @@
|
||||
try { loadModelList(); } catch {}
|
||||
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
|
||||
try { loadSafeguard(); } catch {}
|
||||
// Laufende Modell-Downloads (Badge am ⬇-Knopf)
|
||||
send({ action: 'get_llm_jobs' });
|
||||
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
|
||||
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
|
||||
};
|
||||
@@ -2530,6 +2589,7 @@
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
|
||||
if (msg.type === 'room_clients') { renderRoomClients(msg.payload || {}); 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; }
|
||||
@@ -2563,7 +2623,7 @@
|
||||
if (p.ok) setTimeout(closeDeviceCreds, 700);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
|
||||
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); renderLlmCardSettings(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
|
||||
if (msg.type === 'llm_response') {
|
||||
const p = msg.payload || {};
|
||||
const out = document.getElementById('llm-test-result');
|
||||
@@ -2574,11 +2634,19 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'llm_provision_progress') { onLlmJob(msg.payload); return; }
|
||||
if (msg.type === 'llm_jobs') {
|
||||
_llmJobs = {};
|
||||
for (const j of ((msg.payload || {}).jobs || [])) _llmJobs[_llmJobId(j)] = j;
|
||||
renderLlmDownloads();
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'llm_provision_result') {
|
||||
const p = msg.payload || {};
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) {
|
||||
if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
|
||||
else if (p.unloaded) { st.textContent = `✓ Karte ${p.gpu} geleert (${p.key})`; st.style.color = '#8888AA'; }
|
||||
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
|
||||
else { st.textContent = `✗ '${p.key}': ${p.error || 'Fehler'}`; st.style.color = '#FF6B6B'; }
|
||||
}
|
||||
@@ -3797,13 +3865,19 @@
|
||||
text = `${labels[s] || s}: bereit${info.model ? ' ' + info.model : ''}${sec}${downloadedHint}`;
|
||||
} else if (info.state === 'error') {
|
||||
dot = '❌'; color = '#FF3B30'; anyError = true;
|
||||
text = `${labels[s] || s}: Fehler ${info.error || ''}`;
|
||||
// httpx haengt an jeden Fehler "For more information check: <mozilla-Link>"
|
||||
// — kostet den halben Platz und erklaert nichts.
|
||||
const err = String(info.error || '').replace(/\s*For more information check:\s*\S+/gi, '').trim();
|
||||
text = `${labels[s] || s}: Fehler ${escapeHtml(err)}`;
|
||||
} else {
|
||||
text = `${labels[s] || s}: ${info.state}`;
|
||||
}
|
||||
row.innerHTML = `<span style="color:${color}">${dot}</span><span>${text}</span>`;
|
||||
row.innerHTML = `<span class="svc-dot" style="color:${color}">${dot}</span><span>${text}</span>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
// Nur eine Meldung: das grosse Symbol links sagt schon alles — sonst
|
||||
// stuende z.B. das ❌ doppelt da.
|
||||
if (list.children.length === 1) list.querySelector('.svc-dot').style.display = 'none';
|
||||
|
||||
// Icon spiegelt Gesamt-Status
|
||||
if (anyError) icon.innerHTML = '❌';
|
||||
@@ -3812,20 +3886,27 @@
|
||||
|
||||
banner.style.display = 'block';
|
||||
|
||||
// Wenn alles ready (kein Loading, kein Error): X-Button anzeigen
|
||||
// + nach 8s automatisch wegfaden
|
||||
// X immer da — auch (gerade) bei Fehlern. Alles ready → nach 8 s von selbst weg.
|
||||
closeBtn.style.display = 'block';
|
||||
clearTimeout(_serviceFadeTimer);
|
||||
if (!anyLoading && !anyError) {
|
||||
closeBtn.style.display = 'block';
|
||||
clearTimeout(_serviceFadeTimer);
|
||||
_serviceFadeTimer = setTimeout(() => {
|
||||
banner.style.display = 'none';
|
||||
}, 8000);
|
||||
} else {
|
||||
closeBtn.style.display = 'none';
|
||||
clearTimeout(_serviceFadeTimer);
|
||||
}
|
||||
}
|
||||
|
||||
// Wegklicken: Fehler und Fertig-Meldungen vergessen, sonst kaemen sie mit
|
||||
// der naechsten Statusmeldung eines anderen Dienstes gleich wieder hoch.
|
||||
// Was noch laedt, bleibt gemerkt und zeigt sich beim naechsten Update.
|
||||
function dismissServiceStatus() {
|
||||
for (const [k, v] of Object.entries(_serviceState)) {
|
||||
if (v.state !== 'loading') delete _serviceState[k];
|
||||
}
|
||||
clearTimeout(_serviceFadeTimer);
|
||||
document.getElementById('service-status-banner').style.display = 'none';
|
||||
}
|
||||
|
||||
function updateThinkingIndicator(msg) {
|
||||
// Gedanken-Stream fuettern — JEDES Event (auch idle als ✓ fertig)
|
||||
pushThought(msg.activity || '', msg.tool || '');
|
||||
@@ -5070,6 +5151,7 @@
|
||||
if (tab === 'settings') {
|
||||
send({ action: 'get_voice_config' });
|
||||
loadRuntimeConfig();
|
||||
requestRoomClients();
|
||||
loadOnboardingQR();
|
||||
loadOAuthServices();
|
||||
loadJellyfinConfig();
|
||||
@@ -5126,16 +5208,26 @@
|
||||
const meta = WORKER_SVC_META[w.service] || { icon: '⚙️', label: w.service };
|
||||
const dot = !w.online ? '#666' : (w.busy ? '#FFB020' : '#3FFF3F');
|
||||
const stat = !w.online ? 'offline' : (w.busy ? 'beschaeftigt' : 'frei – idle – keine Auslastung');
|
||||
// llm-Boxen koennen mehrere Modelle fahren (llama-swap) → Liste zeigen.
|
||||
const modelText = (w.service === 'llm' && Array.isArray(w.models) && w.models.length)
|
||||
? w.models.join(', ') : (w.model || '');
|
||||
// LLM-Boxen: pro Karte genau ein Modell, dauerhaft geladen. Laden und
|
||||
// Entladen nur hier (Modell-Katalog), Clients waehlen nur aus.
|
||||
let modelHtml;
|
||||
if (w.service === 'llm' && Array.isArray(w.slots)) {
|
||||
modelHtml = ''; // Karten folgen als eigene Zeilen unten
|
||||
} else if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) {
|
||||
// Box mit altem Adapter (ohne Karten): nur die Liste
|
||||
modelHtml = '<span style="color:#8888AA;" title="Box meldet noch keine Karten — llm-adapter aktualisieren">'
|
||||
+ escapeHtml(w.models.join(', ')) + '</span>';
|
||||
} else {
|
||||
modelHtml = '<span style="color:#8888AA;">' + escapeHtml(w.model || '') + '</span>';
|
||||
}
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:4px 0;">' +
|
||||
'<span style="width:8px;height:8px;border-radius:50%;background:' + dot + ';display:inline-block;"></span>' +
|
||||
'<span>' + meta.icon + ' <b>' + escapeHtml(meta.label) + '</b></span>' +
|
||||
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
|
||||
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
'<span>' + modelHtml + '</span>' +
|
||||
(w.gpus && !(w.service === 'llm' && Array.isArray(w.slots)) ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
|
||||
'</div>' +
|
||||
(w.service === 'llm' && Array.isArray(w.slots) ? renderLlmSlots(w) : '') +
|
||||
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
|
||||
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
|
||||
}).join('');
|
||||
@@ -5285,6 +5377,52 @@
|
||||
function requestSatCreds(id) {
|
||||
send({ action: 'sat_creds_list', satellite: id });
|
||||
}
|
||||
// ── Wer ist im Raum? ─────────────────────────
|
||||
function requestRoomClients() {
|
||||
const box = document.getElementById('room-clients');
|
||||
if (box && !box.dataset.loaded) box.textContent = 'Frage den RVS …';
|
||||
send({ action: 'room_clients_query' });
|
||||
}
|
||||
function _ago(ms) {
|
||||
if (!ms) return '–';
|
||||
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
||||
if (s < 60) return s + ' s';
|
||||
if (s < 3600) return Math.round(s / 60) + ' min';
|
||||
if (s < 86400) return Math.round(s / 3600) + ' h';
|
||||
return Math.round(s / 86400) + ' d';
|
||||
}
|
||||
const ROOM_KIND_ICON = { 'Bridge': '🌉', 'Diagnostic': '🩺', 'App': '📱', 'Satellit': '🛰️',
|
||||
'Host-Agent': '🖥️', 'KI-Box': '🧠', 'LLM-Client': '🤖' };
|
||||
function renderRoomClients(p) {
|
||||
const box = document.getElementById('room-clients');
|
||||
if (!box) return;
|
||||
box.dataset.loaded = '1';
|
||||
if (p.error) { box.innerHTML = '<span style="color:#FF6B6B;">' + escapeHtml(p.error) + '</span>'; return; }
|
||||
const list = p.clients || [];
|
||||
if (!list.length) { box.innerHTML = '<span style="color:#8888AA;">(niemand — oder der RVS kennt die Abfrage noch nicht: RVS aktualisieren)</span>'; return; }
|
||||
const td = 'padding:5px 8px;border-top:1px solid #1E1E2E;vertical-align:top;';
|
||||
box.innerHTML = '<div style="color:#E0E0F0;margin-bottom:6px;">' + list.length + ' Verbindung' + (list.length === 1 ? '' : 'en') + '</div>' +
|
||||
'<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:12px;">' +
|
||||
'<tr style="color:#8888AA;text-align:left;"><th style="padding:4px 8px;">Art</th><th style="padding:4px 8px;">Name</th>' +
|
||||
'<th style="padding:4px 8px;">Details</th><th style="padding:4px 8px;">Adresse</th>' +
|
||||
'<th style="padding:4px 8px;">verbunden seit</th><th style="padding:4px 8px;">zuletzt aktiv</th></tr>' +
|
||||
list.map(c => {
|
||||
const kind = c.kind || '';
|
||||
const icon = ROOM_KIND_ICON[kind] || '❔';
|
||||
const kindTxt = kind ? escapeHtml(kind) : '<span style="color:#8888AA;">unbekannt</span>';
|
||||
const you = c.you ? ' <span style="color:#0096FF;">★ diese Diagnostic</span>' : '';
|
||||
const details = [c.detail, c.version ? 'v' + c.version : ''].filter(Boolean).join(' · ')
|
||||
|| (!kind && c.userAgent ? c.userAgent : '');
|
||||
return '<tr>' +
|
||||
'<td style="' + td + 'white-space:nowrap;">' + icon + ' ' + kindTxt + '</td>' +
|
||||
'<td style="' + td + 'color:#E0E0F0;">' + escapeHtml(c.name || '–') + you + '</td>' +
|
||||
'<td style="' + td + 'color:#8888AA;">' + escapeHtml(details || '–') + '</td>' +
|
||||
'<td style="' + td + 'color:#8888AA;font-family:monospace;">' + escapeHtml(c.ip || '–') + '</td>' +
|
||||
'<td style="' + td + 'color:#8888AA;white-space:nowrap;">' + _ago(c.connectedAt) + '</td>' +
|
||||
'<td style="' + td + 'color:#8888AA;white-space:nowrap;">' + _ago(c.lastSeen) + '</td></tr>';
|
||||
}).join('') + '</table></div>';
|
||||
}
|
||||
|
||||
let rooms = []; // [{token8, fp, len, clients, you}]
|
||||
function requestRooms() { send({ action: 'rooms_query' }); }
|
||||
function renderRooms() {
|
||||
@@ -8812,22 +8950,24 @@
|
||||
// Baut das Modell-Dropdown aus der Vereinigung von live-Flotte + kuratierter
|
||||
// Liste; zeigt pro Modell "· N Box(en)" bzw. "· offline". Auswahl bleibt
|
||||
// erhalten (auch wenn das gewaehlte Modell gerade keine online Box hat).
|
||||
// ARIAs lokales Modell: nur was im Cluster GELADEN ist, jedes einmal, ohne
|
||||
// Karte — wo es laeuft, entscheidet der Cluster. Ist das gewaehlte gerade
|
||||
// nirgends geladen, bleibt es mit "(offline)" stehen; sobald eine Box es
|
||||
// wieder hat, geht es von selbst weiter (bis dahin antwortet Claude).
|
||||
function refreshLocalLlmModelChoices() {
|
||||
const sel = document.getElementById('local-llm-model');
|
||||
if (!sel) return;
|
||||
if (document.activeElement === sel) return; // nicht unter der offenen Auswahl umbauen
|
||||
const counts = fleetLlmModelCounts();
|
||||
const nameOf = id => { const m = _localModelsCache.find(x => x.id === id); return (m && m.display_name) || id; };
|
||||
const ids = new Set();
|
||||
Object.keys(counts).forEach(id => ids.add(id));
|
||||
_localModelsCache.forEach(m => ids.add(m.id));
|
||||
if (_currentLocalModel) ids.add(_currentLocalModel);
|
||||
const order = Array.from(ids).sort();
|
||||
sel.innerHTML = order.map(id => {
|
||||
const n = counts[id] || 0;
|
||||
const avail = n > 0 ? ` · ${n} Box${n > 1 ? 'en' : ''}` : ' · offline';
|
||||
return `<option value="${id}">${escapeHtml(nameOf(id))}${avail}</option>`;
|
||||
}).join('') || '<option value="">(keine)</option>';
|
||||
if (order.includes(_currentLocalModel)) sel.value = _currentLocalModel;
|
||||
const cpu = id => workers.some(w => w.online && (w.slots || []).some(sl => sl.model === id && sl.onCpu));
|
||||
const ids = Object.keys(counts).sort();
|
||||
const opts = ids.map(id => `<option value="${escapeHtml(id)}">${escapeHtml(nameOf(id))}${cpu(id) ? ' · läuft auf der CPU' : ''}</option>`);
|
||||
if (_currentLocalModel && !ids.includes(_currentLocalModel)) {
|
||||
opts.unshift(`<option value="${escapeHtml(_currentLocalModel)}">${escapeHtml(nameOf(_currentLocalModel))} (offline)</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join('') || '<option value="">kein Modell verfügbar</option>';
|
||||
sel.value = _currentLocalModel && (ids.includes(_currentLocalModel) || opts.length) ? _currentLocalModel : (ids[0] || '');
|
||||
updateLocalModelDesc();
|
||||
renderLocalLlmFleet();
|
||||
}
|
||||
@@ -8876,6 +9016,9 @@
|
||||
toolVariant: document.getElementById('local-llm-toolvariant').value,
|
||||
localLlmModel: (modelSel && modelSel.value) || '',
|
||||
};
|
||||
// Sofort merken — sonst stellt das naechste Flotten-Update (alle 10 s) das
|
||||
// Dropdown auf das alte Modell zurueck, obwohl das neue gespeichert ist.
|
||||
if (body.localLlmModel) _currentLocalModel = body.localLlmModel;
|
||||
updateLocalModelDesc();
|
||||
try {
|
||||
const r = await fetch('/api/local-llm-config', {
|
||||
@@ -8915,21 +9058,32 @@
|
||||
function renderLlmCatalog() {
|
||||
const box = document.getElementById('llm-catalog-list');
|
||||
if (!box) return;
|
||||
renderLlmCardSettings();
|
||||
if (!_llmCatalog.length) { box.innerHTML = '<span style="color:#6a6a88;">Katalog leer.</span>'; return; }
|
||||
const boxes = onlineLlmBoxes();
|
||||
const th = 'style="padding:6px 8px;text-align:left;position:sticky;top:0;background:#12122A;"';
|
||||
const td = 'style="padding:6px 8px;vertical-align:top;"';
|
||||
const rows = _llmCatalog.map((m, i) => {
|
||||
const have = boxesServing(m.id);
|
||||
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(b => escapeHtml(b.node)).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
||||
const where = b => {
|
||||
const sl = (b.slots || []).find(x => x.model === m.id);
|
||||
return escapeHtml(b.node) + (sl ? ' · Karte ' + escapeHtml(sl.gpu) : '');
|
||||
};
|
||||
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(where).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
||||
const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—';
|
||||
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
|
||||
const opts = boxes.length
|
||||
? boxes.map(b => `<option value="${escapeHtml(b.instanceId)}">${escapeHtml(b.node)}</option>`).join('')
|
||||
// Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste.
|
||||
const targets = llmCardTargets();
|
||||
const pre = (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
|
||||
const opts = targets.length
|
||||
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
|
||||
: '<option value="">(keine Box online)</option>';
|
||||
return '<tr style="border-bottom:1px solid #1E1E2E;">' +
|
||||
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
|
||||
'<td ' + td + 'color:#9a9ab5;min-width:220px;">' + escapeHtml(m.description || m.hfRepo || '') + '</td>' +
|
||||
'<td ' + td + 'color:#9a9ab5;min-width:220px;">' + escapeHtml(m.description || m.hfRepo || '')
|
||||
+ (m.quant ? ' <span style="color:#555570;" title="Quantisierung, die geladen wird' + (m.quants ? ' — vorhanden: ' + escapeHtml(m.quants.join(', ')) : '') + '">· ' + escapeHtml(m.quant) + '</span>' : '')
|
||||
+ (m.unusable ? ' <span style="color:#FF6B6B;" title="Im Repo liegt keine ladbare GGUF-Modelldatei mehr">· nicht ladbar</span>' : '')
|
||||
+ '</td>' +
|
||||
'<td ' + td + vramStyle + 'white-space:nowrap;">' + vram + '</td>' +
|
||||
'<td ' + td + 'white-space:nowrap;">' + haveTxt + '</td>' +
|
||||
'<td ' + td + '"><select id="llm-cat-box-' + i + '" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">' + opts + '</select></td>' +
|
||||
@@ -8939,18 +9093,282 @@
|
||||
box.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
|
||||
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
|
||||
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
|
||||
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' +
|
||||
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '></th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
// ── Download-Fenster ──────────────────────────────────────────────
|
||||
// Zustand kommt vom Diagnostic-Server (llm_provision_progress / llm_jobs);
|
||||
// der kennt auch Downloads, die vor dem Oeffnen dieses Tabs gestartet sind.
|
||||
let _llmJobs = {};
|
||||
const _llmJobId = j => j.instanceId + '|' + j.key;
|
||||
|
||||
function _fmtBytes(b) {
|
||||
if (!b && b !== 0) return '?';
|
||||
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
|
||||
if (b >= 1e6) return (b / 1e6).toFixed(0) + ' MB';
|
||||
return Math.round(b / 1e3) + ' KB';
|
||||
}
|
||||
function _fmtEta(s) {
|
||||
if (s == null) return '';
|
||||
if (s < 60) return s + ' s';
|
||||
return Math.floor(s / 60) + ' min ' + String(s % 60).padStart(2, '0') + ' s';
|
||||
}
|
||||
function _boxName(instanceId) {
|
||||
const w = (typeof workers !== 'undefined' ? workers : []).find(x => x.instanceId === instanceId);
|
||||
return (w && w.node) || (instanceId || '').replace(/^llm@/, '');
|
||||
}
|
||||
|
||||
function onLlmJob(job) {
|
||||
if (!job || !job.key) return;
|
||||
_llmJobs[_llmJobId(job)] = job;
|
||||
renderLlmDownloads();
|
||||
renderLlmCardSettings();
|
||||
// Fertig geladen → Katalog-Status ("✓ ki-box") nachziehen.
|
||||
if (job.phase === 'ready' && typeof requestWorkers === 'function') requestWorkers();
|
||||
}
|
||||
|
||||
function renderLlmDownloads() {
|
||||
const jobs = Object.values(_llmJobs).sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
|
||||
const running = jobs.filter(j => !['ready', 'error'].includes(j.phase)).length;
|
||||
const badge = document.getElementById('llm-dl-badge');
|
||||
if (badge) { badge.textContent = running; badge.style.display = running ? 'inline-block' : 'none'; }
|
||||
const box = document.getElementById('llm-dl-list');
|
||||
if (!box) return;
|
||||
if (!jobs.length) {
|
||||
box.innerHTML = '<div style="color:#6a6a88;text-align:center;padding:20px;">Keine Downloads. „Laden" im Modell-Katalog startet einen.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = jobs.map(j => {
|
||||
const box_ = escapeHtml(_boxName(j.instanceId)) + (j.gpu != null && j.gpu !== '' ? ' · Karte ' + escapeHtml(String(j.gpu)) : '')
|
||||
+ (j.replaces ? ' <span style="color:#6a6a88;">(ersetzt ' + escapeHtml(j.replaces) + ')</span>' : '');
|
||||
let pct = 0, color = '#0096FF', line = '', sub = '';
|
||||
if (j.phase === 'queued') {
|
||||
// Kommt hier nie mehr was, erreicht der Auftrag die Box nicht.
|
||||
const age = (Date.now() - (j.requestedAt || j.startedAt || Date.now())) / 1000;
|
||||
line = 'Angefragt — warte auf die Box …';
|
||||
if (age > 30) { line = '⚠ Die Box antwortet nicht.'; color = '#FF9F0A';
|
||||
sub = 'Ist sie online? Nach einem Update von RVS und Box erneut versuchen.'; }
|
||||
} else if (j.phase === 'start') {
|
||||
line = 'Box hat den Auftrag — prüfe, ob das Modell schon da ist …';
|
||||
} else if (j.phase === 'download') {
|
||||
pct = j.percent != null ? j.percent : 0;
|
||||
line = j.percent != null
|
||||
? `${pct.toFixed(1)} % · ${_fmtBytes(j.bytes)} / ${_fmtBytes(j.total)}`
|
||||
: `${_fmtBytes(j.bytes)} heruntergeladen (Größe unbekannt)`;
|
||||
sub = (j.speedBps ? (j.speedBps / 1e6).toFixed(1) + ' MB/s' : '') + (j.etaSeconds != null ? ' · noch ' + _fmtEta(j.etaSeconds) : '');
|
||||
} else if (j.phase === 'load') {
|
||||
pct = 100; color = '#FFD60A';
|
||||
line = j.cached ? 'Liegt schon auf der Box — lädt in die Karte …' : '100 % heruntergeladen — lädt jetzt in die Karte …';
|
||||
sub = 'Das Modell wandert in den Grafikspeicher; das dauert je nach Größe ein paar Sekunden bis Minuten.';
|
||||
} else if (j.phase === 'ready') {
|
||||
pct = 100; color = '#34C759';
|
||||
const card = j.gpu != null && j.gpu !== '' ? `Karte ${escapeHtml(String(j.gpu))}${j.gpuName ? ' (' + escapeHtml(j.gpuName) + ')' : ''}` : 'die Karte';
|
||||
line = `✓ In ${card} geladen — bereit`;
|
||||
const vram = j.vramTotalMB ? ` · VRAM ${(j.vramUsedMB / 1024).toFixed(1)} / ${(j.vramTotalMB / 1024).toFixed(1)} GB belegt` : '';
|
||||
sub = (j.loadSeconds != null ? `Dauer ${_fmtEta(Math.round(j.loadSeconds))}` : '') + vram;
|
||||
} else if (j.phase === 'error') {
|
||||
color = '#FF6B6B'; pct = j.percent || 0;
|
||||
line = '✗ ' + escapeHtml(j.error || 'Fehler');
|
||||
}
|
||||
const bar = `<div style="height:8px;background:#1E1E2E;border-radius:4px;overflow:hidden;margin:6px 0 4px;">
|
||||
<div style="height:100%;width:${Math.max(0, Math.min(100, pct))}%;background:${color};transition:width .6s;
|
||||
${j.phase === 'queued' || j.phase === 'start' ? 'opacity:.35;width:100%;animation:llmdlpulse 1.2s ease-in-out infinite;' : ''}"></div></div>`;
|
||||
return `<div style="background:#12121E;border:1px solid #2A2A3E;border-radius:8px;padding:10px 12px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;">
|
||||
<strong style="color:#E0E0F0;">${escapeHtml(j.key)}</strong>
|
||||
<span style="color:#8888AA;font-size:11px;">→ ${box_}</span>
|
||||
</div>${bar}
|
||||
<div style="color:${color === '#0096FF' ? '#E0E0F0' : color};font-size:12px;">${line}</div>
|
||||
${sub ? `<div style="color:#6a6a88;font-size:11px;margin-top:2px;">${sub}</div>` : ''}
|
||||
${j.note && j.phase !== 'error' ? `<div style="color:#FFD60A;font-size:11px;margin-top:2px;">ℹ ${escapeHtml(j.note)}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openLlmDownloads() {
|
||||
send({ action: 'get_llm_jobs' });
|
||||
renderLlmDownloads();
|
||||
document.getElementById('llm-dl-modal').classList.add('open');
|
||||
}
|
||||
function closeLlmDownloads() {
|
||||
document.getElementById('llm-dl-modal').classList.remove('open');
|
||||
}
|
||||
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
|
||||
setInterval(() => {
|
||||
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
||||
}, 5000);
|
||||
|
||||
// ── LLM-Karten: Dropdown pro Karte (Einstellungen → Modelle & KI) ──
|
||||
// Optionen = Modell-Katalog + was gerade geladen ist (falls nicht im
|
||||
// Katalog). Zu gross fuer die Karte → markiert, aber waehlbar (Groesse ist
|
||||
// nur eine Schaetzung, der Kontext braucht zusaetzlich Platz).
|
||||
function renderLlmCardSettings() {
|
||||
const box = document.getElementById('llm-card-settings');
|
||||
if (!box) return;
|
||||
if (!_llmCatalog.length && !window._llmCatalogRequested) {
|
||||
window._llmCatalogRequested = true;
|
||||
loadLlmCatalog(); // ruft renderLlmCatalog → renderLlmCardSettings
|
||||
}
|
||||
const boxes = onlineLlmBoxes().filter(b => Array.isArray(b.slots));
|
||||
if (!boxes.length) {
|
||||
const old = onlineLlmBoxes().length;
|
||||
box.innerHTML = '<span style="color:#6a6a88;">' + (old
|
||||
? 'Die LLM-Box meldet noch keine Karten — llm-adapter auf der Box aktualisieren.'
|
||||
: 'Keine LLM-Box online.') + '</span>';
|
||||
return;
|
||||
}
|
||||
const rows = [];
|
||||
for (const b of boxes) {
|
||||
for (const sl of b.slots) {
|
||||
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
|
||||
const ids = new Set(_llmCatalog.map(m => m.id));
|
||||
const opts = ['<option value="">— leer —</option>'];
|
||||
const list = _llmCatalog.filter(m => !m.unusable);
|
||||
if (sl.model && !ids.has(sl.model)) list.unshift({ id: sl.model });
|
||||
for (const m of list) {
|
||||
// Liegt das Modell schon auf einer ANDEREN Karte dieser Box? → nicht anbieten.
|
||||
const elsewhere = (b.slots || []).find(x => x.model === m.id && x.gpu !== sl.gpu);
|
||||
if (elsewhere) continue;
|
||||
const tooBig = vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92;
|
||||
const label = m.id + (m.sizeGB ? ` · ~${m.sizeGB} GB` : '') + (m.quant ? ` · ${m.quant}` : '')
|
||||
+ (tooBig ? ' · passt nicht' : '');
|
||||
opts.push(`<option value="${escapeHtml(m.id)}"${m.id === sl.model ? ' selected' : ''}${tooBig ? ' style="color:#FF9F0A;"' : ''}>${escapeHtml(label)}</option>`);
|
||||
}
|
||||
let [c, st] = sl.model ? (SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')]) : ['#555570', 'leer'];
|
||||
if (sl.onCpu) { c = '#FF9F0A'; st = '⚠ auf der CPU'; }
|
||||
const job = Object.values(_llmJobs || {}).find(j => j.instanceId === b.instanceId && String(j.gpu) === String(sl.gpu)
|
||||
&& !['ready', 'error'].includes(j.phase));
|
||||
const jobTxt = job ? (job.phase === 'download' && job.percent != null ? `lädt ${job.percent.toFixed(0)} %` : 'lädt …') : '';
|
||||
// Kontextfenster dieser Karte. Mehr Kontext = mehr VRAM; passt es
|
||||
// nicht, setzt die Box die Karte von selbst zurueck.
|
||||
const CTX = [4096, 8192, 16384, 32768, 65536, 131072];
|
||||
const curCtx = sl.ctx || 0;
|
||||
const ctxOpts = (curCtx && !CTX.includes(curCtx) ? [curCtx, ...CTX] : CTX)
|
||||
.map(n => `<option value="${n}"${n === curCtx ? ' selected' : ''}>${n >= 1024 ? (n / 1024) + 'k' : n} Tokens</option>`).join('');
|
||||
const ctxSel = sl.model
|
||||
? `<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-model="${escapeHtml(sl.model)}" data-current="${curCtx}"
|
||||
onchange="onLlmCardCtx(this)" ${job ? 'disabled' : ''} title="Kontextfenster (Eingabe + Ausgabe). Mehr braucht mehr Grafikspeicher."
|
||||
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 6px;color:#E0E0F0;font-size:12px;">${ctxOpts}</select>`
|
||||
: '<span></span>';
|
||||
rows.push(`<div style="display:grid;grid-template-columns:minmax(220px,1fr) minmax(260px,2fr) 130px 160px;gap:10px;align-items:center;">
|
||||
<div><b style="color:#E0E0F0;">${escapeHtml(b.node)}</b> · Karte ${escapeHtml(sl.gpu)}
|
||||
<div style="color:#555570;font-size:11px;">${escapeHtml(sl.gpuName || '')}${vramGB ? ' · ' + vramGB.toFixed(0) + ' GB' : ''}${sl.vramUsedMB >= 0 ? ' · belegt ' + (sl.vramUsedMB / 1024).toFixed(1) + ' GB' : ''}${sl.ctx ? ' · Kontext ' + sl.ctx : ''}</div>
|
||||
${sl.onCpu ? '<div style="color:#FF9F0A;font-size:11px;" title="llama.cpp findet die Karte nicht und rechnet langsam auf der CPU. Ursache meist: Treiber zu alt fuer das CUDA im llama-swap-Image. Abhilfe: auf der Box sudo ./bootstrap.sh --upgrade-driver">⚠ Modell läuft auf der CPU, Karte leer — Treiber zu alt? (bootstrap.sh --upgrade-driver)</div>' : ''}</div>
|
||||
<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-current="${escapeHtml(sl.model || '')}"
|
||||
onchange="onLlmCardSelect(this)" ${job ? 'disabled' : ''}
|
||||
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;">${opts.join('')}</select>
|
||||
${ctxSel}
|
||||
<span style="color:${job ? '#FFD60A' : c};font-size:12px;">${jobTxt || st}</span>
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
box.innerHTML = rows.join('');
|
||||
}
|
||||
|
||||
// Kontextfenster einer Karte aendern = dasselbe Modell mit anderem -c neu
|
||||
// laden. Programme bekommen den neuen Wert automatisch (ctx in
|
||||
// llm_cluster_models). Passt es nicht in den Grafikspeicher, setzt die Box
|
||||
// die Karte auf den alten Stand zurueck.
|
||||
function onLlmCardCtx(sel) {
|
||||
const target = sel.dataset.box, gpu = sel.dataset.gpu, model = sel.dataset.model;
|
||||
const cur = Number(sel.dataset.current || 0), next = Number(sel.value);
|
||||
if (next === cur) return;
|
||||
const m = _llmCatalog.find(x => x.id === model);
|
||||
if (!m || !m.hfRepo) { alert(`${model} steht nicht im Modell-Katalog — Kontext so nicht änderbar.`); sel.value = cur; return; }
|
||||
const more = next > cur;
|
||||
if (!confirm(`${model} auf ${_boxName(target)} · Karte ${gpu} mit ${next} statt ${cur || '?'} Tokens Kontext neu laden?\n\n`
|
||||
+ (more ? 'Mehr Kontext braucht mehr Grafikspeicher. Passt es nicht, setzt die Box die Karte von selbst auf den alten Stand zurück.\n\n' : '')
|
||||
+ 'Die Box startet ihre Modelle dabei kurz neu. Programme bekommen den neuen Wert automatisch.')) { sel.value = cur; return; }
|
||||
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: next });
|
||||
openLlmDownloads();
|
||||
}
|
||||
|
||||
function onLlmCardSelect(sel) {
|
||||
const target = sel.dataset.box, gpu = sel.dataset.gpu, current = sel.dataset.current, next = sel.value;
|
||||
const node = _boxName(target);
|
||||
if (next === current) return;
|
||||
if (!next) {
|
||||
if (!confirm(`Karte ${gpu} auf ${node} leeren?\n\n${current} wird entladen; ARIA und Programme können es dann nicht mehr wählen. `
|
||||
+ `Die Datei bleibt auf der Box.\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) { sel.value = current; return; }
|
||||
send({ action: 'llm_unload_model', targetInstance: target, gpu });
|
||||
return;
|
||||
}
|
||||
const m = _llmCatalog.find(x => x.id === next);
|
||||
if (!m || !m.hfRepo) { alert(`${next} steht nicht im Modell-Katalog — Herkunft unbekannt.`); sel.value = current; return; }
|
||||
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
|
||||
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
|
||||
const warn = (vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92)
|
||||
? `\n\n⚠ ${next} ist etwa ${m.sizeGB} GB groß, die Karte hat ${vramGB.toFixed(0)} GB — wahrscheinlich passt es nicht.` : '';
|
||||
const repl = current ? `\n\nErsetzt ${current} — Programme, die es nutzen, verlieren es.` : '';
|
||||
if (!confirm(`${next} auf ${node} · Karte ${gpu} laden?${repl}${warn}\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) { sel.value = current; return; }
|
||||
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||
openLlmDownloads();
|
||||
}
|
||||
|
||||
// ── LLM-Karten ────────────────────────────────────────────────────
|
||||
const SLOT_STATE = {
|
||||
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
|
||||
stopping: ['#FF9F0A', 'stoppt …'], stopped: ['#FF6B6B', '✗ nicht gestartet'],
|
||||
unknown: ['#8888AA', '? (llama-swap antwortet nicht)'],
|
||||
};
|
||||
function renderLlmSlots(w) {
|
||||
const rows = (w.slots || []).map(sl => {
|
||||
const card = 'Karte ' + escapeHtml(sl.gpu) + (sl.gpuName ? ' <span style="color:#555570;">(' + escapeHtml(sl.gpuName) + ')</span>' : '');
|
||||
if (!sl.model) {
|
||||
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||
<span style="color:#555570;">— leer · im Modell-Katalog „Laden" → diese Karte</span></div>`;
|
||||
}
|
||||
let [c, label] = SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')];
|
||||
if (sl.onCpu) { c = '#FF9F0A'; label = '⚠ läuft auf der CPU — Karte leer (Treiber zu alt?)'; }
|
||||
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||
<b style="color:#E0E0F0;">${escapeHtml(sl.model)}</b>
|
||||
<span style="color:${c};">${label}</span>
|
||||
<button class="btn secondary" onclick="unloadLlmCard('${escapeHtmlAttr(w.instanceId)}','${escapeHtmlAttr(sl.gpu)}','${escapeHtmlAttr(sl.model)}')"
|
||||
title="Karte leeren — das Modell bleibt auf der Box gespeichert" style="margin-left:auto;padding:2px 8px;font-size:10px;">Entladen</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div style="margin:2px 0 6px 18px;padding:6px 10px;background:#0D0D18;border-left:2px solid #2A2A3E;font-size:11px;">${rows || '<span style="color:#555570;">Keine LLM-Karte konfiguriert (LLM_GPUS in der .env der Box).</span>'}</div>`;
|
||||
}
|
||||
function unloadLlmCard(instanceId, gpu, model) {
|
||||
if (!confirm(`Karte ${gpu} leeren?\n\n${model} wird entladen; Clients können es danach nicht mehr wählen. `
|
||||
+ `Die Datei bleibt auf der Box — erneutes Laden geht ohne Download.\n\n`
|
||||
+ `Die anderen Karten dieser Box starten dabei kurz neu (wenige Sekunden).`)) return;
|
||||
send({ action: 'llm_unload_model', targetInstance: instanceId, gpu });
|
||||
}
|
||||
// Ziele im Katalog: jede LLM-Karte jeder Box ("instanceId|karte").
|
||||
function llmCardTargets() {
|
||||
const out = [];
|
||||
for (const b of onlineLlmBoxes()) {
|
||||
if (Array.isArray(b.slots) && b.slots.length) {
|
||||
for (const sl of b.slots) out.push({ value: b.instanceId + '|' + sl.gpu, box: b, slot: sl,
|
||||
label: `${b.node} · Karte ${sl.gpu}` + (sl.model ? ` (${sl.model})` : ' (leer)') });
|
||||
} else {
|
||||
out.push({ value: b.instanceId + '|', box: b, slot: null, label: b.node }); // alter Adapter
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function provisionModel(i) {
|
||||
const m = _llmCatalog[i];
|
||||
if (!m) return;
|
||||
const sel = document.getElementById('llm-cat-box-' + i);
|
||||
const target = sel && sel.value;
|
||||
const [target, gpu] = String((sel && sel.value) || '').split('|');
|
||||
if (!target) return;
|
||||
const tgt = llmCardTargets().find(t => t.value === sel.value);
|
||||
if (tgt && tgt.slot && tgt.slot.model === m.id) { alert(`${m.id} liegt schon auf dieser Karte.`); return; }
|
||||
const busyBox = ((tgt && tgt.box.slots) || []).find(sl => sl.model === m.id);
|
||||
if (busyBox) { alert(`${m.id} liegt auf dieser Box schon auf Karte ${busyBox.gpu}. Ein Modell pro Box nur einmal.`); return; }
|
||||
if (tgt && tgt.slot && tgt.slot.model &&
|
||||
!confirm(`Karte ${gpu} auf ${tgt.box.node} hält gerade ${tgt.slot.model}.\n\n`
|
||||
+ `${m.id} ersetzt es. Clients, die ${tgt.slot.model} nutzen, verlieren es dann.\n`
|
||||
+ `Die anderen Karten dieser Box starten dabei kurz neu.\n\nErsetzen?`)) return;
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
|
||||
send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||
openLlmDownloads();
|
||||
}
|
||||
function runLlmTest() {
|
||||
const inp = document.getElementById('llm-test-input');
|
||||
|
||||
+146
-5
@@ -473,8 +473,50 @@ function saveLlmCatalog(arr) {
|
||||
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).
|
||||
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
|
||||
// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
|
||||
// eingetragen — auch bei schon vorhandenen HF-Eintraegen.
|
||||
async function refreshLlmCatalogFromHF() {
|
||||
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
|
||||
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
@@ -489,9 +531,24 @@ async function refreshLlmCatalogFromHF() {
|
||||
const id = slugModelId(repo);
|
||||
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
||||
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
|
||||
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf" };
|
||||
byId.set(id, entry); added++;
|
||||
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true };
|
||||
byId.set(id, entry);
|
||||
}
|
||||
// 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) {
|
||||
// Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren.
|
||||
if (e._new) byId.delete(e.id); else e.unusable = true;
|
||||
return;
|
||||
}
|
||||
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
|
||||
}));
|
||||
}
|
||||
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
|
||||
const merged = Array.from(byId.values());
|
||||
saveLlmCatalog(merged);
|
||||
return { models: merged, added };
|
||||
@@ -601,6 +658,13 @@ 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);
|
||||
@@ -705,6 +769,10 @@ function workerList() {
|
||||
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 || [],
|
||||
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
||||
}));
|
||||
}
|
||||
@@ -1148,6 +1216,12 @@ function connectRVS(forcePlain) {
|
||||
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(() => {
|
||||
@@ -1226,6 +1300,9 @@ function connectRVS(forcePlain) {
|
||||
} 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 || {} });
|
||||
@@ -1260,6 +1337,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] : []),
|
||||
// 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 || []),
|
||||
// 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).
|
||||
@@ -1282,6 +1364,10 @@ function connectRVS(forcePlain) {
|
||||
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();
|
||||
}
|
||||
@@ -1372,7 +1458,17 @@ function connectRVS(forcePlain) {
|
||||
broadcast({ type: "service_status", payload: msg.payload });
|
||||
} else if (msg.type === "llm_provision_result") {
|
||||
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
|
||||
broadcast({ type: "llm_provision_result", payload: msg.payload || {} });
|
||||
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 || {} });
|
||||
@@ -1380,7 +1476,7 @@ function connectRVS(forcePlain) {
|
||||
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
|
||||
_handlePreviewChunk(msg.payload);
|
||||
} else {
|
||||
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
|
||||
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg, redactSecrets).slice(0, 150)}`);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
@@ -2074,6 +2170,27 @@ setInterval(checkDiskSpace, 30000);
|
||||
|
||||
// ── 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");
|
||||
|
||||
@@ -3073,6 +3190,13 @@ wss.on("connection", (ws) => {
|
||||
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.
|
||||
@@ -3320,13 +3444,30 @@ wss.on("connection", (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() });
|
||||
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,
|
||||
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
|
||||
}, 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") {
|
||||
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
||||
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
||||
|
||||
@@ -136,6 +136,7 @@ services:
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||
- RVS_TOKEN=${RVS_TOKEN:-}
|
||||
- CLUSTER_LLM_TIMEOUT_S=${CLUSTER_LLM_TIMEOUT_S:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Diagnostic (Selbstcheck-UI und Einstellungen) ────
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# Lokale Sprachmodelle nutzen — Schnittstelle für Programme
|
||||
|
||||
Wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt.
|
||||
|
||||
**Kurz:** Du fragst ein **Modell**, nicht eine Box oder Karte. Der Cluster
|
||||
sucht sich aus, wo es läuft. Eigene Programme sprechen dafür über den RVS
|
||||
(Abschnitt A). Programme, die nur „OpenAI-kompatibel" können, bekommen den
|
||||
OpenAI-RVS-Wrapper neben sich gestellt (Abschnitt B).
|
||||
|
||||
## Die Grundregel
|
||||
|
||||
**Programme wählen ein Modell aus — laden aber nie.**
|
||||
|
||||
- Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten
|
||||
hält **genau ein Modell, dauerhaft geladen**.
|
||||
- Welches Modell wo liegt, legt nur der Betreiber in der ARIA-Diagnostic fest
|
||||
(Einstellungen → LLM-Karten).
|
||||
- Ein Programm sieht die geladenen Modelle des ganzen Clusters — jedes
|
||||
**einmal**, egal auf wie vielen Karten es liegt — und wählt eins.
|
||||
- Liegt das Modell auf mehreren Karten, **verteilt der Cluster bei Last**.
|
||||
Fällt eine Box mitten in einer Anfrage aus, übernimmt eine andere mit
|
||||
demselben Modell. Davon merkt das Programm nichts.
|
||||
- Ist das Modell nirgends geladen, gibt es einen **Fehler** — nie eine normale
|
||||
Antwort mit Fehlertext. Ein Programm würde so einen Text sonst als Aussage
|
||||
der KI in seinen Verlauf schreiben.
|
||||
- Mehrere Programme dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
|
||||
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer.
|
||||
|
||||
## Welcher Zugang?
|
||||
|
||||
| | A) Cluster über RVS | B) OpenAI-RVS-Wrapper | C) Direkt an eine Box |
|
||||
|---|---|---|---|
|
||||
| Für | eigene Programme | OpenHands, Continue, Open WebUI … | Sonderfälle |
|
||||
| Sieht | alle Modelle, jedes einmal | alle Modelle, jedes einmal | nur diese Box |
|
||||
| Verteilung + Ausfall-Ersatz | ja | ja | nein |
|
||||
| Braucht | ausgehende Verbindung zum RVS | Wrapper-Container auf demselben Rechner | ausgehende Verbindung zum RVS |
|
||||
|
||||
---
|
||||
|
||||
## A) Cluster über RVS — empfohlen
|
||||
|
||||
### Verbinden
|
||||
|
||||
```
|
||||
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
|
||||
```
|
||||
|
||||
- Das **Token ist der Raum** — dasselbe `RVS_TOKEN` wie bei ARIA und den
|
||||
KI-Boxen. Wer es hat, ist im Raum.
|
||||
- Der Programm-Rechner braucht nur eine **ausgehende** Verbindung zum RVS.
|
||||
Keine Portfreigabe, kein VPN — er kann in jedem Netz stehen.
|
||||
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an alle anderen im Raum.
|
||||
Du siehst also auch Antworten für andere — ordne sie immer über die
|
||||
`requestId` zu.
|
||||
- Unbekannte Nachrichtentypen verwirft der RVS **ohne Fehlermeldung**. Die hier
|
||||
beschriebenen sind freigegeben.
|
||||
- **Nur ein Leser pro Verbindung.** Schickst du mehrere Anfragen gleichzeitig,
|
||||
liest eine Schleife alle Nachrichten und verteilt sie per `requestId` an die
|
||||
Wartenden (siehe Beispiel). Mehrere gleichzeitige `recv()` auf derselben
|
||||
Verbindung gehen nicht.
|
||||
|
||||
Jede Nachricht hat diese Hülle:
|
||||
|
||||
```json
|
||||
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
|
||||
```
|
||||
|
||||
Beantwortet werden `llm_cluster_*`-Nachrichten von der **ARIA-Bridge** — sie
|
||||
kennt alle Boxen und entscheidet, wohin eine Anfrage geht. Läuft ARIA nicht,
|
||||
kommt keine Antwort (dein Programm sollte einen Timeout haben).
|
||||
|
||||
### 1. Welche Modelle gibt es? — `llm_cluster_models_request`
|
||||
|
||||
```json
|
||||
{ "type": "llm_cluster_models_request", "payload": { "requestId": "a1" } }
|
||||
```
|
||||
|
||||
Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_cluster_models",
|
||||
"payload": {
|
||||
"requestId": "a1",
|
||||
"models": [
|
||||
{
|
||||
"model": "qwen3-8b",
|
||||
"ctx": 8192,
|
||||
"available": 2,
|
||||
"cards": [
|
||||
{ "node": "ki-box", "instanceId": "llm@ki-box", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
|
||||
{ "node": "werkstatt", "instanceId": "llm@werkstatt", "gpu": "0", "gpuName": "NVIDIA RTX A4000", "state": "ready" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `model` | Name — genau so in der Anfrage angeben. **Das** zeigst du dem Nutzer zur Auswahl. |
|
||||
| `ctx` | **Kontextfenster in Tokens** (Eingabe + Ausgabe zusammen). Das kleinste unter allen Karten mit diesem Modell — darauf kannst du dich verlassen, egal wohin verteilt wird. Nicht mehr annehmen. `0` = unbekannt. **Kann sich ändern** (der Betreiber stellt es pro Karte ein) — vor längeren Aufträgen neu holen. |
|
||||
| `available` | auf wie vielen Karten es gerade liegt |
|
||||
| `cards` | nur zur Info (Anzeige, Fehlersuche) — für die Anfrage nicht nötig |
|
||||
|
||||
### 2. Fragen — `llm_cluster_request`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_cluster_request",
|
||||
"payload": {
|
||||
"requestId": "a2",
|
||||
"model": "qwen3-8b",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Du antwortest kurz." },
|
||||
{ "role": "user", "content": "Wie hoch ist die Zugspitze?" }
|
||||
],
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) |
|
||||
| `model` | ja* | aus `llm_cluster_models`. *Darf fehlen, wenn im Cluster genau ein Modell geladen ist. |
|
||||
| `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) |
|
||||
| `max_tokens` | nein | Standard 512 |
|
||||
| `temperature` | nein | Standard 0.7 |
|
||||
| `stop` | nein | Liste von Stop-Strings |
|
||||
| `tools` | nein | Tool-Definitionen im OpenAI-Format; die Antwort enthält dann ggf. `tool_calls` |
|
||||
|
||||
### 3. Antwort — `llm_cluster_response`
|
||||
|
||||
Erfolg:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_cluster_response",
|
||||
"payload": {
|
||||
"requestId": "a2",
|
||||
"ok": true,
|
||||
"content": "Die Zugspitze ist 2962 Meter hoch.",
|
||||
"model": "qwen3-8b",
|
||||
"node": "werkstatt",
|
||||
"gpu": "0",
|
||||
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
|
||||
"elapsedMs": 640,
|
||||
"attempts": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tool_calls` steht drin, wenn das Modell ein Werkzeug aufrufen will (OpenAI-Format).
|
||||
`node`/`gpu` sagen, wer geantwortet hat; `attempts: 2` heißt, die erste Box ist
|
||||
ausgefallen und eine andere hat übernommen.
|
||||
|
||||
Fehler — `ok: false`, dazu `error` (Text für Menschen) und `errorCode`:
|
||||
|
||||
| `errorCode` | Bedeutung | Was tun |
|
||||
|---|---|---|
|
||||
| `model_unavailable` | Das Modell ist gerade auf keiner Karte im Cluster geladen. `available` nennt, was es gibt. | Dem Nutzer anzeigen; Liste neu holen. |
|
||||
| `bad_request` | Anfrage unvollständig, z.B. `messages` leer oder `model` fehlt bei mehreren Modellen | Anfrage korrigieren |
|
||||
| `timeout` | Keine Antwort innerhalb der Frist (Standard 600 s) — auch nach Ersatzversuch | später nochmal |
|
||||
| `box_offline` | Box ist während der Anfrage weggefallen, und es gab keine Ersatz-Box | später nochmal |
|
||||
| `context_exceeded` | Der Verlauf ist länger als das Kontextfenster. Dazu `promptTokens` (so lang war die Anfrage) und `ctx` (so viel geht). | Verlauf kürzen und nochmal senden — z.B. auf ~80 % von `ctx` abzüglich der gewünschten Antwortlänge. |
|
||||
| `model_error` | Das Modell selbst hat einen anderen Fehler gemeldet. Wird nicht anderswo wiederholt — wäre dort genauso. | `error` lesen |
|
||||
|
||||
**Fehler nie als Antwort der KI behandeln.** Zeig `error` dem Nutzer als
|
||||
Fehlermeldung an, aber schreib ihn nicht als Assistant-Nachricht in den
|
||||
Verlauf.
|
||||
|
||||
### Beispiel (Python)
|
||||
|
||||
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Ein Leser
|
||||
pro Verbindung, mehrere Anfragen gleichzeitig möglich.
|
||||
|
||||
```python
|
||||
import asyncio, json, uuid
|
||||
import websockets
|
||||
|
||||
RVS = "wss://rvs.example.org:444/?token=DEIN_RVS_TOKEN"
|
||||
|
||||
|
||||
class AriaCluster:
|
||||
"""Eine RVS-Verbindung, ein Leser. Antworten gehen per requestId an die
|
||||
wartende Anfrage — der Raum ist ein Verteiler, Fremdes wird ignoriert."""
|
||||
|
||||
def __init__(self, ws):
|
||||
self.ws, self.wait = ws, {}
|
||||
self.reader = asyncio.create_task(self._read())
|
||||
|
||||
async def _read(self):
|
||||
async for raw in self.ws:
|
||||
msg = json.loads(raw)
|
||||
p = msg.get("payload") or {}
|
||||
fut = self.wait.get((msg.get("type"), p.get("requestId")))
|
||||
if fut and not fut.done():
|
||||
fut.set_result(p)
|
||||
|
||||
async def _call(self, mtype, answer, payload, timeout):
|
||||
rid = str(uuid.uuid4())
|
||||
fut = asyncio.get_running_loop().create_future()
|
||||
self.wait[(answer, rid)] = fut
|
||||
try:
|
||||
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid)}))
|
||||
return await asyncio.wait_for(fut, timeout)
|
||||
finally:
|
||||
self.wait.pop((answer, rid), None)
|
||||
|
||||
async def models(self):
|
||||
res = await self._call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
|
||||
return res["models"]
|
||||
|
||||
async def chat(self, model, messages, timeout=660, **opts):
|
||||
res = await self._call("llm_cluster_request", "llm_cluster_response",
|
||||
dict(opts, model=model, messages=messages), timeout)
|
||||
if not res["ok"]:
|
||||
raise RuntimeError(f"{res.get('errorCode')}: {res.get('error')}")
|
||||
return res
|
||||
|
||||
|
||||
async def main():
|
||||
async with websockets.connect(RVS, max_size=2**24) as ws:
|
||||
cluster = AriaCluster(ws)
|
||||
models = await cluster.models()
|
||||
for m in models:
|
||||
print(f"{m['model']} (Kontext {m['ctx']}, auf {m['available']} Karte(n))")
|
||||
if models:
|
||||
res = await cluster.chat(models[0]["model"],
|
||||
[{"role": "user", "content": "Sag Hallo in einem Satz."}],
|
||||
max_tokens=64)
|
||||
print(res["content"], f"— von {res['node']} / Karte {res['gpu']}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Umstellen von der direkten Box-Ansprache
|
||||
|
||||
Wer schon gegen Abschnitt C gebaut hat (`llm_models_request` /
|
||||
`llm_request` mit `targetInstance`), stellt so um:
|
||||
|
||||
1. **Modelliste:** `llm_cluster_models_request` statt `llm_models_request`.
|
||||
Statt einer Antwort pro Box kommt **eine** Antwort mit jedem Modell
|
||||
einmal. Kein Einsammeln über Sekunden mehr.
|
||||
2. **Auswahl:** Dem Nutzer nur den **Modellnamen** zeigen — nicht mehr
|
||||
„Modell · Karte · Box". Wo es läuft, entscheidet der Cluster.
|
||||
3. **Kontextfenster:** `ctx` aus der Liste übernehmen statt einen eigenen Wert
|
||||
einzustellen — und **danach kürzen**, nicht nur anzeigen. Die Box gibt nur
|
||||
so viel her, wie dort steht; rechnet ein Programm mit mehr, kommt
|
||||
`context_exceeded`. Den Fehler zusätzlich abfangen: kürzen und nochmal
|
||||
senden (`promptTokens`/`ctx` stehen dabei). Tokens grob schätzen reicht
|
||||
nicht immer — Code und Deutsch brauchen mehr Tokens pro Zeichen als
|
||||
englischer Fließtext.
|
||||
4. **Anfrage:** `llm_cluster_request` statt `llm_request`; **kein**
|
||||
`targetInstance` mehr. Die Antwort kommt als `llm_cluster_response`.
|
||||
5. **Fehler:** `ok: false` als Fehler anzeigen, nicht als Antwort in den
|
||||
Verlauf. Neue Codes siehe Tabelle oben.
|
||||
6. **Lesen:** ein Leser pro Verbindung, Antworten per `requestId` zuordnen.
|
||||
|
||||
Felder und Format von `messages`, `tools`, `tool_calls` und `usage` bleiben
|
||||
gleich.
|
||||
|
||||
---
|
||||
|
||||
## B) OpenAI-RVS-Wrapper — für Programme, die nur OpenAI können
|
||||
|
||||
Ein kleiner Container, der **auf demselben Rechner** wie das Programm läuft.
|
||||
Für das Programm ist der Cluster dann ein ganz normaler OpenAI-Endpunkt:
|
||||
|
||||
```
|
||||
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
|
||||
```
|
||||
|
||||
Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
|
||||
|
||||
```bash
|
||||
cd xtts/openai_rvs_wrapper
|
||||
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Im Browser zeigt **http://127.0.0.1:8099/** eine Hilfsseite: ob die Verbindung
|
||||
steht und welche Modelle es gibt. Im Programm eintragen:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Base URL | `http://127.0.0.1:8099/v1` |
|
||||
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
|
||||
| Modell | ein Name von der Hilfsseite |
|
||||
| Kontextfenster | der Wert von der Hilfsseite |
|
||||
|
||||
- `GET /v1/models` — jedes Modell einmal, mit `context_length`
|
||||
- `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem
|
||||
Rutsch) und `tools`
|
||||
- Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell →
|
||||
404 `model_not_found`, Cluster nicht erreichbar → 503, Zeitüberschreitung → 504
|
||||
- Antwort-Header `X-Aria-Node` / `X-Aria-Gpu` sagen, wer geantwortet hat
|
||||
- **Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
|
||||
Container. Dann das Docker-Netz `aria-llm` anhängen und
|
||||
`http://openai-rvs-wrapper:8099/v1` nehmen (Details in der README des Wrappers).
|
||||
|
||||
Mit dem offiziellen OpenAI-Python-SDK getestet (`models.list`,
|
||||
`chat.completions.create` mit und ohne Stream, Fehlerklassen).
|
||||
|
||||
---
|
||||
|
||||
## C) Direkt an eine Box — Sonderfall
|
||||
|
||||
Ohne den Cluster, eine bestimmte Box gezielt. Keine Verteilung, kein
|
||||
Ausfall-Ersatz. Nur nötig, wenn ein Programm bewusst eine bestimmte Box will
|
||||
oder ARIA nicht läuft.
|
||||
|
||||
**Modelle der Boxen:** `llm_models_request` `{requestId}` → **jede** Box
|
||||
antwortet einzeln mit `llm_models` `{requestId, instanceId, node, models:
|
||||
[{model, gpu, gpuName, ctx, state}]}`. Ein paar Sekunden einsammeln.
|
||||
|
||||
**Anfrage:** `llm_request` `{requestId, targetInstance, model, messages, …}`
|
||||
→ `llm_response` `{requestId, ok, content, tool_calls, model, instanceId,
|
||||
node, gpu, usage, elapsedMs}`. Bei Fehler `errorCode`:
|
||||
`model_not_loaded` (mit `loadedModels`) oder `no_model_loaded`.
|
||||
|
||||
Ohne `targetInstance` antworten nur Boxen, die das Modell geladen haben — die
|
||||
anderen schweigen. Hat keine es, kommt **keine** Antwort. Darum hier immer
|
||||
`targetInstance` setzen.
|
||||
|
||||
**OpenAI-Port einer Box:** Jede Box hat zusätzlich einen eigenen
|
||||
OpenAI-Endpunkt (`http://<IP der Box>:8081/v1`, Standard nur `localhost` der
|
||||
Box). Er sieht nur diese Box; nicht geladene Modelle → HTTP 404. Für mehrere
|
||||
Boxen den Wrapper (B) nehmen.
|
||||
|
||||
---
|
||||
|
||||
## Was Programme nicht senden
|
||||
|
||||
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
|
||||
ARIA-Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der
|
||||
RVS unterscheidet die Teilnehmer nicht. Ein Programm, das sich nicht daran
|
||||
hält, tauscht anderen das Modell unter den Füßen weg.
|
||||
|
||||
## Gleichzeitige Nutzung und Geschwindigkeit
|
||||
|
||||
- Mehrere Anfragen an dasselbe Modell laufen auf einer Karte **parallel**.
|
||||
Jede einzelne wird etwas langsamer, in Summe schafft die Karte mehr.
|
||||
- Liegt ein Modell auf mehreren Karten, schickt der Cluster neue Anfragen an
|
||||
die Karte mit den wenigsten laufenden.
|
||||
- Der Kontext (`ctx`) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer
|
||||
Vorrat** aller gleichzeitigen Anfragen auf einer Karte. Schicken mehrere
|
||||
Programme gleichzeitig sehr lange Verläufe, wird es dort eng.
|
||||
|
||||
## Wenn sich etwas ändert
|
||||
|
||||
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte dort weg.
|
||||
Liegt es noch auf einer anderen Karte, merkt ein Programm nichts; sonst kommt
|
||||
`model_unavailable` — dann die Liste neu holen.
|
||||
- Beim Laden oder Entladen startet die betroffene Box **alle** ihre Modelle
|
||||
kurz neu (wenige Sekunden). Anfragen in diesem Moment warten oder gehen an
|
||||
eine andere Box.
|
||||
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
|
||||
dazukommen. Unbekannte Felder ignorieren.
|
||||
+25
-4
@@ -3,12 +3,33 @@
|
||||
# Kopieren nach .env und Werte eintragen.
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
# ── Wer macht TLS? ──────────────────────────────────────
|
||||
# caddy = Caddy hier im Stack holt das Zertifikat selbst (Let's Encrypt)
|
||||
# (leer) = OHNE Caddy: ein vorhandener Reverse-Proxy (nginx, Traefik ...)
|
||||
# macht TLS und zeigt auf den RVS-Port unten
|
||||
COMPOSE_PROFILES=caddy
|
||||
|
||||
# ── Modus caddy ─────────────────────────────────────────
|
||||
# Oeffentlich erreichbarer DNS-Name dieses Servers. Caddy holt darauf ein
|
||||
# Let's Encrypt-Zertifikat (HTTP-01 Challenge ueber Port 80) und routet
|
||||
# WebSocket + HTTP weiter an den RVS-Container.
|
||||
#
|
||||
# WICHTIG:
|
||||
# - Die Domain muss per DNS-A-Record/AAAA auf diese Maschine zeigen
|
||||
# - Port 80 + 443 muessen vom Internet aus erreichbar sein
|
||||
# - Kein anderer Reverse-Proxy davor (sonst Cert-Konflikt)
|
||||
# - Port 80 muss von aussen HIERHER gehen (sonst laeuft das Zertifikat
|
||||
# nach 90 Tagen still ab — Fritzbox: Freigabe auf 80 pruefen!)
|
||||
PUBLIC_URL=rvs.example.de
|
||||
# Port, auf dem Caddy HTTPS anbietet (Apps/Satelliten: dieser Port)
|
||||
CADDY_HTTPS_PORT=444
|
||||
|
||||
# ── Modus ohne Caddy ────────────────────────────────────
|
||||
# Unverschluesselter RVS-Port, auf den der Reverse-Proxy zeigt.
|
||||
# 127.0.0.1 = nur von diesem Rechner. Steht der Proxy woanders:
|
||||
# 0.0.0.0 bzw. die interne IP — und den Port per Firewall NUR fuer
|
||||
# den Proxy freigeben (Klartext!).
|
||||
RVS_HTTP_BIND=127.0.0.1
|
||||
RVS_HTTP_PORT=3000
|
||||
# Auch die Strecke Proxy → RVS verschluesseln: der RVS spricht dann HTTPS
|
||||
# auf RVS_HTTP_PORT, mit selbstsigniertem Zertifikat (wird beim ersten Start
|
||||
# in ./data/rvs-tls/ erzeugt; eigenes rvs.crt/rvs.key darf dort liegen).
|
||||
# Im Proxy dann https://<ip>:<port> als Ziel, Zertifikat NICHT pruefen.
|
||||
# Nur ohne Caddy — der Caddy im Stack spricht den RVS per HTTP an.
|
||||
RVS_HTTP_TLS=false
|
||||
|
||||
@@ -2,6 +2,9 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# openssl: erzeugt bei RVS_HTTP_TLS=true das interne Zertifikat
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# Erst package.json kopieren — Docker-Cache für npm install nutzen
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
+22
-15
@@ -1,30 +1,36 @@
|
||||
# ════════════════════════════════════════════════════════
|
||||
# ARIA RVS Stack — WebSocket Relay + OAuth Callback HTTP
|
||||
# Caddy davor terminiert TLS via Let's Encrypt (HTTP-01
|
||||
# Challenge ueber Port 80). OAuth-Provider wie Spotify
|
||||
# verlangen HTTPS fuer non-localhost Redirect-URIs.
|
||||
# Apps/Satelliten/Boxen brauchen TLS (wss://), OAuth-Provider
|
||||
# wie Spotify HTTPS. Wer TLS macht, waehlt die .env:
|
||||
#
|
||||
# A) COMPOSE_PROFILES=caddy — Caddy hier im Stack holt das
|
||||
# Zertifikat selbst (Let's Encrypt, HTTP-01 ueber Port 80).
|
||||
# Voraussetzung: Port 80 + CADDY_HTTPS_PORT von aussen auf
|
||||
# diese Maschine, PUBLIC_URL zeigt per DNS hierher.
|
||||
#
|
||||
# B) COMPOSE_PROFILES= — OHNE Caddy. Ein vorhandener
|
||||
# Reverse-Proxy (nginx, Traefik, ...) macht TLS und zeigt auf
|
||||
# den unverschluesselten RVS-Port (RVS_HTTP_BIND:RVS_HTTP_PORT).
|
||||
# nginx-Beispiel: README → RVS → "Ohne Caddy".
|
||||
# ════════════════════════════════════════════════════════
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - Port 80 + 443 frei (kein anderer Reverse-Proxy davor)
|
||||
# - Domain (PUBLIC_URL) zeigt per DNS auf diese Maschine
|
||||
# - .env mit PUBLIC_URL gesetzt
|
||||
#
|
||||
# Start: docker compose up -d
|
||||
# Wenn Du einen eigenen TLS-Terminator nutzt (z.B. nginx,
|
||||
# externer Caddy): caddy-service auskommentieren und
|
||||
# rvs-Container den ports-Block geben (3000 → public Port).
|
||||
|
||||
services:
|
||||
rvs:
|
||||
build: .
|
||||
restart: always
|
||||
# KEIN ports-Block — Caddy ist davor, RVS nur intern
|
||||
# via aria-rvs-net erreichbar. Wenn Du Caddy nicht nutzt,
|
||||
# diesen ports-Block reaktivieren: ports: ["${RVS_PORT:-443}:3000"]
|
||||
# Unverschluesselter RVS-Port fuer einen externen Reverse-Proxy (Modus B).
|
||||
# Default nur 127.0.0.1 — steht der Proxy auf einem ANDEREN Rechner,
|
||||
# RVS_HTTP_BIND=0.0.0.0 (oder die interne IP) setzen und den Port per
|
||||
# Firewall nur fuer den Proxy freigeben: hier laeuft alles im Klartext.
|
||||
ports:
|
||||
- "${RVS_HTTP_BIND:-127.0.0.1}:${RVS_HTTP_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
||||
- ./data/rvs-tls:/certs # internes Zertifikat (RVS_HTTP_TLS)
|
||||
environment:
|
||||
- RVS_HTTP_TLS=${RVS_HTTP_TLS:-false}
|
||||
- MAX_SESSIONS=10
|
||||
# 4 GB V8-Heap — sonst OOM beim Empfang von 1 GB-Files
|
||||
# (base64 inflated ~1.34 GB plus WS-Frame-Margin).
|
||||
@@ -39,10 +45,11 @@ services:
|
||||
# damit Restart nicht jedes Mal ein neues Cert holt (Rate-Limit!).
|
||||
caddy:
|
||||
image: caddy:latest
|
||||
profiles: ["caddy"] # nur mit COMPOSE_PROFILES=caddy
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "444:443"
|
||||
- "${CADDY_HTTPS_PORT:-444}:443"
|
||||
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
|
||||
volumes:
|
||||
- ./data/caddy/data:/data # Zertifikate (PERSISTENT)
|
||||
|
||||
+140
-3
@@ -2,6 +2,8 @@
|
||||
|
||||
const { WebSocketServer } = require("ws");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const { execFileSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
@@ -9,6 +11,12 @@ const path = require("path");
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
||||
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
|
||||
// HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
|
||||
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
|
||||
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
|
||||
// (der spricht den RVS per HTTP an).
|
||||
const HTTP_TLS = /^(1|true|yes|ja)$/i.test(process.env.RVS_HTTP_TLS || "");
|
||||
const TLS_DIR = process.env.RVS_TLS_DIR || "/certs";
|
||||
// Kein Polling — APK wird manuell per git pull bereitgestellt
|
||||
|
||||
// Erlaubte Nachrichtentypen — alles andere wird verworfen
|
||||
@@ -124,6 +132,22 @@ const ALLOWED_TYPES = new Set([
|
||||
// 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",
|
||||
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
|
||||
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
|
||||
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
|
||||
"llm_provision_model", "llm_provision_result", "llm_provision_progress",
|
||||
"llm_remove_model",
|
||||
// Karten: Diagnostic leert eine LLM-Karte; Clients fragen, welche Modelle
|
||||
// auf welchen Boxen/Karten geladen sind (jede Box antwortet mit llm_models).
|
||||
// Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
|
||||
"llm_unload_model", "llm_models_request", "llm_models",
|
||||
// LLM-Cluster: Clients fragen MODELLE, die ARIA-Bridge waehlt die Box
|
||||
// (Last, Ausfall-Ersatz) und antwortet. Empfohlener Weg fuer Programme.
|
||||
"llm_cluster_models_request", "llm_cluster_models",
|
||||
"llm_cluster_request", "llm_cluster_response",
|
||||
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
|
||||
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
|
||||
"client_hello", "room_clients_query", "room_clients",
|
||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||
"node_stats_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
@@ -161,7 +185,7 @@ const pendingReplies = new Map();
|
||||
const PENDING_TTL_MS = 180_000;
|
||||
|
||||
function pendingKey(replyType, requestId) {
|
||||
return `${replyType} | ||||