Compare commits
84
Commits
23feb3ba2d
...
v0.2.6.2
@@ -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,7 @@ 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)
|
||||
@@ -671,7 +672,8 @@ cd android
|
||||
### Release auf Gitea veroeffentlichen
|
||||
|
||||
```bash
|
||||
./release.sh 1.2.0
|
||||
./release.sh # Version automatisch +1 (0.2.6.1 → 0.2.6.2, 0.2.6.9 → 0.2.7.0)
|
||||
./release.sh 1.2.0 # oder Version selbst setzen
|
||||
```
|
||||
|
||||
Das Script macht alles in einem Schritt:
|
||||
@@ -705,7 +707,7 @@ die VM voll laeuft:
|
||||
|
||||
Die App prueft beim Start ob eine neuere Version auf dem RVS liegt.
|
||||
Der Update-Flow:
|
||||
1. `./release.sh 0.0.3.0` → APK wird auf RVS kopiert (via scp)
|
||||
1. `./release.sh` (Version automatisch +1) → APK wird auf RVS kopiert (via scp)
|
||||
2. Alternativ: `git pull` auf dem RVS-Server → APK in `rvs/updates/`
|
||||
3. App sendet `update_check` mit aktueller Version
|
||||
4. RVS vergleicht → sendet `update_available`
|
||||
@@ -824,23 +826,39 @@ 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.)
|
||||
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
|
||||
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
|
||||
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
|
||||
- Auto-Update: App fragt per WebSocket nach der Version, laedt die APK per HTTP
|
||||
(`/update/latest.apk`, nur mit gueltigem Raum-Token im Header `X-RVS-Token`,
|
||||
mit Fortschrittsanzeige + Fortsetzen per Range). Alte Apps: Fallback ueber WebSocket
|
||||
- Heartbeat + tote Verbindungen aufraeumen
|
||||
|
||||
**Auto-Update APK bereitstellen:**
|
||||
@@ -852,10 +870,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1160,7 +1239,7 @@ cd host-agent/android && ./build.sh # Docker (Android-SDK + Gradle)
|
||||
# -> dist/aria-android-agent.apk
|
||||
|
||||
# Release mit allen Agent-Artefakten (Linux/Windows/Android) als Gitea-Assets:
|
||||
cd host-agent && ./release_agent.sh 0.0.1.4
|
||||
cd host-agent && ./release_agent.sh # Version automatisch +1
|
||||
```
|
||||
|
||||
Der Signaturschluessel liegt fest in `host-agent/android/aria-agent.keystore`
|
||||
|
||||
+23
-3
@@ -6,15 +6,17 @@
|
||||
*/
|
||||
|
||||
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 UpdateProgress from './src/components/UpdateProgress';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
@@ -138,6 +140,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,
|
||||
@@ -183,6 +194,7 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
<UpdateProgress />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
@@ -205,10 +217,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 20602
|
||||
versionName "0.2.6.2"
|
||||
// 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.6.2",
|
||||
"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;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Fortschrittsanzeige beim App-Update: Balken mit Prozent und MB, Knopf
|
||||
* zum Abbrechen. Erscheint nur, waehrend ein Update laedt/installiert.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import updateService, { UpdateProgress as Progress } from '../services/updater';
|
||||
|
||||
const UpdateProgress: React.FC = () => {
|
||||
const [p, setP] = useState<Progress | null>(null);
|
||||
|
||||
useEffect(() => updateService.onProgress(setP), []);
|
||||
|
||||
if (!p || !p.active) return null;
|
||||
const known = p.fraction >= 0;
|
||||
const pct = known ? Math.round(p.fraction * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={() => {}}>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.box}>
|
||||
<Text style={styles.title}>Update auf Version {p.version}</Text>
|
||||
<Text style={styles.status}>{p.status}</Text>
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.bar, { width: known ? `${pct}%` : '100%' }, !known && styles.barUnknown]} />
|
||||
</View>
|
||||
<Text style={styles.numbers}>
|
||||
{known ? `${pct} % · ` : ''}
|
||||
{p.receivedMB > 0 ? `${p.receivedMB.toFixed(1)} von ${p.totalMB.toFixed(1)} MB` : `${p.totalMB.toFixed(1)} MB`}
|
||||
</Text>
|
||||
{p.status !== 'Installiere …' && (
|
||||
<TouchableOpacity style={styles.btn} onPress={() => updateService.cancelDownload()}>
|
||||
<Text style={styles.btnText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
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' },
|
||||
status: { color: '#C8C8D8', fontSize: 14, marginTop: 6 },
|
||||
track: { height: 10, borderRadius: 5, backgroundColor: '#2A2A3E', marginTop: 14, overflow: 'hidden' },
|
||||
bar: { height: 10, borderRadius: 5, backgroundColor: '#0096FF' },
|
||||
barUnknown: { opacity: 0.35 },
|
||||
numbers: { color: '#9090A8', fontSize: 13, marginTop: 8 },
|
||||
btn: { alignSelf: 'flex-end', marginTop: 14, paddingVertical: 8, paddingHorizontal: 16,
|
||||
borderRadius: 8, backgroundColor: '#2A2A3E' },
|
||||
btnText: { color: '#FFFFFF', fontWeight: '600' },
|
||||
});
|
||||
|
||||
export default UpdateProgress;
|
||||
@@ -35,6 +35,8 @@ interface VoiceButtonProps {
|
||||
onTapStart: () => Promise<boolean>;
|
||||
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
|
||||
onTapStop: () => Promise<void>;
|
||||
/** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */
|
||||
onTapCancel?: () => Promise<void>;
|
||||
/** Button deaktivieren */
|
||||
disabled?: boolean;
|
||||
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
|
||||
@@ -46,6 +48,7 @@ interface VoiceButtonProps {
|
||||
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
onTapStart,
|
||||
onTapStop,
|
||||
onTapCancel,
|
||||
disabled = false,
|
||||
wakeWordActive = false,
|
||||
}) => {
|
||||
@@ -136,6 +139,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
}
|
||||
}, [disabled, onTapStart, onTapStop]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (!onTapCancel || tapBusy.current) return;
|
||||
tapBusy.current = true;
|
||||
try {
|
||||
if (audioService.getRecordingState() === 'recording') await onTapCancel();
|
||||
} finally {
|
||||
tapBusy.current = false;
|
||||
}
|
||||
}, [onTapCancel]);
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const tenths = Math.floor((ms % 1000) / 100);
|
||||
@@ -154,6 +167,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
{wakeWordActive && !isRecording && (
|
||||
<View style={styles.wakeWordDot} />
|
||||
)}
|
||||
{isRecording && onTapCancel && (
|
||||
<TouchableOpacity
|
||||
onPress={handleCancel}
|
||||
accessibilityLabel="Aufnahme verwerfen"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
<Text style={styles.cancelIcon}>🗑</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.buttonOuter,
|
||||
@@ -197,6 +220,22 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#34C759',
|
||||
zIndex: 10,
|
||||
},
|
||||
// Muelleimer links neben dem Aufnahmeknopf — nur waehrend der Aufnahme
|
||||
cancelButton: {
|
||||
position: 'absolute',
|
||||
left: -46,
|
||||
top: 14,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(60, 60, 70, 0.9)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
cancelIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
buttonOuter: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
|
||||
@@ -2624,6 +2624,15 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Muelleimer waehrend der Aufnahme: verwerfen. cancelStreamingRecording
|
||||
// schickt stt_stream_end mit reason "cancel:*" — die STT-Bridge transkribiert
|
||||
// dann nicht, die aria-bridge wirft einen spaeten Text weg, und das leere
|
||||
// Endpoint raeumt die Sprechblase ab. Danach zurueck aufs Wake-Word.
|
||||
const handleVoiceButtonCancel = useCallback(async (): Promise<void> => {
|
||||
console.log('[Chat] Aufnahme verworfen (Muelleimer)');
|
||||
await ariaStopRecording('user-discard');
|
||||
}, [ariaStopRecording]);
|
||||
|
||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||
setShowFileUpload(false);
|
||||
@@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => {
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
onTapCancel={handleVoiceButtonCancel}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
|
||||
@@ -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 === */}
|
||||
@@ -2513,8 +2539,7 @@ const SettingsScreen: React.FC = () => {
|
||||
style={[styles.connectButton, {marginTop: 12}]}
|
||||
onPress={() => {
|
||||
const updateService = require('../services/updater').default;
|
||||
updateService.checkForUpdate();
|
||||
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
|
||||
updateService.checkForUpdateManual();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
|
||||
|
||||
@@ -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...`);
|
||||
|
||||
+209
-34
@@ -17,6 +17,26 @@ const APP_VERSION = packageJson.version || '0.0.0.0';
|
||||
|
||||
type UpdateCallback = (info: UpdateInfo) => void;
|
||||
|
||||
/** Fortschritt fuer die Anzeige (UpdateProgress-Komponente) */
|
||||
export interface UpdateProgress {
|
||||
active: boolean;
|
||||
version: string;
|
||||
/** 0..1, -1 = unbekannt */
|
||||
fraction: number;
|
||||
receivedMB: number;
|
||||
totalMB: number;
|
||||
/** z.B. "Lade herunter …", "Neuer Versuch (2/3) …", "Installiere …" */
|
||||
status: string;
|
||||
}
|
||||
|
||||
type ProgressCallback = (p: UpdateProgress) => void;
|
||||
|
||||
const HTTP_ATTEMPTS = 3;
|
||||
// Abbruch nur, wenn der Download HAENGT (so lange kommt kein Byte) — nicht
|
||||
// nach einer festen Gesamtzeit wie frueher (60 s fuer 65 MB war zu knapp).
|
||||
const READ_STALL_MS = 30_000;
|
||||
const WS_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
@@ -27,6 +47,13 @@ class UpdateService {
|
||||
private listeners: UpdateCallback[] = [];
|
||||
private checking = false;
|
||||
private downloading = false;
|
||||
private progressListeners: ProgressCallback[] = [];
|
||||
private progress: UpdateProgress = { active: false, version: '', fraction: -1, receivedMB: 0, totalMB: 0, status: '' };
|
||||
private jobId: number | null = null;
|
||||
/** Laeuft gerade eine Pruefung per Knopf? Dann geht die Antwort NUR dorthin
|
||||
* (sonst zeigt der ChatScreen-Listener denselben Dialog ein zweites Mal). */
|
||||
private manualWaiter: ((r: { type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string }) => void) | null = null;
|
||||
private cancelled = false;
|
||||
|
||||
constructor() {
|
||||
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
|
||||
@@ -42,10 +69,19 @@ class UpdateService {
|
||||
downloadUrl: (msg.payload.downloadUrl as string) || '',
|
||||
size: (msg.payload.size as number) || 0,
|
||||
};
|
||||
if (this.manualWaiter) {
|
||||
const w = this.manualWaiter; this.manualWaiter = null;
|
||||
if (info.version && this.isNewer(info.version)) w({ type: 'available', info });
|
||||
else w({ type: 'none', latest: info.version });
|
||||
return;
|
||||
}
|
||||
if (info.version && this.isNewer(info.version)) {
|
||||
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
|
||||
this.listeners.forEach(cb => cb(info));
|
||||
}
|
||||
} else if ((msg.type as string) === 'update_none' && this.manualWaiter) {
|
||||
const w = this.manualWaiter; this.manualWaiter = null;
|
||||
w({ type: 'none', latest: String(msg.payload.latest || '') });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -126,6 +162,64 @@ class UpdateService {
|
||||
setTimeout(() => { this.checking = false; }, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefung per Knopf (Einstellungen → Ueber): sagt IMMER etwas —
|
||||
* neue Version (Update-Dialog), "keine neue Version" mit der Version auf dem
|
||||
* Server, oder warum der Update-Server nicht erreichbar ist (mit Details).
|
||||
*/
|
||||
async checkForUpdateManual(): Promise<void> {
|
||||
const cfg = rvs.getConfig();
|
||||
const where = cfg ? `${cfg.useTLS ? 'wss' : 'ws'}://${cfg.host}:${cfg.port}` : '(nicht eingerichtet)';
|
||||
if (!cfg) {
|
||||
Alert.alert('Update-Server nicht erreichbar', 'Es ist noch kein Server eingerichtet (Einstellungen → Verbindung).');
|
||||
return;
|
||||
}
|
||||
if (!rvs.isSocketOpen()) {
|
||||
const lastErr = [...rvs.getConnectionLog()].reverse().find(e => e.level === 'error');
|
||||
Alert.alert('Update-Server nicht erreichbar',
|
||||
`Keine Verbindung zu ${where}.\n\nZustand: ${rvs.getState()}`
|
||||
+ (lastErr ? `\nLetzter Fehler (${new Date(lastErr.timestamp).toLocaleTimeString()}):\n${lastErr.message}` : ''));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<{ type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string } | { type: 'timeout' }>(resolve => {
|
||||
const t = setTimeout(() => { this.manualWaiter = null; resolve({ type: 'timeout' }); }, 10_000);
|
||||
this.manualWaiter = (r) => { clearTimeout(t); resolve(r); };
|
||||
rvs.send('update_check' as any, { version: APP_VERSION });
|
||||
});
|
||||
|
||||
if (result.type === 'available') {
|
||||
this.promptUpdate(result.info);
|
||||
return;
|
||||
}
|
||||
if (result.type === 'timeout') {
|
||||
Alert.alert('Update-Server antwortet nicht',
|
||||
`Verbunden mit ${where}, aber nach 10 s keine Antwort auf die Update-Anfrage.\n\n`
|
||||
+ 'Moeglicherweise laeuft auf dem Server eine alte RVS-Version.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Keine neue Version — zusaetzlich pruefen, ob der Download-Weg funktioniert
|
||||
let dl = '';
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 10_000);
|
||||
const r = await fetch(`${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`,
|
||||
{ method: 'HEAD', headers: { 'X-RVS-Token': cfg.token }, signal: ctrl.signal });
|
||||
clearTimeout(t);
|
||||
dl = r.ok ? 'Download-Weg: erreichbar ✓'
|
||||
: r.status === 404 && result.latest ? 'Download-Weg: vom Server noch nicht unterstuetzt (alter RVS)'
|
||||
: r.status === 404 ? 'Download-Weg: erreichbar, aber auf dem Server liegt keine APK'
|
||||
: `Download-Weg: Fehler HTTP ${r.status}`;
|
||||
} catch (e: any) {
|
||||
dl = `Download-Weg: nicht erreichbar (${e?.name === 'AbortError' ? 'Zeitueberschreitung' : (e?.message || e)})`;
|
||||
}
|
||||
Alert.alert('Keine neue Version',
|
||||
`Installiert: ${APP_VERSION}\n`
|
||||
+ `Neueste auf dem Server: ${result.latest || '(keine APK hinterlegt)'}\n\n`
|
||||
+ `Update-Server: ${where} ✓\n${dl}`);
|
||||
}
|
||||
|
||||
/** Callback registrieren */
|
||||
onUpdateAvailable(callback: UpdateCallback): () => void {
|
||||
this.listeners.push(callback);
|
||||
@@ -150,46 +244,125 @@ class UpdateService {
|
||||
);
|
||||
}
|
||||
|
||||
/** APK ueber WebSocket herunterladen und installieren */
|
||||
onProgress(callback: ProgressCallback): () => void {
|
||||
this.progressListeners.push(callback);
|
||||
callback(this.progress);
|
||||
return () => {
|
||||
this.progressListeners = this.progressListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
private setProgress(p: Partial<UpdateProgress>): void {
|
||||
this.progress = { ...this.progress, ...p };
|
||||
this.progressListeners.forEach(cb => cb(this.progress));
|
||||
}
|
||||
|
||||
/** Laufenden Download abbrechen (Knopf in der Fortschrittsanzeige) */
|
||||
cancelDownload(): void {
|
||||
this.cancelled = true;
|
||||
if (this.jobId !== null) {
|
||||
try { RNFS.stopDownload(this.jobId); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APK per HTTP vom RVS laden (/update/latest.apk, Token im Header).
|
||||
* Gibt false zurueck, wenn der RVS den Weg noch nicht kennt (alter RVS) —
|
||||
* dann nimmt downloadAndInstall den alten WebSocket-Weg.
|
||||
*/
|
||||
private async downloadViaHttp(info: UpdateInfo, destPath: string): Promise<boolean> {
|
||||
const cfg = rvs.getConfig();
|
||||
if (!cfg) return false;
|
||||
const url = `${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`;
|
||||
let lastErr = '';
|
||||
for (let attempt = 1; attempt <= HTTP_ATTEMPTS; attempt++) {
|
||||
if (this.cancelled) throw new Error('Abgebrochen');
|
||||
this.setProgress({ status: attempt === 1 ? 'Lade herunter …' : `Neuer Versuch (${attempt}/${HTTP_ATTEMPTS}) …`,
|
||||
fraction: -1, receivedMB: 0 });
|
||||
let expected = 0;
|
||||
try {
|
||||
const job = RNFS.downloadFile({
|
||||
fromUrl: url,
|
||||
toFile: destPath,
|
||||
headers: { 'X-RVS-Token': cfg.token },
|
||||
connectionTimeout: 15_000,
|
||||
readTimeout: READ_STALL_MS,
|
||||
progressInterval: 300,
|
||||
begin: (r) => { expected = r.contentLength > 0 ? r.contentLength : 0; },
|
||||
progress: (r) => {
|
||||
const total = r.contentLength > 0 ? r.contentLength : info.size;
|
||||
this.setProgress({
|
||||
fraction: total > 0 ? Math.min(1, r.bytesWritten / total) : -1,
|
||||
receivedMB: r.bytesWritten / 1048576,
|
||||
totalMB: total / 1048576,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.jobId = job.jobId;
|
||||
const res = await job.promise;
|
||||
this.jobId = null;
|
||||
if (res.statusCode === 403 || res.statusCode === 404) {
|
||||
// Alter RVS ohne HTTP-Download (404) bzw. Token unbekannt (403)
|
||||
console.warn(`[Update] HTTP-Download nicht verfuegbar (${res.statusCode}) — nehme WebSocket`);
|
||||
return false;
|
||||
}
|
||||
if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`);
|
||||
const st = await RNFS.stat(destPath);
|
||||
if (expected && Number(st.size) !== expected) {
|
||||
throw new Error(`Datei unvollstaendig (${st.size} von ${expected} Bytes)`);
|
||||
}
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
this.jobId = null;
|
||||
if (this.cancelled) throw new Error('Abgebrochen');
|
||||
lastErr = err?.message || String(err);
|
||||
console.warn(`[Update] HTTP-Download Versuch ${attempt} fehlgeschlagen: ${lastErr}`);
|
||||
await new Promise<void>(r => setTimeout(r, 2000 * attempt));
|
||||
}
|
||||
}
|
||||
throw new Error(`Download fehlgeschlagen: ${lastErr}`);
|
||||
}
|
||||
|
||||
/** Alter Weg: APK als ein grosser Block ueber die WebSocket-Verbindung */
|
||||
private async downloadViaWebSocket(info: UpdateInfo, destPath: string): Promise<void> {
|
||||
this.setProgress({ status: 'Lade herunter (ohne Fortschritt) …', fraction: -1 });
|
||||
rvs.send('update_download' as any, {});
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { unsub(); reject(new Error('Download-Timeout (5 min)')); }, WS_TIMEOUT_MS);
|
||||
const unsub = rvs.onMessage((msg: RVSMessage) => {
|
||||
if ((msg.type as string) === 'update_data') {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
if (msg.payload.error) reject(new Error(msg.payload.error as string));
|
||||
else resolve(msg.payload.base64 as string);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (this.cancelled) throw new Error('Abgebrochen');
|
||||
await RNFS.writeFile(destPath, base64, 'base64');
|
||||
}
|
||||
|
||||
/** APK herunterladen (HTTP mit Fortschritt, sonst WebSocket) und installieren */
|
||||
async downloadAndInstall(info: UpdateInfo): Promise<void> {
|
||||
if (this.downloading) return;
|
||||
this.downloading = true;
|
||||
this.cancelled = false;
|
||||
this.setProgress({ active: true, version: info.version, fraction: -1, receivedMB: 0,
|
||||
totalMB: info.size / 1048576, status: 'Starte …' });
|
||||
|
||||
const fileName = `ARIA-v${info.version}.apk`;
|
||||
const destPath = `${RNFS.CachesDirectoryPath}/${fileName}`;
|
||||
try {
|
||||
console.log(`[Update] Fordere APK v${info.version} an...`);
|
||||
Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`);
|
||||
|
||||
// APK ueber WebSocket anfordern
|
||||
rvs.send('update_download' as any, {});
|
||||
|
||||
// Auf update_data warten (einmalig)
|
||||
const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000);
|
||||
const unsub = rvs.onMessage((msg: RVSMessage) => {
|
||||
if ((msg.type as string) === 'update_data') {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
if (msg.payload.error) {
|
||||
reject(new Error(msg.payload.error as string));
|
||||
} else {
|
||||
resolve({
|
||||
base64: msg.payload.base64 as string,
|
||||
fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
|
||||
// Updates in einer Session gezogen werden
|
||||
console.log(`[Update] Lade APK v${info.version} …`);
|
||||
// Vor dem Download alte APKs im Cache wegraeumen
|
||||
await this.cleanupOldApks();
|
||||
|
||||
// Base64 als APK-Datei speichern
|
||||
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
||||
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
||||
const viaHttp = await this.downloadViaHttp(info, destPath);
|
||||
if (!viaHttp) await this.downloadViaWebSocket(info, destPath);
|
||||
|
||||
const fileSize = await RNFS.stat(destPath);
|
||||
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
||||
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB, ${viaHttp ? 'HTTP' : 'WebSocket'})`);
|
||||
this.setProgress({ fraction: 1, status: 'Installiere …' });
|
||||
|
||||
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
||||
if (Platform.OS === 'android') {
|
||||
@@ -199,15 +372,17 @@ class UpdateService {
|
||||
} catch (installErr: any) {
|
||||
Alert.alert(
|
||||
'APK heruntergeladen',
|
||||
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`,
|
||||
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${fileName} antippen.\n\n(${installErr.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Update] Fehler: ${err.message}`);
|
||||
Alert.alert('Update fehlgeschlagen', err.message);
|
||||
try { await RNFS.unlink(destPath); } catch (_) {}
|
||||
if (!this.cancelled) Alert.alert('Update fehlgeschlagen', err.message);
|
||||
} finally {
|
||||
this.downloading = false;
|
||||
this.setProgress({ active: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+251
-13
@@ -33,6 +33,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage
|
||||
import router as router_mod
|
||||
import metrics
|
||||
from local_llm import local_llm_chat
|
||||
import local_compact
|
||||
import skills as skills_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
@@ -47,6 +48,11 @@ BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
# Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
|
||||
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
|
||||
_NO_MODEL = {"model": "", "requested": "", "reason": ""}
|
||||
|
||||
# Lokale Modelle bewusst waehlen: pro Chat ("local:<modell>" im Modell-Override)
|
||||
# oder fuer eine Frage ("@lokal …" / "@lokal:<modell> …").
|
||||
LOCAL_MODEL_PREFIX = "local:"
|
||||
_AT_LOCAL_RE = re.compile(r"^\s*@lokal(?::([\w.\-]+))?(?=[\s:,]|$)[\s:,]*(.*)$", re.IGNORECASE | re.DOTALL)
|
||||
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
|
||||
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
|
||||
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
||||
@@ -1015,6 +1021,27 @@ META_TOOLS = [
|
||||
"category": {"type": "string", "description": "Neue Kategorie (optional)"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
|
||||
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
|
||||
"type": {"type": "string", "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
|
||||
"description": "Schublade aendern (optional), z.B. fact → tool fuer Infrastruktur. tool/rule/preference/skill/identity werden dabei angepinnt."},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "memory_delete",
|
||||
"description": (
|
||||
"Loesche einen Gedaechtnis-Eintrag, der FALSCH oder UEBERHOLT ist "
|
||||
"(ID aus memory_search oder Cold-Memory). Ist er nur unvollstaendig, "
|
||||
"lieber memory_update. identity-Eintraege kann nur Stefan loeschen."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Memory-ID (UUID)"},
|
||||
"reason": {"type": "string", "description": "Kurz warum (landet im Log)"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
@@ -1026,8 +1053,10 @@ META_TOOLS = [
|
||||
"name": "memory_save",
|
||||
"description": (
|
||||
"Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
|
||||
"Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges "
|
||||
"festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
|
||||
"Nutze das VON DIR AUS, sobald du etwas Nicht-Offensichtliches lernst "
|
||||
"(siehe 'DEIN GEDAECHTNIS' im Prompt) — nicht erst auf 'merk dir das'. "
|
||||
"VORHER memory_search: gibt es schon einen Eintrag, nimm memory_update. "
|
||||
"Keine live nachsehbaren, sich aendernden Staende speichern. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
|
||||
"File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
|
||||
"Type-Wahl:\n"
|
||||
"- identity: ARIAs Selbstbild / Wesensart (PINNED)\n"
|
||||
@@ -1071,7 +1100,7 @@ META_TOOLS = [
|
||||
},
|
||||
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
|
||||
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
|
||||
"pinned": {"type": "boolean", "description": "Weglassen = automatisch nach Schublade (identity/rule/preference/tool/skill angepinnt, fact nicht). Nur angeben, um davon abzuweichen."},
|
||||
"attach_paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
@@ -1268,8 +1297,51 @@ META_TOOLS = [
|
||||
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
|
||||
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
|
||||
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
|
||||
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
|
||||
"hinterlegten FritzBox-Login). 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
|
||||
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete MIT "
|
||||
"Verbindungsart (LAN/WLAN), lan_port (an welcher LAN-Buchse der Box) und "
|
||||
"speed_mbit; 'find' filtert nach Name/IP/MAC (braucht "
|
||||
"hinterlegten FritzBox-Login). FRITZ!BOX VOLLSTAENDIG STEUERN: "
|
||||
"'fritzbox.describe' (params={'ip':'<box oder Name>','filter':'WLAN'}) listet, "
|
||||
"was DIESE Box per TR-064 kann (je Aktion [liest]/[aendert]/[GEFAEHRLICH]); "
|
||||
"'fritzbox.call' (params={'ip':..,'service':'WLANConfiguration:3',"
|
||||
"'action':'GetInfo','args':{...}}) ruft jede davon auf; 'fritzbox.wlan' "
|
||||
"zeigt alle WLANs inkl. Gast (index, ssid, an) und schaltet sie "
|
||||
"(index + enable). Unbekannte Funktion (WPS, DECT, Anrufliste, Internet "
|
||||
"sperren …)? Erst describe mit passendem filter, dann call. REGEL: Alles "
|
||||
"was AENDERT (Set…, WPS, Neustart …) braucht confirmed=true — und das "
|
||||
"setzt Du NUR, nachdem Du Stefan gesagt hast, was genau passiert, und er "
|
||||
"ausdruecklich ja gesagt hat. Ohne confirmed kommt needs_confirmation "
|
||||
"zurueck: dann fragen, nicht raten. [GEFAEHRLICH] (Neustart, "
|
||||
"Werkseinstellung, Portfreigabe, Passwort) besonders deutlich ankuendigen. "
|
||||
"TASTE AN DER BOX: Manche Aktionen (z.B. Sicherung) muss man an der Box "
|
||||
"bestaetigen — dann kommt 'muss an der FritzBox bestaetigt werden'. Frag "
|
||||
"Stefan, ob er an der Box steht; nach seinem Ja erneut mit button_wait=true "
|
||||
"aufrufen, dann hat er 2 Minuten fuer einen Tastendruck (sonst Timeout, "
|
||||
"nichts passiert — einfach nachfragen und neu versuchen). 'fritzbox.backup' "
|
||||
"(params={'ip':..,'button_wait':true}) sichert die Einstellungen — OHNE "
|
||||
"'password': der Satellit nimmt das hinterlegte Sicherungs-Kennwort (das "
|
||||
"Du nicht kennst und nicht kennen sollst); 'password' nur, wenn Stefan "
|
||||
"ausdruecklich ein anderes will. SICHERUNGEN VERWALTEN: 'fritzbox.backups' "
|
||||
"(params={'ip':..}) listet die Sicherungen der Box (datei, erstellt, modell, "
|
||||
"firmware), neueste zuerst. 'fritzbox.backup_view' (params={'ip':..,'file':"
|
||||
"'<datei>'}) zeigt die wichtigsten Einstellungen daraus (Kennwoerter bleiben "
|
||||
"verschluesselt). 'fritzbox.restore' (params={'ip':..,'file':'<datei>',"
|
||||
"'confirmed':true,'button_wait':true}) spielt eine Sicherung ein — GEFAEHRLICH: "
|
||||
"Box uebernimmt alles und startet neu (1–3 min kein Netz); vorher Datum der "
|
||||
"Sicherung nennen und Stefans ausdrueckliches Ja holen, ebenso ob er an der "
|
||||
"Box steht. 'fritzbox.backup_delete' (params={'ip':..,'file':..,'confirmed':true}) "
|
||||
"loescht eine Sicherung (nach Ja). 'fritzbox.backup_get' (params={'ip':..,"
|
||||
"'file':..}) holt eine VORHANDENE Sicherung — sie liegt danach unter "
|
||||
"/shared/uploads/ und kommt mit [FILE: <pfad>] in den Chat. ALLE BOXEN SICHERN: 'fritzbox.backup_all' "
|
||||
"(params={} oder {'button_wait':true}) — pro Satellit einmal aufrufen (jeder "
|
||||
"Satellit sichert seine Boxen mit hinterlegtem Login); nicht erreichbare oder "
|
||||
"fehlerhafte werden uebersprungen, es geht mit der naechsten weiter — am Ende "
|
||||
"die Liste je Box (✓/✗ + Grund) an Stefan. TASTENBESTAETIGUNG: "
|
||||
"'fritzbox.second_factor' zeigt, ob sie an ist und ob sie sich per Schnittstelle "
|
||||
"schalten laesst; 'fritzbox.second_factor_set' (params={'ip':..,'enable':false,"
|
||||
"'confirmed':true,'button_wait':true}) schaltet sie — nur nach Stefans Ja; "
|
||||
"geht es nicht, den Hinweis (Weg ueber die Oberflaeche) weitergeben. die Datei liegt danach unter /shared/uploads/ "
|
||||
"und kommt mit [FILE: <pfad>] in den Chat. 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
|
||||
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
|
||||
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
|
||||
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
|
||||
@@ -2359,7 +2431,8 @@ class Agent:
|
||||
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
|
||||
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
|
||||
|
||||
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
|
||||
# Wie viele der letzten Turns ans lokale Modell gehen, regelt jetzt
|
||||
# local_compact (nach Tokens + LOCAL_MAX_TURNS, aeltere als Notizzettel).
|
||||
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
|
||||
|
||||
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
|
||||
@@ -2370,6 +2443,9 @@ class Agent:
|
||||
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
|
||||
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
|
||||
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
|
||||
# Schubladen, die angepinnt sind (immer im System-Prompt)
|
||||
_PINNED_TYPES = {"identity", "rule", "preference", "tool", "skill"}
|
||||
|
||||
_SOFT_FAIL_MARKERS = (
|
||||
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
|
||||
"konnte nicht", "keine verbindung", "nicht verfuegbar",
|
||||
@@ -2441,6 +2517,61 @@ class Agent:
|
||||
converse = flags["converse"]
|
||||
return speak, converse
|
||||
|
||||
def _local_area_route(self, user_message: str, project_id: str):
|
||||
"""(modell, frage, begruendung) wenn dieser Turn bewusst lokal laufen
|
||||
soll, sonst None. modell "" = das gerade geladene lokale Modell."""
|
||||
msg = router_mod._strip_leading_hint_blocks(user_message)
|
||||
m = _AT_LOCAL_RE.match(msg)
|
||||
if m:
|
||||
return (m.group(1) or "").strip(), (m.group(2) or "").strip() or msg, "einmalig"
|
||||
ov = context_model.get(project_id)
|
||||
if ov.startswith(LOCAL_MODEL_PREFIX):
|
||||
return ov[len(LOCAL_MODEL_PREFIX):], msg, context_model.REASON_PER_CHAT
|
||||
return None
|
||||
|
||||
def _local_area_turn(self, question: str, project_id: str, model: str) -> tuple:
|
||||
"""Ein ganzer Turn auf dem lokalen Modell — ohne Werkzeuge, ohne Claude.
|
||||
Gibt (antwort, benutztes_modell). Fehler werden NICHT als ARIAs Antwort
|
||||
in den Verlauf geschrieben (nur angezeigt)."""
|
||||
sys_prompt = "\n".join([
|
||||
IDENTITY_ANCHOR.strip(), "",
|
||||
"## LOKALES MODELL",
|
||||
"Du laeufst gerade als lokales Modell auf Stefans eigener Hardware — er hat "
|
||||
"dieses Modell fuer diese Frage bzw. diesen Chat bewusst gewaehlt. Antworte "
|
||||
"vollstaendig und sachlich auf Deutsch, als ARIA. Du hast hier KEINE "
|
||||
"Werkzeuge (kein Internet, kein Gedaechtnis, keine Aktionen): behaupte nie, "
|
||||
"etwas nachgesehen oder ausgefuehrt zu haben. Weisst du etwas nicht sicher, "
|
||||
"sag das. Normaler Text, kein <voice>, kein [FILE:], keine Tool-Aufrufe.",
|
||||
])
|
||||
notes = local_compact.summary_block(project_id)
|
||||
if notes:
|
||||
sys_prompt += "\n\n" + notes
|
||||
all_turns = self.conversation.window(project_id=project_id)
|
||||
ctx = local_compact.model_ctx(model) if model else local_compact.DEFAULT_CTX * 4
|
||||
budget = int(ctx * 0.6) - 2500 - local_compact.est_tokens(sys_prompt)
|
||||
window = local_compact.fit_window(all_turns, max(budget, 512))
|
||||
messages = [{"role": "system", "content": sys_prompt}]
|
||||
messages += [{"role": t.role, "content": t.content} for t in window]
|
||||
if question and len(messages) > 1 and messages[-1]["role"] == "user":
|
||||
messages[-1]["content"] = question # ohne "@lokal" davor
|
||||
res = local_llm_chat(messages, max_tokens=2000, temperature=0.6,
|
||||
model=model or None, timeout=600)
|
||||
used = res.get("model") or model or "lokales Modell"
|
||||
if not res.get("ok"):
|
||||
logger.warning("[lokal] %s nicht erreichbar: %s", model or "(geladenes)", res.get("error"))
|
||||
return (f"⚠ Das lokale Modell {model or ''} hat nicht geantwortet: "
|
||||
f"{res.get('error', 'unbekannt')}. Liegt es auf einer Karte? "
|
||||
"(Diagnostic → LLM-Karten)").replace(" ", " "), used
|
||||
try:
|
||||
metrics.log_local_call(used, messages, res.get("content") or "", res.get("usage"))
|
||||
except Exception:
|
||||
pass
|
||||
reply = re.sub(r"<think>[\s\S]*?</think>", "", res.get("content") or "")
|
||||
reply = re.sub(r"</?voice>", "", reply).strip() or "[Das lokale Modell lieferte keine Antwort.]"
|
||||
self.conversation.add("assistant", reply, project_id=project_id)
|
||||
logger.info("[lokal] %s beantwortet (%s)", used, project_id or "Hauptchat")
|
||||
return reply, used
|
||||
|
||||
def _try_local_fast_lane(self, user_message: str,
|
||||
active_project_id: str) -> Optional[str]:
|
||||
cfg = router_mod.load_config()
|
||||
@@ -2476,9 +2607,21 @@ class Agent:
|
||||
|
||||
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
|
||||
has_tools=bool(tools))
|
||||
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
|
||||
# wuerde das Prefill aufblaehen).
|
||||
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
|
||||
# Compacting (local_compact): aeltere Nachrichten als Notizzettel im
|
||||
# Systemprompt, dazu so viele der letzten Nachrichten, wie ins
|
||||
# Kontextfenster der Karte passen (hoechstens LOCAL_MAX_TURNS — Tempo).
|
||||
notes = local_compact.summary_block(active_project_id)
|
||||
if notes:
|
||||
sys_prompt += "\n\n" + notes
|
||||
all_turns = self.conversation.window(project_id=active_project_id)
|
||||
ctx = local_compact.model_ctx(local_model)
|
||||
budget = int(ctx * 0.75) - 500 - local_compact.est_tokens(sys_prompt)
|
||||
window = local_compact.fit_window(all_turns, max(budget, 256))
|
||||
try:
|
||||
local_compact.maybe_compact(active_project_id, all_turns, window,
|
||||
local_model, local_llm_chat)
|
||||
except Exception as exc:
|
||||
logger.debug("[compact] uebersprungen: %s", exc)
|
||||
messages = [{"role": "system", "content": sys_prompt}]
|
||||
messages += [{"role": t.role, "content": t.content} for t in window]
|
||||
|
||||
@@ -2604,6 +2747,13 @@ class Agent:
|
||||
result = self.proxy.chat_full(messages, tools=tools, model=model,
|
||||
project_id=project_id)
|
||||
if not safeguard.looks_like_block(result.content, result.is_error):
|
||||
if result.is_error:
|
||||
# Die CLI meldet selbst einen Fehler (Anmeldung abgelaufen,
|
||||
# Limit, …) — das ist KEINE Antwort von ARIA. Frueher landete
|
||||
# "Failed to authenticate: OAuth session expired" als ihre
|
||||
# Antwort im Verlauf. Jetzt: wie jede Ausnahme behandeln
|
||||
# (klar markierter [Fehler: …]-Eintrag, Stefan sieht den Fehler).
|
||||
raise RuntimeError(f"Claude meldet einen Fehler: {(result.content or '').strip()[:300]}")
|
||||
return result, model
|
||||
|
||||
code = safeguard.detail_code(result.content)
|
||||
@@ -2719,6 +2869,19 @@ class Agent:
|
||||
if active_project_id:
|
||||
projects_mod.touch_project(active_project_id)
|
||||
|
||||
# Lokales Modell bewusst gewaehlt — Claude ist fuer diesen Turn raus:
|
||||
# * "@lokal …" / "@lokal:<modell> …" nur fuer diese eine Frage
|
||||
# * Chat steht (🧠, pro Chat) auf "local:<modell>"
|
||||
# Stefan entscheidet das selbst (z.B. ein Pentest-Projekt auf einem
|
||||
# unzensierten Modell); nichts wird automatisch umgeleitet.
|
||||
area = self._local_area_route(user_message, active_project_id)
|
||||
if area is not None:
|
||||
model, question, why = area
|
||||
reply, used = self._local_area_turn(question, active_project_id, model)
|
||||
converse = not _wants_end
|
||||
return reply, "local", True, converse, False, {
|
||||
"model": used, "requested": model or "lokal", "reason": why}
|
||||
|
||||
# Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
|
||||
# /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
|
||||
# ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
|
||||
@@ -2791,6 +2954,14 @@ class Agent:
|
||||
oauth_callback_host=oauth_host,
|
||||
oauth_callback_port=oauth_port,
|
||||
oauth_callback_tls=oauth_tls)
|
||||
# Bei einer Ablehnung: Stefan kann es bewusst an ein lokales Modell geben.
|
||||
system_prompt += (
|
||||
"\n\n## LOKALE MODELLE ALS AUSWEG\n"
|
||||
"Lehnst du eine Anfrage ab (Richtlinien), sag Stefan kurz, dass er sie selbst "
|
||||
"an ein lokales Modell auf seiner eigenen Hardware geben kann: mit "
|
||||
"`@lokal <Frage>` fuer diese eine Frage, oder indem er den Chat oben (🧠) "
|
||||
"auf ein Modell unter „Lokal“ stellt. Leite NICHT selbst weiter und gib "
|
||||
"den Inhalt nicht selbst an ein Werkzeug — die Entscheidung trifft er.")
|
||||
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
|
||||
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
|
||||
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
|
||||
@@ -3178,13 +3349,22 @@ class Agent:
|
||||
if not sat or not action:
|
||||
return "FEHLER: satellite und action sind Pflicht."
|
||||
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
|
||||
# Kennwoerter im Klartext gehen nie ans Sprachmodell — die zeigt nur
|
||||
# das Diagnostic (👁) in Stefans Browser.
|
||||
if action in ("fritzbox.backup_password",):
|
||||
return ("FEHLER: Kennwoerter zeige ich nicht an — Stefan sieht es im Diagnostic "
|
||||
"(Satelliten → Geraet → 💾 → 👁 bei der Sicherung).")
|
||||
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
|
||||
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
|
||||
# obwohl die Installation noch laeuft.
|
||||
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
|
||||
# Installation koennen zusammen mehrere Minuten dauern.
|
||||
sat_timeout = (320.0 if action.startswith("adb.")
|
||||
else 200.0 if action == "ssh.exec" else 20.0)
|
||||
else 200.0 if action == "ssh.exec"
|
||||
# button_wait: bis 2 min auf die Taste an der FritzBox
|
||||
else 900.0 if action == "fritzbox.backup_all"
|
||||
else 200.0 if action.startswith("fritzbox.") and params.get("button_wait")
|
||||
else 60.0 if action.startswith("fritzbox.") else 20.0)
|
||||
result = _post("/internal/satellite", {
|
||||
"op": "command", "satellite": sat,
|
||||
"device": arguments.get("device") or "", "action": action, "params": params,
|
||||
@@ -3192,7 +3372,18 @@ class Agent:
|
||||
}, sat_timeout + 20)
|
||||
if result.get("ok"):
|
||||
r = result.get("result")
|
||||
if isinstance(r, dict) and r.get("file_b64"):
|
||||
# Datei vom Satelliten (z.B. FritzBox-Sicherung) → /shared/uploads/
|
||||
import base64
|
||||
fname = re.sub(r"[^A-Za-z0-9._-]", "_", str(r.get("filename") or "satellit_datei"))
|
||||
path = f"/shared/uploads/sat_{int(time.time())}_{fname}"
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(base64.b64decode(r.pop("file_b64")))
|
||||
r["im_chat_ablegbar"] = path
|
||||
r["hinweis_chat"] = f"In den Chat legen mit [FILE: {path}]"
|
||||
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
|
||||
if result.get("needs_button") or result.get("needs_confirmation") or result.get("timeout"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||
@@ -4391,6 +4582,15 @@ class Agent:
|
||||
existing.category = str(arguments["category"]).strip()
|
||||
if "tags" in arguments and arguments["tags"] is not None:
|
||||
existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
|
||||
if arguments.get("type"):
|
||||
new_type = str(arguments["type"]).strip()
|
||||
if new_type not in {"identity", "rule", "preference", "tool", "skill",
|
||||
"fact", "conversation", "reminder"}:
|
||||
return f"FEHLER: unbekannter type {new_type!r}."
|
||||
if new_type != existing.type:
|
||||
existing.type = new_type
|
||||
if arguments.get("pinned") is None:
|
||||
existing.pinned = new_type in self._PINNED_TYPES
|
||||
if "pinned" in arguments and arguments["pinned"] is not None:
|
||||
existing.pinned = bool(arguments["pinned"])
|
||||
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
@@ -4418,6 +4618,29 @@ class Agent:
|
||||
except Exception as e:
|
||||
logger.exception("memory_update fehlgeschlagen")
|
||||
return f"FEHLER: {e}"
|
||||
if name == "memory_delete":
|
||||
pid = (arguments.get("id") or "").strip()
|
||||
reason = (arguments.get("reason") or "").strip()
|
||||
if not pid:
|
||||
return "FEHLER: id ist Pflicht."
|
||||
existing = self.store.get(pid)
|
||||
if not existing:
|
||||
return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
|
||||
if existing.type == "identity":
|
||||
return "FEHLER: identity-Eintraege loescht nur Stefan (Diagnostic)."
|
||||
try:
|
||||
self.store.delete(pid)
|
||||
logger.info("[memory] geloescht: %r (%s) — Grund: %s", existing.title, pid[:8], reason or "-")
|
||||
self._pending_events.append({
|
||||
"type": "memory_saved", "action": "deleted",
|
||||
"memory": {"id": pid, "type": existing.type, "title": existing.title,
|
||||
"content_preview": (existing.content or "")[:140],
|
||||
"category": existing.category, "pinned": existing.pinned},
|
||||
})
|
||||
return f"OK — Memory '{existing.title}' geloescht."
|
||||
except Exception as e:
|
||||
logger.exception("memory_delete fehlgeschlagen")
|
||||
return f"FEHLER: {e}"
|
||||
if name == "memory_save":
|
||||
title = (arguments.get("title") or "").strip()
|
||||
content = (arguments.get("content") or "").strip()
|
||||
@@ -4431,7 +4654,13 @@ class Agent:
|
||||
category = (arguments.get("category") or "").strip()
|
||||
tags_in = arguments.get("tags") or []
|
||||
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
|
||||
pinned = bool(arguments.get("pinned", False))
|
||||
# Schubladen identity/rule/preference/tool/skill sind laut
|
||||
# Beschreibung ANGEPINNT — frueher nur, wenn pinned=true explizit
|
||||
# mitkam (Default false), Infrastruktur landete so im kalten Teil.
|
||||
if arguments.get("pinned") is None:
|
||||
pinned = mem_type in self._PINNED_TYPES
|
||||
else:
|
||||
pinned = bool(arguments.get("pinned"))
|
||||
attach_paths_in = arguments.get("attach_paths") or []
|
||||
attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
|
||||
try:
|
||||
@@ -4681,13 +4910,22 @@ class Agent:
|
||||
|
||||
# Konversation als Klartext bauen
|
||||
transcript = "\n".join(
|
||||
f"[{t.role.upper()}] {t.content}" for t in old_turns
|
||||
f"[{'STEFAN' if t.role == 'user' else 'ARIA'}] {t.content}" for t in old_turns
|
||||
)[:30000] # Cap auf 30k Zeichen damit der Prompt nicht explodiert
|
||||
|
||||
system = (
|
||||
"Du extrahierst aus einer Konversation zwischen Stefan und ARIA die "
|
||||
"wichtigsten dauerhaft relevanten Fakten — keine Smalltalk-Details, "
|
||||
"keine flüchtigen Zustände. Antworte AUSSCHLIESSLICH mit gültigem JSON "
|
||||
"keine flüchtigen Zustände.\n"
|
||||
"QUELLE: Fakten kommen aus dem, was STEFAN sagt, entscheidet oder "
|
||||
"bestätigt. ARIAs Nachrichten sind Berichte und können falsch sein — "
|
||||
"übernimm daraus nur, was Stefan danach bestätigt hat.\n"
|
||||
"NICHT aufnehmen: Systemzustände, die man live nachsehen kann und die "
|
||||
"sich ändern (Projekt-/Ticket-Nummern im Fluss, Ticket-Status, Zeilen-"
|
||||
"zahlen, Commit-IDs, wer gerade woran arbeitet, 'gerade nachgesehen'-"
|
||||
"Befunde). Gut sind: Entscheidungen und ihr Grund, Stefans Vorlieben, "
|
||||
"dauerhafte Regeln/Abläufe, wo etwas läuft (falls Stefan es bestätigt).\n"
|
||||
"Antworte AUSSCHLIESSLICH mit gültigem JSON "
|
||||
"im Format: {\"facts\": [{\"title\": \"kurz, max 80 Zeichen\", "
|
||||
"\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. "
|
||||
"Mindestens 0, höchstens 7 Facts. Wenn nichts wichtig genug ist: leeres Array."
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Compacting fuer ARIAs lokalen Modus (Fast-Lane / localOnly).
|
||||
|
||||
Ein lokales Modell hat ein kleines Kontextfenster (z.B. 32k auf einer
|
||||
12-GB-Karte). Frueher bekam es starr die letzten 8 Nachrichten — alles davor
|
||||
war fuer das lokale Modell einfach weg. Jetzt:
|
||||
|
||||
* Fenster nach TOKENS statt fester Anzahl: so viele der letzten Nachrichten,
|
||||
wie in ~75 % des Kontextfensters passen (Systemprompt + Antwortplatz
|
||||
abgezogen), hoechstens LOCAL_MAX_TURNS (Tempo).
|
||||
* Was aus dem Fenster faellt, fasst das lokale Modell im HINTERGRUND zu
|
||||
einer laufenden Zusammenfassung zusammen ("Notizzettel"). Die liegt pro
|
||||
Projekt in /data/local_summaries.json und geht bei jeder lokalen Anfrage
|
||||
als "Bisheriger Verlauf" in den Systemprompt.
|
||||
|
||||
Unabhaengig davon laeuft weiter das Destillat (agent.distill_old_turns):
|
||||
dauerhaft Wichtiges wandert als Fakt ins Gedaechtnis (Qdrant). Compacting =
|
||||
Notizzettel fuer DIESES Gespraech, Destillat = Karteikarten fuers Archiv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
SUMMARY_FILE = Path(os.environ.get("LOCAL_SUMMARY_FILE", "/data/local_summaries.json"))
|
||||
LOCAL_MAX_TURNS = int(os.environ.get("LOCAL_MAX_TURNS", "12"))
|
||||
DEFAULT_CTX = 8192
|
||||
CHARS_PER_TOKEN = 2.8 # Deutsch/Code: eher vorsichtig schaetzen
|
||||
COMPACT_MIN_TURNS = 4 # erst ab so vielen neu herausgefallenen Nachrichten
|
||||
SUMMARY_MAX_TOKENS = 450
|
||||
|
||||
_lock = threading.Lock()
|
||||
_running: set = set() # Projekte, fuer die gerade verdichtet wird
|
||||
_ctx_cache: dict = {"ts": 0.0, "models": {}}
|
||||
|
||||
|
||||
def est_tokens(text: str) -> int:
|
||||
return int(len(text or "") / CHARS_PER_TOKEN) + 4
|
||||
|
||||
|
||||
def model_ctx(model: str) -> int:
|
||||
"""Kontextfenster des lokalen Modells laut Cluster (60 s gecacht)."""
|
||||
now = time.time()
|
||||
if now - _ctx_cache["ts"] > 60:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{BRIDGE_URL}/internal/llm-models", timeout=3) as r:
|
||||
data = json.loads(r.read().decode("utf-8", "ignore"))
|
||||
_ctx_cache["models"] = {m.get("model"): int(m.get("ctx") or 0)
|
||||
for m in data.get("models") or []}
|
||||
_ctx_cache["ts"] = now
|
||||
except Exception as exc:
|
||||
logger.debug("[compact] Kontextfenster nicht abfragbar: %s", exc)
|
||||
_ctx_cache["ts"] = now - 50 # in 10 s nochmal versuchen
|
||||
return _ctx_cache["models"].get(model) or DEFAULT_CTX
|
||||
|
||||
|
||||
def fit_window(turns: list, budget_tokens: int) -> list:
|
||||
"""Die neuesten Nachrichten, die ins Budget passen (mind. die letzte)."""
|
||||
out, used = [], 0
|
||||
for t in reversed(turns[-LOCAL_MAX_TURNS:]):
|
||||
need = est_tokens(t.content)
|
||||
if out and used + need > budget_tokens:
|
||||
break
|
||||
out.append(t)
|
||||
used += need
|
||||
return list(reversed(out))
|
||||
|
||||
|
||||
# ── Laufende Zusammenfassung pro Projekt ─────────────────────────────
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
return json.loads(SUMMARY_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_summary(project_id: str) -> dict:
|
||||
"""{text, upto_ts} — upto_ts = Zeitstempel der juengsten verdichteten Nachricht."""
|
||||
s = _load().get(project_id or "", {})
|
||||
return {"text": s.get("text", ""), "upto_ts": s.get("upto_ts", "")}
|
||||
|
||||
|
||||
def _save_summary(project_id: str, text: str, upto_ts: str) -> None:
|
||||
with _lock:
|
||||
data = _load()
|
||||
data[project_id or ""] = {"text": text, "upto_ts": upto_ts,
|
||||
"updated": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
||||
tmp = SUMMARY_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(SUMMARY_FILE)
|
||||
|
||||
|
||||
def reset(project_id=None) -> None:
|
||||
"""Zusammenfassung(en) verwerfen (z.B. beim Zuruecksetzen des Gespraechs)."""
|
||||
with _lock:
|
||||
if project_id is None:
|
||||
try:
|
||||
SUMMARY_FILE.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return
|
||||
data = _load()
|
||||
data.pop(project_id or "", None)
|
||||
SUMMARY_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
|
||||
|
||||
def summary_block(project_id: str) -> str:
|
||||
s = get_summary(project_id).get("text", "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
return ("## BISHERIGER VERLAUF (Zusammenfassung aelterer Nachrichten)\n"
|
||||
"Das ist eine Zusammenfassung dessen, was vor den letzten Nachrichten "
|
||||
"besprochen wurde. Nutze es als Hintergrund, zitiere es nicht.\n" + s)
|
||||
|
||||
|
||||
def maybe_compact(project_id: str, all_turns: list, window: list, model: str,
|
||||
chat_fn) -> None:
|
||||
"""Startet im Hintergrund eine Verdichtung, wenn genug Nachrichten aus dem
|
||||
Fenster gefallen sind, die noch nicht im Notizzettel stehen.
|
||||
chat_fn = local_llm_chat (wird nur im Hintergrund-Thread aufgerufen)."""
|
||||
if not window:
|
||||
return
|
||||
first_kept = window[0].ts
|
||||
upto = get_summary(project_id)["upto_ts"]
|
||||
dropped = [t for t in all_turns if t.ts < first_kept and t.ts > upto]
|
||||
if len(dropped) < COMPACT_MIN_TURNS:
|
||||
return
|
||||
key = project_id or ""
|
||||
with _lock:
|
||||
if key in _running:
|
||||
return
|
||||
_running.add(key)
|
||||
threading.Thread(target=_compact, args=(key, dropped, model, chat_fn),
|
||||
daemon=True, name=f"compact-{key or 'main'}").start()
|
||||
|
||||
|
||||
def _compact(project_id: str, dropped: list, model: str, chat_fn) -> None:
|
||||
try:
|
||||
prev = get_summary(project_id)["text"]
|
||||
ctx = model_ctx(model)
|
||||
# Was in die Verdichtungs-Anfrage passt: ~60 % des Fensters
|
||||
room = int(ctx * 0.6 * CHARS_PER_TOKEN) - len(prev) - 2000
|
||||
lines, used, last_ts = [], 0, ""
|
||||
for t in dropped: # aelteste zuerst
|
||||
who = "Stefan" if t.role == "user" else "ARIA"
|
||||
line = f"{who}: {t.content.strip()}"
|
||||
if used + len(line) > room:
|
||||
line = line[: max(0, room - used)] + " …"
|
||||
lines.append(line)
|
||||
used += len(line)
|
||||
last_ts = t.ts
|
||||
if used >= room:
|
||||
break # Rest beim naechsten Durchlauf
|
||||
system = ("Du fuehrst einen laufenden Notizzettel ueber ein Gespraech zwischen "
|
||||
"Stefan und seiner Assistentin ARIA. Schreibe auf Deutsch eine knappe "
|
||||
"Zusammenfassung (hoechstens ~250 Woerter, Stichpunkte erlaubt): worum "
|
||||
"es ging, was entschieden oder erledigt wurde, offene Punkte, wichtige "
|
||||
"Namen/Zahlen. Kein Smalltalk, keine Einleitung, nur der Notizzettel.")
|
||||
user = ((f"Bisheriger Notizzettel:\n{prev}\n\n" if prev else "")
|
||||
+ "Diese Nachrichten kommen neu dazu:\n" + "\n".join(lines)
|
||||
+ "\n\nSchreibe den aktualisierten Notizzettel.")
|
||||
res = chat_fn([{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
max_tokens=SUMMARY_MAX_TOKENS, temperature=0.2, model=model, timeout=180)
|
||||
text = (res.get("content") or "").strip() if res.get("ok") else ""
|
||||
if not text:
|
||||
logger.warning("[compact] Verdichtung fehlgeschlagen (%s) — Notizzettel bleibt",
|
||||
res.get("error"))
|
||||
return
|
||||
_save_summary(project_id, text, last_ts)
|
||||
logger.info("[compact] %d Nachrichten verdichtet (Projekt %r) → %d Zeichen Notizzettel",
|
||||
len(dropped), project_id, len(text))
|
||||
except Exception as exc:
|
||||
logger.warning("[compact] Fehler: %s", exc)
|
||||
finally:
|
||||
with _lock:
|
||||
_running.discard(project_id or "")
|
||||
@@ -30,7 +30,7 @@ LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC",
|
||||
|
||||
def local_llm_chat(messages: list, *, max_tokens: int = 512,
|
||||
temperature: float = 0.7, stop=None, tools=None,
|
||||
model=None) -> dict:
|
||||
model=None, timeout=None) -> dict:
|
||||
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
|
||||
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
|
||||
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten.
|
||||
@@ -44,13 +44,15 @@ def local_llm_chat(messages: list, *, max_tokens: int = 512,
|
||||
req["tools"] = tools
|
||||
if model:
|
||||
req["model"] = model
|
||||
if timeout:
|
||||
req["timeout"] = timeout # Bridge-seitige Frist (sonst 30 s)
|
||||
try:
|
||||
body = json.dumps(req).encode("utf-8")
|
||||
http_req = urllib.request.Request(
|
||||
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(http_req, timeout=LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
|
||||
with urllib.request.urlopen(http_req, timeout=(timeout + 5) if timeout else LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8", "ignore"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
|
||||
+20
-1
@@ -1190,6 +1190,11 @@ def conversation_reset():
|
||||
"""Hardes Reset — der Rolling-Window-Verlauf wird komplett geleert.
|
||||
Destillierte facts bleiben in der DB."""
|
||||
conversation().reset()
|
||||
try:
|
||||
import local_compact
|
||||
local_compact.reset() # Notizzettel des lokalen Modus gehoert dazu
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "turns": 0}
|
||||
|
||||
|
||||
@@ -1338,11 +1343,25 @@ def _context_model_rows() -> dict:
|
||||
"reason": reason,
|
||||
"blocked": sorted(blocked),
|
||||
})
|
||||
choices = _model_choices()
|
||||
choices = _model_choices() + _local_model_choices()
|
||||
names = {c["id"]: c["name"] for c in choices}
|
||||
return {"default": default, "rows": rows, "choices": choices, "names": names}
|
||||
|
||||
|
||||
def _local_model_choices() -> list:
|
||||
"""Lokale Modelle, die gerade auf einer Karte liegen (ARIA-Cluster ueber
|
||||
die Bridge) — als "local:<modell>" waehlbar pro Chat. Fail-soft: []."""
|
||||
try:
|
||||
import httpx as _httpx
|
||||
base = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090").rstrip("/")
|
||||
data = _httpx.get(f"{base}/internal/llm-models", timeout=4).json()
|
||||
return [{"id": "local:" + m["model"], "name": "🖥 lokal: " + m["model"], "section": "local"}
|
||||
for m in (data.get("models") or []) if m.get("model")]
|
||||
except Exception as exc:
|
||||
logger.info("[context-model] lokale Modelle nicht abrufbar: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/context-model/list")
|
||||
def context_model_list():
|
||||
return _context_model_rows()
|
||||
|
||||
+71
-5
@@ -292,6 +292,70 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
MEMORY_CARE = """## DEIN GEDAECHTNIS — so pflegst du es
|
||||
|
||||
Zwischen zwei Nachrichten behaeltst du NICHTS. Was du spaeter wissen musst, steht
|
||||
nur dann wieder vor dir, wenn es im Gedaechtnis liegt — angepinnt (immer im
|
||||
Prompt), im Verzeichnis unten (Titel + Merkzeile) oder als Treffer der Suche.
|
||||
Darum pflegst du es selbst, ohne dass Stefan "merk dir das" sagen muss.
|
||||
|
||||
### Wann speichern (von dir aus)
|
||||
- Du lernst etwas, das man sonst muehsam neu herausfinden muesste: wo etwas
|
||||
laeuft, welches System Test und welches Produktion ist, wie man hinkommt, wie
|
||||
ein Ablauf funktioniert, was Stefan entschieden hat und WARUM.
|
||||
- Stefan korrigiert dich oder bestaetigt einen nicht offensichtlichen Weg.
|
||||
- Sofort, nicht "spaeter" — am Ende des Gespraechs bist du es nicht mehr.
|
||||
|
||||
### Was NICHT ins Gedaechtnis gehoert
|
||||
- Was man jederzeit live nachsehen kann und sich laufend aendert: Ticket-Staende,
|
||||
Projekt-/Ticket-Nummern im Fluss, Zeilenzahlen, "wer arbeitet gerade woran".
|
||||
Das wird NACHGESEHEN, nicht erinnert.
|
||||
- Deine eigenen Berichte oder Vermutungen, die du nicht in diesem Zug geprueft hast.
|
||||
- Was ohnehin im Code, in Git oder in der Doku steht.
|
||||
|
||||
### Welche Schublade
|
||||
- `tool` (angepinnt): Infrastruktur und Umgebungen — Hosts, Zugaenge, welche
|
||||
Instanz Test und welche Produktion ist. Das muss IMMER vor dir liegen.
|
||||
- `rule` / `preference` (angepinnt): harte Regeln, Stefans Arbeitsweise.
|
||||
- `fact`: alles andere (nur per Suche sichtbar). Der Titel ist die Merkzeile im
|
||||
Verzeichnis — schreib ihn so, dass du beim Lesen weisst, wann du nachschlagen
|
||||
musst (z.B. "Schmiede: Prod 172.0.2.46, Test aria-wohnung" statt "Software Schmiede").
|
||||
|
||||
### Pflegen statt anhaeufen
|
||||
- VOR dem Speichern mit `memory_search` pruefen, ob es schon einen Eintrag gibt.
|
||||
Gibt es einen: `memory_update` statt eines zweiten Eintrags.
|
||||
- Hat sich etwas geaendert (z.B. eine "alte" Kopie ist jetzt deine Testumgebung):
|
||||
den Eintrag SOFORT berichtigen.
|
||||
- Ist ein Eintrag falsch oder ueberholt: `memory_delete`.
|
||||
|
||||
### Erinnern ist nicht Wissen
|
||||
- Ein Eintrag gibt den Stand von damals wieder (Datum steht dabei). Bevor du
|
||||
Stefan einen Systemzustand als Tatsache nennst, pruefe ihn live.
|
||||
- Jeder Stand-Bericht nennt seine Quelle: WO und WOMIT du nachgesehen hast und
|
||||
wann ("Prod-Board 172.0.2.46, gerade eben"). Hast du in diesem Zug nicht
|
||||
nachgesehen, sag das — und schau nach, statt aus dem Verlauf weiterzuschreiben.
|
||||
- Widerspricht ein neuer Befund einem frueheren aus dem Verlauf: erst klaeren,
|
||||
WO du jeweils nachgesehen hast, bevor du einen davon fuer falsch erklaerst."""
|
||||
|
||||
|
||||
def build_memory_care_section() -> str:
|
||||
return MEMORY_CARE
|
||||
|
||||
|
||||
def _mem_date(p: MemoryPoint) -> str:
|
||||
d = (p.updated_at or p.created_at or "")[:10]
|
||||
return f" (Stand {d})" if d else ""
|
||||
|
||||
|
||||
def _mem_hook(p: MemoryPoint, n: int = 110) -> str:
|
||||
"""Erste Zeile/Satz des Inhalts als Merkzeile fuers Verzeichnis."""
|
||||
c = " ".join((p.content or "").split())
|
||||
if len(c) <= n:
|
||||
return c
|
||||
cut = c[:n]
|
||||
return cut[: cut.rfind(" ")] + " …" if " " in cut else cut + " …"
|
||||
|
||||
|
||||
def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
||||
"""Baue 'Moeglicherweise relevant'-Block aus Search-Treffern."""
|
||||
if not matches:
|
||||
@@ -299,7 +363,7 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
||||
lines = ["## Moeglicherweise relevant (aus Gedaechtnis)"]
|
||||
for p in matches:
|
||||
score = f" [score={p.score:.2f}]" if p.score is not None else ""
|
||||
lines.append(f"- **{p.title}**{score}")
|
||||
lines.append(f"- **{p.title}**{_mem_date(p)}{score}")
|
||||
lines.append(f" {p.content.strip()}")
|
||||
att_line = _attachments_line(p)
|
||||
if att_line:
|
||||
@@ -322,8 +386,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
|
||||
|
||||
lines = [
|
||||
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
|
||||
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
|
||||
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
|
||||
"Diese Eintraege hast DU gespeichert — hier Titel und Merkzeile, nicht der "
|
||||
"ganze Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
|
||||
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
|
||||
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
|
||||
"",
|
||||
@@ -332,7 +396,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
|
||||
items = grouped[cat]
|
||||
lines.append(f"### {cat}")
|
||||
for p in items:
|
||||
lines.append(f"- {p.title}")
|
||||
hook = _mem_hook(p)
|
||||
lines.append(f"- {p.title}{_mem_date(p)}" + (f" — {hook}" if hook and hook != p.title else ""))
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@@ -555,7 +620,8 @@ def build_system_prompt(
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
|
||||
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_memory_care_section(),
|
||||
"", build_time_section(),
|
||||
"", build_voice_flow_section()]
|
||||
if skills:
|
||||
parts.append("")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+78
-64
@@ -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
|
||||
@@ -3421,6 +3449,16 @@ class ARIABridge:
|
||||
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
|
||||
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
|
||||
req_id = payload.get("requestId", "") or ""
|
||||
# Verworfen (Muelleimer in der App)? Dann darf ein trotzdem noch
|
||||
# kommender Text NICHT an ARIA — auch wenn eine alte STT-Bridge ihn
|
||||
# noch transkribiert.
|
||||
if req_id and str(payload.get("reason", "")).startswith("cancel"):
|
||||
if not hasattr(self, "_stt_cancelled"):
|
||||
self._stt_cancelled = {}
|
||||
self._stt_cancelled[req_id] = time.time()
|
||||
for rid, t in list(self._stt_cancelled.items()):
|
||||
if time.time() - t > 120:
|
||||
self._stt_cancelled.pop(rid, None)
|
||||
if req_id:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -3445,6 +3483,9 @@ class ARIABridge:
|
||||
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
|
||||
payload.get("reason", ""))
|
||||
return
|
||||
if (payload.get("requestId") or "") in getattr(self, "_stt_cancelled", {}):
|
||||
logger.info("[rvs] stt_endpoint einer verworfenen Aufnahme — ignoriert")
|
||||
return
|
||||
audio_request_id = payload.get("audioRequestId", "") or ""
|
||||
voice = payload.get("voice", "") or ""
|
||||
speed_raw = payload.get("speed")
|
||||
@@ -4243,7 +4284,7 @@ class ARIABridge:
|
||||
|
||||
async def _local_llm(self, messages: list, max_tokens: int = 512,
|
||||
temperature: float = 0.7, stop=None, tools=None,
|
||||
model=None) -> dict:
|
||||
model=None, timeout: Optional[float] = None) -> dict:
|
||||
"""Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf
|
||||
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
|
||||
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
|
||||
@@ -4252,65 +4293,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
|
||||
elif model and self._llm_boxes_with_slots():
|
||||
# Boxen mit Karten-Belegung laden nichts nach und schweigen auf
|
||||
# Broadcasts fuer fremde Modelle. Ein Broadcast liefe also nur in
|
||||
# den Timeout — lieber sofort ehrlich absagen.
|
||||
self._pending_llm.pop(request_id, None)
|
||||
loaded = sorted({m for w in self._llm_boxes_with_slots() for m in (w.get("models") or [])})
|
||||
return {"ok": False, "errorCode": "model_not_loaded",
|
||||
"error": (f"Lokales Modell {model!r} ist auf keiner KI-Box geladen. "
|
||||
f"Geladen: {', '.join(loaded) or 'nichts'}. "
|
||||
"Laden: Diagnostic → Modell-Katalog.")}
|
||||
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=timeout or 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],
|
||||
@@ -4782,6 +4786,10 @@ class ARIABridge:
|
||||
elif method == "POST" and path == "/internal/satellite-list":
|
||||
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
|
||||
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
|
||||
elif method in ("GET", "POST") and path == "/internal/llm-models":
|
||||
# Brain fragt: welche lokalen Modelle sind geladen + ihr
|
||||
# Kontextfenster (fuer ARIAs Compacting im lokalen Modus).
|
||||
await _send_response(writer, 200, {"ok": True, "models": self._llm_cluster.models()})
|
||||
elif method in ("GET", "POST") and path == "/internal/worker-list":
|
||||
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
|
||||
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
|
||||
@@ -4943,10 +4951,16 @@ class ARIABridge:
|
||||
temperature = 0.7
|
||||
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
|
||||
_model = data.get("model") if isinstance(data.get("model"), str) else None
|
||||
# Hintergrund-Arbeit (Compacting) darf laenger dauern als die
|
||||
# 30 s fuer schnelle Antworten — gedeckelt auf 5 min.
|
||||
try:
|
||||
_timeout = min(300.0, float(data["timeout"])) if data.get("timeout") else None
|
||||
except (TypeError, ValueError):
|
||||
_timeout = None
|
||||
result = await self._local_llm(
|
||||
messages=messages, max_tokens=max_tokens,
|
||||
temperature=temperature, stop=data.get("stop"),
|
||||
tools=_tools, model=_model,
|
||||
tools=_tools, model=_model, timeout=_timeout,
|
||||
)
|
||||
status = 200 if result.get("ok") else 502
|
||||
await _send_response(writer, status, result)
|
||||
|
||||
@@ -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)})
|
||||
+1656
-197
File diff suppressed because it is too large
Load Diff
+571
-22
@@ -473,28 +473,445 @@ 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.
|
||||
// "Von HuggingFace aktualisieren": mehrere Blickwinkel statt nur der
|
||||
// meistgeladenen aller Zeiten (da gewinnen alte Modelle). Neu dazu:
|
||||
// was gerade im Trend ist, Coder-Modelle und unzensierte Fassungen.
|
||||
// Nur was eine Box laden kann (GGUF) und hoechstens LLM_CATALOG_MAX_GB.
|
||||
const LLM_CATALOG_MAX_GB = Number(process.env.LLM_CATALOG_MAX_GB || 30);
|
||||
const NON_LLM_RE = /image|audio|tts|speech|music|yue|whisper|diffusion|flux|sdxl|wan2|video|embed|rerank|ocr/i;
|
||||
const HF_REFRESH_QUERIES = [
|
||||
["beliebt", "search=GGUF&sort=downloads&direction=-1&limit=30"],
|
||||
["im Trend", "filter=gguf&sort=trendingScore&direction=-1&limit=30"],
|
||||
["Code", "search=coder&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
["unzensiert", "search=uncensored&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
["unzensiert", "search=heretic&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
["unzensiert", "search=abliterated&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
];
|
||||
async function refreshLlmCatalogFromHF() {
|
||||
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
|
||||
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) throw new Error(`HF API ${r.status}`);
|
||||
const list = await r.json();
|
||||
const existing = loadLlmCatalog();
|
||||
const byId = new Map(existing.map(m => [m.id, m]));
|
||||
let added = 0;
|
||||
for (const m of (Array.isArray(list) ? list : [])) {
|
||||
const repo = m.id || m.modelId;
|
||||
if (!repo || !/gguf/i.test(repo)) continue;
|
||||
const fresh = new Map(); // repo → {cat, downloads}
|
||||
for (const [cat, q] of HF_REFRESH_QUERIES) {
|
||||
const list = await hfJson(`https://huggingface.co/api/models?${q}`);
|
||||
for (const m of Array.isArray(list) ? list : []) {
|
||||
const repo = m.id || m.modelId;
|
||||
if (!repo || fresh.has(repo)) continue;
|
||||
// Nur Sprachmodelle: Bild-, Audio-, Musik- und Sprach-Modelle kann die
|
||||
// LLM-Box nicht als Chat-Modell laden.
|
||||
const pt = String(m.pipeline_tag || "");
|
||||
if (pt && !["text-generation", "image-text-to-text", "conversational"].includes(pt)) continue;
|
||||
if (NON_LLM_RE.test(repo)) continue;
|
||||
fresh.set(repo, { cat, downloads: m.downloads || 0 });
|
||||
}
|
||||
}
|
||||
if (!fresh.size) throw new Error("HuggingFace hat nichts geliefert (erreichbar?)");
|
||||
const known = new Set(existing.map(m => m.hfRepo));
|
||||
for (const [repo, f] of fresh) {
|
||||
if (known.has(repo)) continue;
|
||||
const id = slugModelId(repo);
|
||||
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
||||
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++;
|
||||
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
||||
byId.set(id, { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
|
||||
description: `HuggingFace · ${f.cat} · ${(f.downloads).toLocaleString("de")} Downloads`, source: "hf",
|
||||
uncensored: UNCENSORED_RE.test(repo), added_at: new Date().toISOString().slice(0, 10), _new: true });
|
||||
}
|
||||
// Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
|
||||
// gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
|
||||
const toCheck = [...byId.values()].filter(e => e.source === "hf");
|
||||
for (let i = 0; i < toCheck.length; i += 6) {
|
||||
await Promise.all(toCheck.slice(i, i + 6).map(async e => {
|
||||
const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
|
||||
if (!q || (e._new && q.sizeGB > LLM_CATALOG_MAX_GB)) {
|
||||
// Nicht ladbar oder fuer unsere Karten viel zu gross: neue nicht
|
||||
// aufnehmen, alte markieren.
|
||||
if (e._new) byId.delete(e.id); else if (!q) e.unusable = true;
|
||||
return;
|
||||
}
|
||||
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
|
||||
}));
|
||||
}
|
||||
// Werkzeug-Faehigkeit + Sicherheits-Scan nachtragen (wo noch keine Angabe),
|
||||
// Beliebtheit bei jedem Abgleich frisch (aendert sich taeglich)
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const toEnrich = [...byId.values()].filter(e => e.hfRepo && (e.tools === undefined || e.safety === undefined
|
||||
|| !e.popularity || e.popularity.at !== today));
|
||||
for (let i = 0; i < toEnrich.length; i += 6) {
|
||||
await Promise.all(toEnrich.slice(i, i + 6).map(async e => {
|
||||
if (e.tools === undefined) e.tools = await hfToolSupport(e.hfRepo);
|
||||
if (e.safety === undefined) e.safety = await hfRepoSafety(e.hfRepo);
|
||||
if (!e.popularity || e.popularity.at !== today) e.popularity = await hfPopularity(e.hfRepo) || e.popularity;
|
||||
}));
|
||||
}
|
||||
let added = 0;
|
||||
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
|
||||
// Aufraeumen: von HF uebernommen, vor > LLM_CATALOG_KEEP_DAYS Tagen, auf
|
||||
// keiner Box (Platte/Karte), heute nicht mehr gelistet → raus. Kuratierte
|
||||
// und von Hand hinzugefuegte bleiben immer.
|
||||
const keepDays = Number(process.env.LLM_CATALOG_KEEP_DAYS || 30);
|
||||
const onBox = new Set();
|
||||
for (const w of workers.values()) {
|
||||
for (const d of ((w.disk && w.disk.models) || [])) onBox.add(d.repo);
|
||||
for (const sl of (w.slots || [])) if (sl.model) onBox.add(sl.model);
|
||||
}
|
||||
let removed = 0;
|
||||
for (const [id, e] of [...byId]) {
|
||||
const age = e.added_at ? (Date.now() - new Date(e.added_at).getTime()) / 864e5 : 0;
|
||||
if (e.source === "hf" && !/Hand/.test(e.description || "") && age > keepDays
|
||||
&& !fresh.has(e.hfRepo) && !onBox.has(e.hfRepo) && !onBox.has(e.id)) {
|
||||
byId.delete(id); removed++;
|
||||
}
|
||||
}
|
||||
const merged = Array.from(byId.values());
|
||||
saveLlmCatalog(merged);
|
||||
return { models: merged, added };
|
||||
return { models: merged, added, removed };
|
||||
}
|
||||
// Einmal am Tag von selbst (LLM_CATALOG_REFRESH_HOURS, 0 = aus)
|
||||
const LLM_CATALOG_REFRESH_HOURS = Number(process.env.LLM_CATALOG_REFRESH_HOURS ?? 24);
|
||||
if (LLM_CATALOG_REFRESH_HOURS > 0) {
|
||||
setInterval(() => {
|
||||
refreshLlmCatalogFromHF()
|
||||
.then(r => log("info", "llm", `LLM-Katalog automatisch aktualisiert: +${r.added}, -${r.removed}`))
|
||||
.catch(err => log("warn", "llm", `LLM-Katalog automatisch: ${err.message}`));
|
||||
}, LLM_CATALOG_REFRESH_HOURS * 3600 * 1000);
|
||||
}
|
||||
|
||||
// ── Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort ─────────────────
|
||||
const ARIA_TOOL_LOG_DIR = "/shared/logs/aria-tools";
|
||||
function ariaProof(toTs, project) {
|
||||
// Beginn = Stefans letzte Nachricht davor im selben Kontext (sonst 30 min)
|
||||
let fromTs = toTs - 30 * 60 * 1000;
|
||||
try {
|
||||
const lines = fs.readFileSync("/shared/config/chat_backup.jsonl", "utf-8").split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (!lines[i]) continue;
|
||||
let e; try { e = JSON.parse(lines[i]); } catch { continue; }
|
||||
if (!e || !(e.ts < toTs) || e.role !== "user") continue;
|
||||
if ((e.project_id || "") !== project) continue;
|
||||
fromTs = Math.max(fromTs, e.ts - 2000);
|
||||
break;
|
||||
}
|
||||
} catch (_) { }
|
||||
const days = new Set([new Date(fromTs).toISOString().slice(0, 10), new Date(toTs).toISOString().slice(0, 10)]);
|
||||
const reqs = new Map();
|
||||
for (const d of days) {
|
||||
let txt = "";
|
||||
try { txt = fs.readFileSync(`${ARIA_TOOL_LOG_DIR}/${d}.jsonl`, "utf-8"); } catch { continue; }
|
||||
for (const line of txt.split("\n")) {
|
||||
if (!line) continue;
|
||||
let ev; try { ev = JSON.parse(line); } catch { continue; }
|
||||
if (!ev.requestId) continue;
|
||||
const r = reqs.get(ev.requestId) || { requestId: ev.requestId, project: null, start: 0, end: 0, tools: [], byId: {} };
|
||||
if (ev.kind === "start") { r.start = ev.ts; r.project = ev.projectId || ""; r.model = ev.model || ""; }
|
||||
else if (ev.kind === "end") r.end = ev.ts;
|
||||
else if (ev.kind === "tool_use") {
|
||||
const t = { id: ev.id, name: ev.name, input: ev.input, inputTruncatedBytes: ev.inputTruncatedBytes || 0, ts: ev.ts };
|
||||
r.tools.push(t); if (ev.id) r.byId[ev.id] = t;
|
||||
} else if (ev.kind === "tool_result") {
|
||||
const t = ev.id && r.byId[ev.id];
|
||||
if (t) { t.result = ev.content; t.isError = !!ev.isError; t.resultTruncatedBytes = ev.truncatedBytes || 0; t.doneTs = ev.ts; }
|
||||
}
|
||||
reqs.set(ev.requestId, r);
|
||||
}
|
||||
}
|
||||
const hit = [...reqs.values()]
|
||||
.filter(r => r.start && r.project === project && r.start >= fromTs && r.start <= toTs + 5000)
|
||||
.sort((a, b) => a.start - b.start)
|
||||
.map(({ byId, ...r }) => r);
|
||||
const logSince = (() => { try { return fs.readdirSync(ARIA_TOOL_LOG_DIR).filter(f => /\.jsonl$/.test(f)).sort()[0]?.slice(0, 10) || ""; } catch { return ""; } })();
|
||||
return { from: fromTs, to: toTs, requests: hit, toolCount: hit.reduce((n, r) => n + r.tools.length, 0), logSince };
|
||||
}
|
||||
|
||||
// ── Gerade im Trend: ladbare GGUF-Modelle von HuggingFace ───────────
|
||||
// Fuer das Fenster "Bestenlisten" im Modell-Katalog. "Trend" ist nicht
|
||||
// "das Beste" — viele Community-Mischungen. Darum standardmaessig nur
|
||||
// bekannte Anbieter. Ergebnis 30 min gecacht (HF nicht dauernd fragen).
|
||||
const TRUSTED_GGUF_AUTHORS = new Set(["unsloth", "bartowski", "lmstudio-community", "ggml-org", "qwen",
|
||||
"google", "mistralai", "microsoft", "meta-llama", "deepseek-ai", "ibm-granite", "nvidia", "openai",
|
||||
"allenai", "huggingfacetb", "thudm", "zai-org", "moonshotai", "minimaxai", "tencent", "baidu"]);
|
||||
const DISCOVER_CATS = {
|
||||
chat: { label: "Allgemein / Chat", q: "pipeline_tag=text-generation" },
|
||||
code: { label: "Programmieren", q: "search=coder" },
|
||||
vision: { label: "Bilder verstehen", q: "pipeline_tag=image-text-to-text" },
|
||||
};
|
||||
const _discoverCache = new Map(); // key → {ts, items}
|
||||
async function discoverGguf(cat, trusted) {
|
||||
const c = DISCOVER_CATS[cat] || DISCOVER_CATS.chat;
|
||||
const key = `${cat}|${trusted ? 1 : 0}`;
|
||||
const hit = _discoverCache.get(key);
|
||||
if (hit && Date.now() - hit.ts < 30 * 60 * 1000) return hit.items;
|
||||
const r = await fetch(`https://huggingface.co/api/models?library=gguf&sort=trendingScore&direction=-1&limit=100&${c.q}`,
|
||||
{ headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) throw new Error(`HF API ${r.status}`);
|
||||
let list = (await r.json()).filter(m => m && m.id);
|
||||
if (trusted) list = list.filter(m => TRUSTED_GGUF_AUTHORS.has(String(m.id).split("/")[0].toLowerCase()));
|
||||
list = list.slice(0, 18);
|
||||
const items = [];
|
||||
for (let i = 0; i < list.length; i += 6) {
|
||||
await Promise.all(list.slice(i, i + 6).map(async m => {
|
||||
const q = await hfRepoQuant(m.id, "Q4_K_M");
|
||||
if (!q) return; // kein ladbares GGUF im Repo
|
||||
items.push({ repo: m.id, author: String(m.id).split("/")[0], downloads: m.downloads || 0,
|
||||
likes: m.likes || 0, trending: m.trendingScore || 0,
|
||||
lastModified: m.lastModified || m.createdAt || "", quant: q.quant, sizeGB: q.sizeGB });
|
||||
}));
|
||||
}
|
||||
items.sort((a, b) => b.trending - a.trending);
|
||||
_discoverCache.set(key, { ts: Date.now(), items });
|
||||
return items;
|
||||
}
|
||||
|
||||
// Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden")
|
||||
// Link oder "org/name" → "org/name" (huggingface.co/org/name/tree/main … geht auch)
|
||||
function hfRepoFromInput(input) {
|
||||
const t = String(input || "").trim().replace(/^https?:\/\/(www\.)?huggingface\.co\//i, "").replace(/[?#].*$/, "");
|
||||
const m = t.match(/^([\w.-]+)\/([\w.-]+)/);
|
||||
return m ? `${m[1]}/${m[2]}` : "";
|
||||
}
|
||||
// Repo ohne GGUF (z.B. nur safetensors): GGUF-Fassungen desselben Modells
|
||||
// auf HuggingFace suchen — meist von mradermacher, bartowski, unsloth & Co.
|
||||
async function hfGgufAlternatives(repo) {
|
||||
const name = String(repo).split("/")[1] || "";
|
||||
if (!name) return [];
|
||||
try {
|
||||
const r = await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(name)}&filter=gguf&sort=downloads&direction=-1&limit=12`,
|
||||
{ headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) return [];
|
||||
return (await r.json()).filter(m => m.id && m.id !== repo)
|
||||
.map(m => ({ repo: m.id, downloads: m.downloads || 0, likes: m.likes || 0 })).slice(0, 8);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── ℹ im Modell-Katalog: was kann das Modell, ist es zensiert? ──────
|
||||
// HuggingFace liefert Grundmodell, Lizenz und Schlagworte, aber keine
|
||||
// verstaendliche Beschreibung. Darum: kurze deutsche Texte je Modellfamilie
|
||||
// (erkannt am Namen) + die Fakten von HF + Anfang der Modellkarte.
|
||||
const MODEL_FAMILIES = [
|
||||
["qwen3-coder", "Qwen3-Coder (Alibaba)", "Aufs Programmieren spezialisiert: Code schreiben, verstehen, umbauen, mit Werkzeugen arbeiten (Agenten). Die 30B-A3B-Fassung ist ein MoE-Modell: pro Wort rechnen nur ~3 Mrd. Parameter — schnell für seine Größe."],
|
||||
["foundation-sec", "Foundation-Sec (Cisco Foundation AI)", "Auf IT-Sicherheit trainiert: Schwachstellen, Angriffstechniken (MITRE ATT&CK), Logs und Warnmeldungen auswerten. Die reine „Foundation-Sec-8B“ ist ein Basismodell (setzt nur Text fort) — zum Chatten die Instruct- oder Reasoning-Fassung nehmen."],
|
||||
["vulnllm", "VulnLLM-R", "Sucht Sicherheitslücken in Quellcode und begründet Schritt für Schritt (Reasoning), warum eine Stelle verwundbar ist."],
|
||||
["dolphin", "Dolphin (Cognitive Computations)", "Bewusst ohne Ablehnungen trainiert: macht, was man verlangt, und lässt sich stark über den Systemprompt steuern. Gut für Aufgaben, die große Anbieter verweigern — man ist aber selbst verantwortlich, was man damit tut."],
|
||||
["hermes", "Hermes (Nous Research)", "Sehr gutes Befolgen von Anweisungen, Rollenspiel und Werkzeug-Aufrufe (Function Calling), wenige Ablehnungen. Hermes 4 kann zusätzlich „denken“ (Reasoning)."],
|
||||
["lexi", "Lexi (Orenguteng)", "Unzensierter Llama-Ableger für allgemeinen Chat, beantwortet auch Fragen, die das Original ablehnt."],
|
||||
["gemma", "Gemma (Google)", "Allround-Modell mit gutem Deutsch; neuere Fassungen verstehen auch Bilder. Eher vorsichtig, lehnt heikle Themen ab."],
|
||||
["qwen", "Qwen (Alibaba)", "Starke Allround-Familie: Wissen, Logik, Mathe, Code, viele Sprachen inkl. Deutsch; neuere Fassungen mit Denkmodus. Standard-Fassungen haben Schutzfilter."],
|
||||
["llama", "Llama (Meta)", "Bewährtes Allround-Modell und Basis für sehr viele Ableger; ordentliches Deutsch, solide Werkzeug-Aufrufe."],
|
||||
["mistral", "Mistral (Mistral AI, Frankreich)", "Gut in europäischen Sprachen, effizient; Mistral Small 24B ist für seine Größe sehr stark."],
|
||||
["deepseek", "DeepSeek", "Stark in Logik, Mathe und Code, oft mit ausführlichem Denkweg (Reasoning)."],
|
||||
["phi", "Phi (Microsoft)", "Klein und schnell, erstaunlich gut in Logik für die Größe; eher Englisch."],
|
||||
];
|
||||
const UNCENSORED_RE = /uncensor|abliterat|heretic|obliterat|dolphin|lexi|unfiltered|nsfw|no.?refus/i;
|
||||
async function hfJson(url) {
|
||||
try {
|
||||
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
return r.ok ? await r.json() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function hfReadmeIntro(repo) {
|
||||
try {
|
||||
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) return "";
|
||||
let t = await r.text();
|
||||
t = t.replace(/^---[\s\S]*?\n---\n/, ""); // YAML-Kopf
|
||||
const paras = t.split(/\n\s*\n/).map(p => p.trim())
|
||||
.filter(p => p && !/^(#|!\[|<|\||```|\[!\[|>|-{3,})/.test(p) && p.replace(/[^A-Za-z]/g, "").length > 60)
|
||||
// Absaetze, die fast nur Links sind (Website:, Twitter: …), ueberspringen
|
||||
.filter(p => p.replace(/https?:\/\/\S+/g, "").replace(/[^A-Za-z]/g, "").length > 80);
|
||||
return paras.slice(0, 2).join("\n\n").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").slice(0, 900);
|
||||
} catch { return ""; }
|
||||
}
|
||||
// Ganze Modellkarte als Text (ohne YAML/Bilder/HTML), fuer die Zusammenfassung
|
||||
async function hfReadmeText(repo, max = 7000) {
|
||||
try {
|
||||
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) return "";
|
||||
return (await r.text()).replace(/^---[\s\S]*?\n---\n/, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").slice(0, max);
|
||||
} catch { return ""; }
|
||||
}
|
||||
// Deutsche Kurzbeschreibung vom LOKALEN Modell aus der Modellkarte — einmal
|
||||
// erzeugt, dann im Katalog gespeichert (summary_de). So hat jedes neue Modell
|
||||
// eine, ohne dass jemand Texte pflegt.
|
||||
async function llmModelSummary(repo) {
|
||||
repo = hfRepoFromInput(repo);
|
||||
const cat = loadLlmCatalog();
|
||||
const entry = cat.find(m => m.hfRepo === repo);
|
||||
if (entry && entry.summary_de) return { summary: entry.summary_de, by: entry.summary_by || "", cached: true };
|
||||
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
|
||||
let base = (meta.cardData || {}).base_model;
|
||||
base = Array.isArray(base) ? base[0] : base || "";
|
||||
const card = [await hfReadmeText(base) , await hfReadmeText(repo, 3000)].filter(Boolean).join("\n\n---\n\n");
|
||||
if (!card.trim()) return { summary: "", error: "Keine Modellkarte auf HuggingFace." };
|
||||
const tags = (meta.tags || []).filter(t => !/^(base_model|license|region|endpoints)/.test(t)).slice(0, 25).join(", ");
|
||||
const res = await rvsRequest("llm_request", {
|
||||
requestId: "modelinfo_" + Date.now(),
|
||||
messages: [
|
||||
{ role: "system", content: "Du fasst Modellkarten von KI-Sprachmodellen fuer einen Laien auf Deutsch zusammen. "
|
||||
+ "Nur was in der Karte steht, nichts erfinden. 3 bis 5 kurze Saetze: wofuer das Modell gedacht ist, worin es "
|
||||
+ "besonders gut ist, ob es Ablehnungen/Schutzfilter entfernt hat (uncensored/abliterated), und Besonderheiten "
|
||||
+ "(Denkmodus, Werkzeuge, Sprachen, Groesse). Keine Ueberschrift, kein Markdown." },
|
||||
{ role: "user", content: `Modell: ${repo}\nGrundmodell: ${base || "-"}\nSchlagworte: ${tags}\n\nModellkarte:\n${card}` },
|
||||
],
|
||||
max_tokens: 400, temperature: 0.2, model: "", targetInstance: "",
|
||||
}, "llm_response", 180000);
|
||||
const text = String(res.content || res.text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
||||
if (!res.ok || !text) return { summary: "", error: res.error || "Das lokale Modell hat nicht geantwortet." };
|
||||
const by = res.model || "lokales Modell";
|
||||
if (entry) {
|
||||
entry.summary_de = text; entry.summary_by = by; entry.summary_at = new Date().toISOString();
|
||||
saveLlmCatalog(cat);
|
||||
}
|
||||
return { summary: text, by, cached: false };
|
||||
}
|
||||
|
||||
async function llmModelInfo(repo) {
|
||||
repo = hfRepoFromInput(repo);
|
||||
if (!repo) throw new Error("ungueltiges Repo");
|
||||
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
|
||||
const card = meta.cardData || {};
|
||||
let base = card.base_model;
|
||||
base = Array.isArray(base) ? base[0] : base || "";
|
||||
const baseMeta = base ? (await hfJson(`https://huggingface.co/api/models/${base}`) || {}) : {};
|
||||
const tags = [...new Set([...(meta.tags || []), ...((card.tags) || [])])]
|
||||
.filter(t => !/^(base_model|license|region|endpoints|autotrain|text-generation-inference|safetensors|transformers|gguf|conversational|imatrix)/.test(t));
|
||||
const hay = [repo, base, ...tags].join(" ").toLowerCase();
|
||||
const fams = [];
|
||||
for (const [key, name, text] of MODEL_FAMILIES) {
|
||||
// Ganzes Wort am Anfang ("phi" nicht in "dolphin" finden)
|
||||
const re = new RegExp("(^|[^a-z0-9])" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
if (name && re.test(hay) && !fams.some(f => f.name === name)) fams.push({ name, text });
|
||||
}
|
||||
// Allgemeine Familien (Qwen/Llama …) nur, wenn keine speziellere passt
|
||||
const special = fams.filter(f => !/^(Qwen|Llama|Mistral|Gemma) \(/.test(f.name));
|
||||
const families = special.length ? [...special, ...fams.filter(f => !special.includes(f)).slice(0, 1)] : fams.slice(0, 1);
|
||||
const unc = UNCENSORED_RE.exec(hay);
|
||||
return {
|
||||
repo, base, author: meta.author || repo.split("/")[0], license: card.license || "",
|
||||
downloads: meta.downloads || 0, likes: meta.likes || 0,
|
||||
created: meta.createdAt || "", updated: meta.lastModified || "",
|
||||
tags: tags.slice(0, 20), families,
|
||||
uncensored: !!unc,
|
||||
tools: await hfToolSupport(repo),
|
||||
safety: await hfRepoSafety(repo),
|
||||
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
|
||||
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
|
||||
introFrom: base || repo,
|
||||
};
|
||||
}
|
||||
|
||||
// Kann das Modell Werkzeuge? Kennzeichen: die Chat-Vorlage im GGUF (HF liefert
|
||||
// sie unter expand[]=gguf) kennt ein Werkzeug-Format, oder Schlagworte wie
|
||||
// function-calling/tool-use. Zeigt nur, OB das Format da ist, nicht wie gut.
|
||||
const TOOLS_TEMPLATE_RE = /\btools\b|tool_call|<tool|function_call/;
|
||||
const TOOLS_TAG_RE = /function.?call|tool.?use|tool.?calling|agent/i;
|
||||
async function hfToolSupport(repo) {
|
||||
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=gguf&expand[]=tags`);
|
||||
if (!d) return null;
|
||||
const tpl = String(((d.gguf || {}).chat_template) || "");
|
||||
if (TOOLS_TEMPLATE_RE.test(tpl)) return true;
|
||||
if ((d.tags || []).some(t => TOOLS_TAG_RE.test(t))) return true;
|
||||
return tpl ? false : null; // keine Vorlage = unbekannt
|
||||
}
|
||||
|
||||
// ── Sicherheit eines Repos ─────────────────────────────────────────
|
||||
// Die Box laedt NUR die GGUF-Datei (llama.cpp -hf / paralleler Download per
|
||||
// sha256) — Skripte, Pickle o.ae. daneben werden nie geholt oder ausgefuehrt.
|
||||
// Liegen sie trotzdem im Repo, ist das ein Warnsignal fuer den Anbieter.
|
||||
// HuggingFace scannt jede Datei (securityFileStatus) — das Ergebnis zeigen wir.
|
||||
const EXTRA_KNOWN_GGUF_AUTHORS = new Set(["mradermacher", "thebloke", "maziyarpanahi", "nousresearch",
|
||||
"dphn", "cognitivecomputations", "fdtn-ai", "quantfactory", "lmstudio-ai"]);
|
||||
function knownGgufAuthor(a) { // Funktion: TRUSTED_GGUF_AUTHORS steht weiter unten in der Datei
|
||||
return TRUSTED_GGUF_AUTHORS.has(a) || EXTRA_KNOWN_GGUF_AUTHORS.has(a);
|
||||
}
|
||||
const RISKY_FILE_RE = /\.(py|sh|bash|bat|cmd|ps1|exe|dll|so|dylib|pkl|pickle|pt|pth|ckpt|joblib|npy|zip|tar|gz|7z|jar|js|vbs)$|(^|\/)pytorch_model[^/]*\.bin$/i;
|
||||
// Beliebtheit: HF hat keine Sterne — nur Downloads (30 Tage / gesamt) und Likes.
|
||||
async function hfPopularity(repo) {
|
||||
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=downloads&expand[]=likes&expand[]=downloadsAllTime`);
|
||||
if (!d) return null;
|
||||
return { downloads: d.downloads || 0, downloadsAll: d.downloadsAllTime || 0, likes: d.likes || 0,
|
||||
at: new Date().toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
async function hfRepoSafety(repo) {
|
||||
const author = String(repo).split("/")[0].toLowerCase();
|
||||
const out = { known: knownGgufAuthor(author), flagged: [], risky: [], scanned: 0 };
|
||||
const tree = await hfJson(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1&expand=true`);
|
||||
for (const f of Array.isArray(tree) ? tree : []) {
|
||||
if (f.type !== "file") continue;
|
||||
const st = f.securityFileStatus || {};
|
||||
const subs = Object.values(st).filter(v => v && typeof v === "object").map(v => String(v.status || ""));
|
||||
const all = [String(st.status || ""), ...subs].map(x => x.toLowerCase());
|
||||
if (all.some(x => /unsafe|suspicious|malicious|caution|infected/.test(x))) out.flagged.push({ path: f.path, status: all.filter(Boolean).join(", ") });
|
||||
else if (all.includes("safe")) out.scanned++;
|
||||
if (RISKY_FILE_RE.test(f.path)) out.risky.push(f.path);
|
||||
}
|
||||
out.risky = out.risky.slice(0, 20);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function addHfRepoToCatalog(repo, description = "") {
|
||||
repo = hfRepoFromInput(repo);
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
|
||||
const q = await hfRepoQuant(repo, "Q4_K_M");
|
||||
if (!q) {
|
||||
const err = new Error("In diesem Repo gibt es kein GGUF — das braucht die Box (llama.cpp).");
|
||||
err.alternatives = await hfGgufAlternatives(repo);
|
||||
throw err;
|
||||
}
|
||||
const cat = loadLlmCatalog();
|
||||
let id = slugModelId(repo);
|
||||
const same = cat.find(m => m.hfRepo === repo);
|
||||
if (same) return { models: cat, id: same.id, existed: true };
|
||||
while (cat.some(m => m.id === id)) id += "-2";
|
||||
cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants,
|
||||
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf",
|
||||
tools: await hfToolSupport(repo), uncensored: UNCENSORED_RE.test(repo),
|
||||
safety: await hfRepoSafety(repo), popularity: await hfPopularity(repo),
|
||||
added_at: new Date().toISOString().slice(0, 10) });
|
||||
saveLlmCatalog(cat);
|
||||
return { models: cat, id, existed: false };
|
||||
}
|
||||
|
||||
// ── File-Project-Manifest ───────────────────────────────────────────
|
||||
@@ -601,6 +1018,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);
|
||||
@@ -671,6 +1095,9 @@ function satelliteList() {
|
||||
}));
|
||||
}
|
||||
|
||||
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
|
||||
const credsRevealWaiters = new Map();
|
||||
|
||||
function broadcastSatellites() {
|
||||
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||
}
|
||||
@@ -705,6 +1132,11 @@ 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 || [],
|
||||
disk: w.disk || null, // LLM-Box: freier Platz + heruntergeladene Modelle
|
||||
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
||||
}));
|
||||
}
|
||||
@@ -1148,6 +1580,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 +1664,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 || {} });
|
||||
@@ -1233,7 +1674,15 @@ function connectRVS(forcePlain) {
|
||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_result") {
|
||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||
broadcast({ type: "sat_result", payload: msg.payload || {} });
|
||||
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
|
||||
const p = msg.payload || {};
|
||||
const asker = credsRevealWaiters.get(p.requestId);
|
||||
if (asker) {
|
||||
credsRevealWaiters.delete(p.requestId);
|
||||
if (asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: "sat_result", payload: p }));
|
||||
} else if (!(p.result && typeof p.result === "object" && "kennwort" in p.result)) { // Kennwort ohne Fragenden: verwerfen
|
||||
broadcast({ type: "sat_result", payload: p });
|
||||
}
|
||||
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
|
||||
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
|
||||
// der Bridge → an den Browser (Agent-Historie).
|
||||
@@ -1246,7 +1695,16 @@ function connectRVS(forcePlain) {
|
||||
location: p.location || "", devices: p.devices || [] });
|
||||
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
|
||||
// Credential-Store-Antworten eines Satelliten → an den Browser.
|
||||
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||
// Mit Klartext-Passwoertern (Auge im Formular) NUR an den Browser, der
|
||||
// gefragt hat — nie an alle offenen Diagnostic-Tabs.
|
||||
const p = msg.payload || {};
|
||||
if (p.revealed) {
|
||||
const asker = credsRevealWaiters.get(p.requestId);
|
||||
credsRevealWaiters.delete(p.requestId);
|
||||
if (asker && asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: msg.type, payload: p }));
|
||||
} else {
|
||||
broadcast({ type: msg.type, payload: p });
|
||||
}
|
||||
} else if (msg.type === "worker_hello") {
|
||||
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
|
||||
const p = msg.payload || {};
|
||||
@@ -1265,6 +1723,7 @@ function connectRVS(forcePlain) {
|
||||
// in eine Karte laden koennte.
|
||||
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
|
||||
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []),
|
||||
disk: p.disk || prev.disk || null,
|
||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||
@@ -1399,7 +1858,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 {}
|
||||
});
|
||||
@@ -1464,6 +1923,26 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, tim
|
||||
freshWs.on("error", () => {});
|
||||
}
|
||||
|
||||
// Wie sendToRVS_withResponse, aber als Promise fuer den Server selbst:
|
||||
// eigene Verbindung, wartet auf expectType mit passender requestId.
|
||||
function rvsRequest(sendType, sendPayload, expectType, timeoutMs = 60000) {
|
||||
return new Promise((resolve) => {
|
||||
if (!RVS_HOST || !RVS_TOKEN) return resolve({ ok: false, error: "RVS nicht konfiguriert" });
|
||||
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
||||
const ws2 = new WebSocket(`${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`);
|
||||
const done = (v) => { clearTimeout(t); try { ws2.close(); } catch (_) {} resolve(v); };
|
||||
const t = setTimeout(() => done({ ok: false, error: "Zeitüberschreitung" }), timeoutMs);
|
||||
ws2.on("open", () => ws2.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })));
|
||||
ws2.on("message", (raw) => {
|
||||
try {
|
||||
const r = JSON.parse(raw.toString());
|
||||
if (r.type === expectType && (r.payload || {}).requestId === sendPayload.requestId) done(r.payload || {});
|
||||
} catch {}
|
||||
});
|
||||
ws2.on("error", (e) => done({ ok: false, error: e.message }));
|
||||
});
|
||||
}
|
||||
|
||||
function sendToRVS_raw(msgObj) {
|
||||
if (!RVS_HOST || !RVS_TOKEN) return;
|
||||
const payload = JSON.stringify(msgObj);
|
||||
@@ -2249,13 +2728,63 @@ const server = http.createServer((req, res) => {
|
||||
refreshLlmCatalogFromHF()
|
||||
.then(r => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
|
||||
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added, removed: r.removed }));
|
||||
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
|
||||
})
|
||||
.catch(err => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
|
||||
});
|
||||
} else if (req.url.startsWith("/api/aria-proof") && req.method === "GET") {
|
||||
// Belege zu einer ARIA-Antwort: alle Werkzeug-Aufrufe (Bash/ssh, Read, …),
|
||||
// die ARIA im Proxy zwischen Stefans Frage und dieser Antwort gemacht hat.
|
||||
// Quelle: /shared/logs/aria-tools/<tag>.jsonl (proxy-patches/routes.js).
|
||||
const u = new URL(req.url, "http://x");
|
||||
const to = Number(u.searchParams.get("ts")) || 0;
|
||||
const project = u.searchParams.get("project") || "";
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
if (!to) { res.end(JSON.stringify({ ok: false, error: "ts fehlt" })); return; }
|
||||
try { res.end(JSON.stringify({ ok: true, ...ariaProof(to, project) })); }
|
||||
catch (e) { res.end(JSON.stringify({ ok: false, error: e.message })); }
|
||||
} else if (req.url.startsWith("/api/llm-discover") && req.method === "GET") {
|
||||
const u = new URL(req.url, "http://x");
|
||||
discoverGguf(u.searchParams.get("cat") || "chat", u.searchParams.get("trusted") !== "0")
|
||||
.then(items => {
|
||||
const inCat = new Set(loadLlmCatalog().map(m => m.hfRepo));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, items: items.map(i => ({ ...i, inCatalog: inCat.has(i.repo) })) }));
|
||||
})
|
||||
.catch(err => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message, items: [] }));
|
||||
});
|
||||
} else if (req.url.startsWith("/api/llm-model-info") && req.method === "GET") {
|
||||
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
|
||||
llmModelInfo(repo)
|
||||
.then(info => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...info })); })
|
||||
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
|
||||
} else if (req.url.startsWith("/api/llm-model-summary") && req.method === "GET") {
|
||||
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
|
||||
llmModelSummary(repo)
|
||||
.then(r => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: !!r.summary, ...r })); })
|
||||
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
|
||||
} else if (req.url === "/api/llm-catalog/add" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
let repo = "", manual = false;
|
||||
try { const b = JSON.parse(body || "{}"); repo = b.hfRepo; manual = !!b.manual; } catch {}
|
||||
addHfRepoToCatalog(repo, manual ? "Von Hand hinzugefügt" : "")
|
||||
.then(r => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, ...r }));
|
||||
if (!r.existed) log("info", "llm", `Katalog: ${repo} aus den HF-Trends übernommen (${r.id})`);
|
||||
})
|
||||
.catch(err => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message, alternatives: err.alternatives || [] }));
|
||||
});
|
||||
});
|
||||
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readLocalLlmConfig()));
|
||||
@@ -3113,6 +3642,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.
|
||||
@@ -3131,6 +3667,10 @@ wss.on("connection", (ws) => {
|
||||
params.rvs_tls = RVS_TLS === "true";
|
||||
params.rvs_token = RVS_TOKEN;
|
||||
}
|
||||
if (String(msg.cmd || "") === "fritzbox.backup_password" && msg.requestId) {
|
||||
credsRevealWaiters.set(String(msg.requestId), ws);
|
||||
setTimeout(() => credsRevealWaiters.delete(String(msg.requestId)), 60000);
|
||||
}
|
||||
sendToRVS_raw({
|
||||
type: "sat_command",
|
||||
payload: {
|
||||
@@ -3184,8 +3724,13 @@ wss.on("connection", (ws) => {
|
||||
requestId: "dn_" + Date.now() },
|
||||
timestamp: Date.now() });
|
||||
} else if (msg.action === "sat_creds_list") {
|
||||
const requestId = "dc_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
|
||||
if (msg.reveal) {
|
||||
credsRevealWaiters.set(requestId, ws);
|
||||
setTimeout(() => credsRevealWaiters.delete(requestId), 30000);
|
||||
}
|
||||
sendToRVS_raw({ type: "sat_creds_list",
|
||||
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
|
||||
payload: { satellite: msg.satellite || "", ip: msg.ip || "", reveal: !!msg.reveal, requestId },
|
||||
timestamp: Date.now() });
|
||||
} else if (msg.action === "sat_creds_set") {
|
||||
sendToRVS_raw({ type: "sat_creds_set",
|
||||
@@ -3371,12 +3916,14 @@ wss.on("connection", (ws) => {
|
||||
// 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() });
|
||||
{ phase: "queued", requestedAt: Date.now(), downloadOnly: !!msg.downloadOnly });
|
||||
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
|
||||
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
||||
targetInstance: msg.targetInstance || "",
|
||||
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
||||
cpuMoe: msg.cpuMoe ?? 0, // MoE: 0 Karte, -1 alle Experten in den RAM, -2 automatisch verteilen
|
||||
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
|
||||
downloadOnly: !!msg.downloadOnly, // nur auf die Platte, in keine Karte
|
||||
}, timestamp: Date.now() });
|
||||
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_unload_model") {
|
||||
@@ -3385,9 +3932,11 @@ wss.on("connection", (ws) => {
|
||||
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
|
||||
log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_remove_model") {
|
||||
// cancel = laufenden Download abbrechen, deleteFiles = Dateien von der Platte
|
||||
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
||||
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
||||
log("info", "llm", `remove '${msg.key}' → ${msg.targetInstance || "?"}`);
|
||||
targetInstance: msg.targetInstance || "", key: msg.key, hfRepo: msg.hfRepo || "",
|
||||
cancel: !!msg.cancel, deleteFiles: !!msg.deleteFiles }, timestamp: Date.now() });
|
||||
log("info", "llm", `${msg.cancel ? "abbrechen" : msg.deleteFiles ? "Dateien loeschen" : "remove"} '${msg.key}' → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_test") {
|
||||
// Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response).
|
||||
const reqId = "diagtest_" + Date.now();
|
||||
|
||||
@@ -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) ────
|
||||
|
||||
+263
-174
@@ -1,47 +1,43 @@
|
||||
# Lokale Sprachmodelle nutzen — Schnittstelle für Drittprogramme
|
||||
# Lokale Sprachmodelle nutzen — Schnittstelle für Programme
|
||||
|
||||
Diese Seite beschreibt, wie ein Programm die lokalen Sprachmodelle auf den
|
||||
KI-Boxen von ARIA nutzt: über den RVS (WebSocket) oder direkt über den
|
||||
OpenAI-kompatiblen Port einer Box.
|
||||
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
|
||||
|
||||
**Clients wählen aus, laden aber nie.**
|
||||
**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 auf welcher Karte liegt, legt ausschließlich der Betreiber in
|
||||
der ARIA-Diagnostic fest (Modell-Katalog → Laden → Karte).
|
||||
- Ein Client — ARIA selbst, ein Drittprogramm, OpenHands — sieht nur die
|
||||
**geladenen** Modelle aller Boxen und schickt seine Anfrage an eines davon.
|
||||
- Eine Anfrage für ein Modell, das nicht geladen ist, wird **abgelehnt**. Sie
|
||||
lädt nichts nach und verdrängt nichts. Kein Client kann einem anderen das
|
||||
Modell wegnehmen.
|
||||
- Mehrere Clients dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
|
||||
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer, die
|
||||
Karte schafft in Summe aber mehr.
|
||||
- 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.
|
||||
|
||||
Welche Karten einer Box dem LLM gehören, steht in deren `.env`:
|
||||
## Welcher Zugang?
|
||||
|
||||
```bash
|
||||
# Beispiel: Karte 0 für Spracherkennung + Sprachausgabe, Karten 1 und 2 fürs LLM
|
||||
WHISPER_GPU=0
|
||||
F5TTS_GPU=0
|
||||
LLM_GPUS=1,2
|
||||
```
|
||||
|
||||
## Zwei Zugänge
|
||||
|
||||
| | RVS (WebSocket) | OpenAI-Port der Box |
|
||||
|---|---|---|
|
||||
| Erreichbar | überall, wo der RVS erreichbar ist | nur im Netz der Box (Standard: nur `localhost`) |
|
||||
| Sieht | alle Boxen und ihre Karten | nur diese eine Box |
|
||||
| Format | eigene Nachrichten (unten) | OpenAI `/v1/chat/completions` |
|
||||
| Typische Nutzer | eigene Programme, ARIA | OpenHands, Continue, Open WebUI |
|
||||
| | 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) Über den RVS
|
||||
## A) Cluster über RVS — empfohlen
|
||||
|
||||
### Verbinden
|
||||
|
||||
@@ -49,13 +45,19 @@ LLM_GPUS=1,2
|
||||
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
|
||||
```
|
||||
|
||||
- Das **Token ist der Raum**. Es ist dasselbe Token, das ARIA, die Diagnostic
|
||||
und die KI-Boxen nutzen (`RVS_TOKEN`). Wer das Token hat, ist im Raum.
|
||||
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an *alle anderen*
|
||||
Teilnehmer des Raums. Du siehst also auch Antworten, die für andere Clients
|
||||
bestimmt sind — ordne Antworten immer über die `requestId` zu.
|
||||
- Der RVS lässt nur bekannte Nachrichtentypen durch. Unbekannte Typen
|
||||
verschwinden **ohne Fehlermeldung**. Die hier beschriebenen sind freigegeben.
|
||||
- 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:
|
||||
|
||||
@@ -63,25 +65,33 @@ Jede Nachricht hat diese Hülle:
|
||||
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
|
||||
```
|
||||
|
||||
### 1. Welche Modelle gibt es? — `llm_models_request`
|
||||
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_models_request", "payload": { "requestId": "abc-1" } }
|
||||
{ "type": "llm_cluster_models_request", "payload": { "requestId": "a1" } }
|
||||
```
|
||||
|
||||
**Jede** Box im Raum antwortet einzeln mit `llm_models`. Sammle die Antworten
|
||||
ein paar Sekunden lang ein (2–3 s reichen):
|
||||
Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_models",
|
||||
"type": "llm_cluster_models",
|
||||
"payload": {
|
||||
"requestId": "abc-1",
|
||||
"instanceId": "llm@ki-box",
|
||||
"node": "ki-box",
|
||||
"requestId": "a1",
|
||||
"models": [
|
||||
{ "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
|
||||
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" }
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -89,28 +99,18 @@ ein paar Sekunden lang ein (2–3 s reichen):
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `instanceId` | Adresse der Box — brauchst du gleich als `targetInstance` |
|
||||
| `node` | lesbarer Name der Box |
|
||||
| `models[].model` | Modellname, so in der Anfrage angeben |
|
||||
| `models[].gpu` | Karte, auf der es läuft |
|
||||
| `models[].state` | `ready` = bereit, `starting` = startet gerade neu (Anfragen warten dann kurz), `unknown` = Box konnte es nicht prüfen |
|
||||
| `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 |
|
||||
|
||||
Es stehen **nur geladene** Modelle in der Liste. Was dort nicht steht, kannst
|
||||
du nicht nutzen.
|
||||
|
||||
Mitlesen statt fragen: Die Boxen melden sich außerdem regelmäßig selbst
|
||||
(`worker_hello` etwa alle 30 s, `worker_ping` alle 10 s, jeweils mit
|
||||
`service: "llm"`, `models` und `slots`). Ein Client, der länger verbunden
|
||||
bleibt, kann die Liste daraus aktuell halten.
|
||||
|
||||
### 2. Fragen — `llm_request`
|
||||
### 2. Fragen — `llm_cluster_request`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_request",
|
||||
"type": "llm_cluster_request",
|
||||
"payload": {
|
||||
"requestId": "abc-2",
|
||||
"targetInstance": "llm@ki-box",
|
||||
"requestId": "a2",
|
||||
"model": "qwen3-8b",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Du antwortest kurz." },
|
||||
@@ -124,172 +124,261 @@ bleibt, kann die Liste daraus aktuell halten.
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) — damit findest du deine Antwort |
|
||||
| `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`) |
|
||||
| `model` | dringend empfohlen | eines aus `llm_models`. Ohne Angabe nimmt die Box das Modell ihrer ersten LLM-Karte. |
|
||||
| `targetInstance` | dringend empfohlen | die `instanceId` aus `llm_models`. Siehe „Ohne `targetInstance`". |
|
||||
| `max_tokens` | nein | Standard 512 |
|
||||
| `temperature` | nein | Standard 0.7 |
|
||||
| `stop` | nein | Liste von Stop-Strings |
|
||||
| `tools` | nein | Tool-Definitionen im OpenAI-Format; Antwort enthält dann ggf. `tool_calls` |
|
||||
| `tools` | nein | Tool-Definitionen im OpenAI-Format; die Antwort enthält dann ggf. `tool_calls` |
|
||||
|
||||
### 3. Antwort — `llm_response`
|
||||
### 3. Antwort — `llm_cluster_response`
|
||||
|
||||
Erfolg:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_response",
|
||||
"type": "llm_cluster_response",
|
||||
"payload": {
|
||||
"requestId": "abc-2",
|
||||
"requestId": "a2",
|
||||
"ok": true,
|
||||
"content": "Die Zugspitze ist 2962 Meter hoch.",
|
||||
"tool_calls": null,
|
||||
"model": "qwen3-8b",
|
||||
"instanceId": "llm@ki-box",
|
||||
"node": "ki-box",
|
||||
"gpu": "1",
|
||||
"node": "werkstatt",
|
||||
"gpu": "0",
|
||||
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
|
||||
"elapsedMs": 640
|
||||
"elapsedMs": 640,
|
||||
"attempts": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Im Fehlerfall ist `ok` `false`, dazu kommen `error` (Text für Menschen) und
|
||||
`errorCode` (für Programme):
|
||||
`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_not_loaded` | Das Modell liegt auf dieser Box nicht. `loadedModels` nennt, was dort geladen ist. | Ein geladenes Modell wählen. Die Liste neu holen (`llm_models_request`). |
|
||||
| `no_model_loaded` | Auf dieser Box ist keine LLM-Karte belegt. | Andere Box wählen. |
|
||||
| *(fehlt)* | Fehler des Modells selbst, z.B. Kontext zu lang | `error` lesen |
|
||||
| `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 |
|
||||
|
||||
### Ohne `targetInstance`
|
||||
|
||||
Ohne `targetInstance` geht die Anfrage an alle Boxen. Antworten werden nur die,
|
||||
die das Modell geladen haben — die anderen **schweigen**, damit du nicht von
|
||||
jeder Box einen Fehler bekommst. Hat keine Box das Modell, kommt also **gar
|
||||
keine** Antwort und dein Client läuft in seinen Timeout. Darum: immer
|
||||
`targetInstance` setzen. Dann bekommst du bei einem Problem sofort eine klare
|
||||
Absage.
|
||||
|
||||
Haben mehrere Boxen dasselbe Modell geladen und du lässt `targetInstance`
|
||||
weg, antworten **alle**. Auch das spricht für `targetInstance`.
|
||||
|
||||
### Was Clients nicht senden
|
||||
|
||||
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
|
||||
Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der RVS
|
||||
unterscheidet die Teilnehmer nicht. Ein Programm, das sich daran nicht hält,
|
||||
tauscht anderen Clients das Modell unter den Füßen weg; genau das soll diese
|
||||
Schnittstelle verhindern.
|
||||
**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`). Genau so gegen einen
|
||||
echten RVS, llm-adapter und llama-swap getestet — nur die Adresse war eine andere.
|
||||
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:443/?token=DEIN_RVS_TOKEN"
|
||||
RVS = "wss://rvs.example.org:444/?token=DEIN_RVS_TOKEN"
|
||||
|
||||
|
||||
async def models(ws, wait=2.5):
|
||||
"""Alle geladenen Modelle aller Boxen: [(instanceId, node, model, gpu)]."""
|
||||
rid = str(uuid.uuid4())
|
||||
await ws.send(json.dumps({"type": "llm_models_request", "payload": {"requestId": rid}}))
|
||||
found = []
|
||||
loop = asyncio.get_running_loop()
|
||||
end = loop.time() + wait
|
||||
while (left := end - loop.time()) > 0:
|
||||
try:
|
||||
msg = json.loads(await asyncio.wait_for(ws.recv(), left))
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
p = msg.get("payload") or {}
|
||||
if msg.get("type") == "llm_models" and p.get("requestId") == rid:
|
||||
found += [(p["instanceId"], p["node"], m["model"], m["gpu"]) for m in p["models"]]
|
||||
return found
|
||||
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 ask(ws, instance_id, model, prompt, timeout=120):
|
||||
rid = str(uuid.uuid4())
|
||||
await ws.send(json.dumps({"type": "llm_request", "payload": {
|
||||
"requestId": rid, "targetInstance": instance_id, "model": model,
|
||||
"messages": [{"role": "user", "content": prompt}], "max_tokens": 256}}))
|
||||
async with asyncio.timeout(timeout):
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
async def _read(self):
|
||||
async for raw in self.ws:
|
||||
msg = json.loads(raw)
|
||||
p = msg.get("payload") or {}
|
||||
# Der Raum ist ein Verteiler: nur UNSERE Antwort nehmen.
|
||||
if msg.get("type") == "llm_response" and p.get("requestId") == rid:
|
||||
if not p["ok"]:
|
||||
raise RuntimeError(f"{p.get('errorCode')}: {p.get('error')}")
|
||||
return p["content"]
|
||||
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:
|
||||
available = await models(ws)
|
||||
for iid, node, model, gpu in available:
|
||||
print(f"{node} / Karte {gpu}: {model} ({iid})")
|
||||
if available:
|
||||
iid, node, model, gpu = available[0]
|
||||
print(await ask(ws, iid, model, "Sag Hallo in einem Satz."))
|
||||
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) Über den OpenAI-Port einer Box
|
||||
## B) OpenAI-RVS-Wrapper — für Programme, die nur OpenAI können
|
||||
|
||||
Jede KI-Box bietet einen OpenAI-kompatiblen Endpunkt — für Programme, die nur
|
||||
„OpenAI-kompatibel" können (OpenHands, Continue, Open WebUI …).
|
||||
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:
|
||||
|
||||
```
|
||||
http://<IP der Box>:<LLM_OPENAI_PORT>/v1 (Standard-Port 8081)
|
||||
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
|
||||
```
|
||||
|
||||
- `GET /v1/models` listet **nur die geladenen** Modelle dieser Box.
|
||||
- `POST /v1/chat/completions` mit `"model": "<Name>"` wie gewohnt.
|
||||
- Ein nicht geladenes Modell wird mit **HTTP 404** abgelehnt
|
||||
(`"no router for requested model"`) — es wird nichts nachgeladen.
|
||||
- Ist in der Diagnostic ein API-Key gesetzt, mit
|
||||
`Authorization: Bearer <KEY>` schicken.
|
||||
- Standardmäßig hört der Port nur auf `localhost` der Box. Für Zugriff aus dem
|
||||
Netz `LLM_OPENAI_BIND=0.0.0.0` in der `.env` der Box setzen — dann
|
||||
**unbedingt** einen API-Key vergeben.
|
||||
- Dieser Zugang sieht nur seine eigene Box. Wer mehrere Boxen nutzen will,
|
||||
nimmt den RVS.
|
||||
Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
|
||||
|
||||
```bash
|
||||
curl http://192.168.177.153:8081/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "qwen3-8b", "messages": [{"role": "user", "content": "Hallo"}]}'
|
||||
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,
|
||||
Verlauf zu lang → **400 `context_length_exceeded`** mit dem OpenAI-üblichen Text
|
||||
(„maximum context length is N tokens …") — daran erkennen OpenAI-Clients
|
||||
(litellm, OpenHands …) den Überlauf und kürzen/verdichten selbst
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Den Verlauf verwaltet das Programm
|
||||
|
||||
Die Box merkt sich **nichts** zwischen zwei Anfragen. Jede Anfrage muss alles
|
||||
mitbringen, was das Modell sehen soll (Systemprompt, Werkzeuge, Verlauf). Das
|
||||
Kontextfenster (`ctx`) ist die Obergrenze für **eine** Anfrage inklusive
|
||||
Antwort. Wächst der Verlauf darüber hinaus, muss das Programm ihn kleiner
|
||||
machen — drei übliche Wege, auch kombiniert:
|
||||
|
||||
| Weg | Was passiert | Gut für |
|
||||
|---|---|---|
|
||||
| **Kürzen** | älteste Nachrichten weglassen (Systemprompt behalten) | einfache Chats, Werkzeug-Schleifen |
|
||||
| **Verdichten** („Compacting") | alte Teile vom Modell zusammenfassen lassen, Zusammenfassung statt Original mitsenden | lange Arbeitssitzungen |
|
||||
| **Gedächtnis** (RAG) | Wichtiges als Fakten in einer Datenbank ablegen, pro Anfrage nur Passendes dazuholen | dauerhaftes Wissen über Sitzungen hinweg |
|
||||
|
||||
Wichtig dabei: Systemprompt **und** Werkzeug-Definitionen zählen mit, Platz
|
||||
für die Antwort (`max_tokens`) einplanen, Token grob schätzen (Deutsch/Code:
|
||||
eher 2,5–3 Zeichen pro Token) — und `context_exceeded` trotzdem abfangen:
|
||||
kürzen, einmal neu senden.
|
||||
|
||||
## 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 Clients können **dasselbe Modell gleichzeitig** nutzen. llama.cpp
|
||||
bedient sie parallel (Continuous Batching); jede Antwort wird etwas
|
||||
langsamer, insgesamt schafft die Karte mehr.
|
||||
- Der Kontext (`-c`, Standard 8192 Tokens) ist bei aktuellen llama.cpp-Versionen
|
||||
ein **gemeinsamer Vorrat** aller gleichzeitigen Anfragen. Schicken mehrere
|
||||
Clients gleichzeitig sehr lange Verläufe, wird es dort eng.
|
||||
- Ältere llama.cpp-Versionen arbeiten Anfragen an dasselbe Modell nacheinander
|
||||
ab statt parallel. Welche Version eine Box fährt, hängt am Image von
|
||||
llama-swap (`docker compose pull llama-swap` holt die aktuelle).
|
||||
- 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 sofort
|
||||
weg; laufende Clients bekommen dann `model_not_loaded` und sollten die Liste
|
||||
neu holen.
|
||||
- Beim Laden oder Entladen startet die Box **alle** ihre Modelle kurz neu
|
||||
(wenige Sekunden). Anfragen in diesem Moment warten, bis das Modell wieder
|
||||
bereit ist (`state: "starting"`).
|
||||
- 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. Ein Client sollte unbekannte Felder ignorieren.
|
||||
dazukommen. Unbekannte Felder ignorieren.
|
||||
|
||||
@@ -18,7 +18,16 @@ WORKDIR /work
|
||||
|
||||
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
|
||||
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
|
||||
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
|
||||
# nssm.cc faellt oefter aus (503) — dann Ersatz aus dem Internet Archive.
|
||||
# Beide liefern dieselbe Datei; die Pruefsumme stellt das sicher.
|
||||
ARG NSSM_SHA256=727d1e42275c605e0f04aba98095c38a8e1e46def453cdffce42869428aa6743
|
||||
RUN for url in https://nssm.cc/release/nssm-2.24.zip \
|
||||
"https://web.archive.org/web/2024id_/https://nssm.cc/release/nssm-2.24.zip"; do \
|
||||
curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors "$url" -o /tmp/nssm.zip \
|
||||
&& echo "$NSSM_SHA256 /tmp/nssm.zip" | sha256sum -c - && break; \
|
||||
echo "nssm: $url fehlgeschlagen, versuche naechste Quelle" >&2; rm -f /tmp/nssm.zip; \
|
||||
done \
|
||||
&& test -f /tmp/nssm.zip \
|
||||
&& unzip -q /tmp/nssm.zip -d /tmp \
|
||||
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
|
||||
&& rm -rf /tmp/nssm*
|
||||
|
||||
@@ -142,15 +142,19 @@ Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
|
||||
|
||||
## Release (Binaries als Gitea-Assets)
|
||||
|
||||
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
|
||||
`release_agent.sh [version]` baut alles Docker-Baubare und hängt es als
|
||||
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
|
||||
|
||||
```bash
|
||||
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
|
||||
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
|
||||
./release_agent.sh # Version automatisch +1 (0.0.1.6 → 0.0.1.7)
|
||||
./release_agent.sh 0.2.0 # Version selbst setzen; Linux + Android + Windows (Wine)
|
||||
SKIP_WINDOWS=1 ./release_agent.sh # ohne Windows (schneller)
|
||||
```
|
||||
|
||||
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
|
||||
Assets: `aria-host-agent-linux-x64-v<v>.zip` (Binary + `install-service.sh` +
|
||||
systemd-Unit + `.env.example` + `LIESMICH.txt` — entpacken, `.env` anlegen, dann
|
||||
`./aria-host-agent` oder als Dienst `sudo ./install-service.sh ./.env`),
|
||||
`aria-host-agent-android-agent-v<v>.apk`,
|
||||
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
|
||||
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
|
||||
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
|
||||
|
||||
@@ -246,7 +246,7 @@ es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh
|
||||
|
||||
```bash
|
||||
cd host-agent
|
||||
./release_agent.sh 0.2.0
|
||||
./release_agent.sh # Version automatisch +1 (oder: ./release_agent.sh 0.2.0)
|
||||
```
|
||||
|
||||
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
|
||||
@@ -268,3 +268,19 @@ einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
|
||||
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
|
||||
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
|
||||
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
|
||||
|
||||
## Auto-Update
|
||||
|
||||
Der Agent fragt beim Verbinden (und danach alle ~6 h) den RVS nach einer neueren
|
||||
Version (`agent_update_check`). Liegt auf dem RVS eine neuere APK in
|
||||
`rvs/updates/agent/`, lädt er sie im Hintergrund per HTTP
|
||||
(`/update/agent/latest.apk`, Token im Header) und zeigt
|
||||
**„ARIA Host-Agent x.y bereit — Tippen zum Installieren"** — zusätzlich einen
|
||||
Knopf in der App. Den Install-Dialog zeigt Android selbst; still installieren
|
||||
darf eine normale App nicht. Beim ersten Mal fragt Android, ob die App
|
||||
„unbekannte Apps installieren" darf.
|
||||
|
||||
`release_agent.sh` kopiert die APK automatisch auf den RVS, wenn `RVS_UPDATE_HOST`
|
||||
gesetzt ist (wie bei `release.sh`). Manuell:
|
||||
`scp aria-android-agent.apk <rvs-host>:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v<version>.apk`
|
||||
— die Version muss im Dateinamen stehen.
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId 'de.hackersoft.ariaagent'
|
||||
minSdk 26
|
||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||
versionCode 105
|
||||
versionName '0.0.1.5'
|
||||
versionCode 108
|
||||
versionName '0.0.1.8'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<!-- Auto-Update: neue Agent-APK vom RVS installieren (Android fragt trotzdem nach) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
|
||||
@@ -60,6 +62,17 @@
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- Auto-Update: geladene APK an den Android-Installer uebergeben -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".AgentService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -32,6 +32,11 @@ class AgentService : Service() {
|
||||
@Volatile var status: String = "gestoppt"
|
||||
@Volatile var connected: Boolean = false
|
||||
|
||||
/** App-Oberflaeche auffrischen (z.B. "Update bereit"-Knopf) */
|
||||
fun broadcastStatus(ctx: Context) {
|
||||
ctx.sendBroadcast(Intent(ACTION_STATUS).setPackage(ctx.packageName))
|
||||
}
|
||||
|
||||
fun start(ctx: Context) {
|
||||
val i = Intent(ctx, AgentService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Auto-Update fuer den Host-Agent (Android / Fire TV).
|
||||
*
|
||||
* 1. RvsClient fragt beim Verbinden (und alle paar Stunden) per
|
||||
* agent_update_check nach; der RVS antwortet mit agent_update_available.
|
||||
* 2. Wir laden die APK im Hintergrund per HTTP vom RVS
|
||||
* (/update/agent/latest.apk, Token im Header) — sie ist nur ein paar MB.
|
||||
* 3. Dann eine Benachrichtigung "Tippen zum Installieren" (und ein Knopf in
|
||||
* der App). Den Install-Dialog zeigt Android selbst — still installieren
|
||||
* darf eine normale App nicht, ein Tippen braucht es immer.
|
||||
*/
|
||||
object AgentUpdater {
|
||||
private const val CH = "aria_agent_update"
|
||||
private const val NOTIF_ID = 4711
|
||||
private const val DIR = "updates"
|
||||
|
||||
/** Fertig geladene, noch nicht installierte Version ("" = keine) */
|
||||
@Volatile var readyVersion: String = ""
|
||||
private set
|
||||
@Volatile private var downloading = false
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS) // Abbruch nur, wenn der Download haengt
|
||||
.build()
|
||||
|
||||
fun installedVersion(ctx: Context): String = try {
|
||||
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0"
|
||||
} catch (_: Exception) { "0" }
|
||||
|
||||
/** a > b ? (Versionen wie 0.0.1.6) */
|
||||
fun isNewer(a: String, b: String): Boolean {
|
||||
val pa = a.split('.').map { it.toIntOrNull() ?: 0 }
|
||||
val pb = b.split('.').map { it.toIntOrNull() ?: 0 }
|
||||
for (i in 0 until maxOf(pa.size, pb.size)) {
|
||||
val d = pa.getOrElse(i) { 0 } - pb.getOrElse(i) { 0 }
|
||||
if (d != 0) return d > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun apkFile(ctx: Context, version: String) =
|
||||
File(File(ctx.cacheDir, DIR).apply { mkdirs() }, "aria-host-agent-v$version.apk")
|
||||
|
||||
/** RVS meldet eine neue Version → im Hintergrund laden, dann melden. */
|
||||
fun onAvailable(ctx: Context, config: AgentConfig, version: String, size: Long) {
|
||||
val app = ctx.applicationContext
|
||||
if (!isNewer(version, installedVersion(app))) return
|
||||
val file = apkFile(app, version)
|
||||
if (readyVersion == version && file.exists()) { notifyReady(app, version); return }
|
||||
if (downloading) return
|
||||
downloading = true
|
||||
Thread {
|
||||
try {
|
||||
// Reste frueherer Versionen wegraeumen
|
||||
File(app.cacheDir, DIR).listFiles()?.forEach { if (it != file) it.delete() }
|
||||
val proto = if (config.tls) "https" else "http"
|
||||
val req = Request.Builder()
|
||||
.url("$proto://${config.host}:${config.port}/update/agent/latest.apk")
|
||||
.header("X-RVS-Token", config.token)
|
||||
.build()
|
||||
var lastErr = ""
|
||||
for (attempt in 1..3) {
|
||||
try {
|
||||
http.newCall(req).execute().use { res ->
|
||||
if (!res.isSuccessful) throw IllegalStateException("HTTP ${res.code}")
|
||||
val body = res.body ?: throw IllegalStateException("leere Antwort")
|
||||
val expected = body.contentLength()
|
||||
val tmp = File(file.path + ".part")
|
||||
body.byteStream().use { input -> tmp.outputStream().use { input.copyTo(it) } }
|
||||
if (expected > 0 && tmp.length() != expected) {
|
||||
throw IllegalStateException("unvollstaendig (${tmp.length()} von $expected)")
|
||||
}
|
||||
if (!tmp.renameTo(file)) throw IllegalStateException("Speichern fehlgeschlagen")
|
||||
}
|
||||
readyVersion = version
|
||||
notifyReady(app, version)
|
||||
AgentService.broadcastStatus(app)
|
||||
return@Thread
|
||||
} catch (e: Exception) {
|
||||
lastErr = e.message ?: e.toString()
|
||||
try { Thread.sleep(3000L * attempt) } catch (_: InterruptedException) { return@Thread }
|
||||
}
|
||||
}
|
||||
android.util.Log.w("AgentUpdater", "Update $version nicht geladen: $lastErr")
|
||||
} finally {
|
||||
downloading = false
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
/** Install-Dialog von Android oeffnen (aus Benachrichtigung oder App-Knopf). */
|
||||
fun install(ctx: Context): Boolean {
|
||||
val v = readyVersion
|
||||
if (v.isEmpty()) return false
|
||||
val file = apkFile(ctx, v)
|
||||
if (!file.exists()) { readyVersion = ""; return false }
|
||||
// Android 8+: "Unbekannte Apps installieren" muss fuer DIESE App erlaubt sein
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !ctx.packageManager.canRequestPackageInstalls()) {
|
||||
ctx.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${ctx.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
return true
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||
ctx.startActivity(Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun notifyReady(ctx: Context, version: String) {
|
||||
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
nm.createNotificationChannel(NotificationChannel(CH, "Updates", NotificationManager.IMPORTANCE_DEFAULT))
|
||||
}
|
||||
val open = PendingIntent.getActivity(ctx, 1,
|
||||
Intent(ctx, MainActivity::class.java).setAction(MainActivity.ACTION_INSTALL_UPDATE)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
nm.notify(NOTIF_ID, NotificationCompat.Builder(ctx, CH)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("ARIA Host-Agent $version bereit")
|
||||
.setContentText("Tippen zum Installieren")
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.build())
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import org.json.JSONObject
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
/** Aus der "Update bereit"-Benachrichtigung: gleich den Install-Dialog oeffnen */
|
||||
const val ACTION_INSTALL_UPDATE = "de.hackersoft.ariaagent.INSTALL_UPDATE"
|
||||
}
|
||||
|
||||
private lateinit var host: EditText
|
||||
private lateinit var port: EditText
|
||||
private lateinit var token: EditText
|
||||
@@ -78,6 +83,10 @@ class MainActivity : AppCompatActivity() {
|
||||
tls = findViewById(R.id.tls)
|
||||
control = findViewById(R.id.control)
|
||||
statusView = findViewById(R.id.status)
|
||||
findViewById<Button>(R.id.btnUpdate).setOnClickListener {
|
||||
if (!AgentUpdater.install(this)) toast("Kein Update bereit")
|
||||
}
|
||||
if (intent?.action == ACTION_INSTALL_UPDATE) AgentUpdater.install(this)
|
||||
|
||||
findViewById<Button>(R.id.btnScan).setOnClickListener {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
@@ -209,13 +218,19 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun refreshStatus() {
|
||||
val dot = if (AgentService.connected) "🟢" else "🔴"
|
||||
statusView.text = "Status: ${AgentService.status} $dot"
|
||||
statusView.text = "Status: ${AgentService.status} $dot · v${AgentUpdater.installedVersion(this)}"
|
||||
val ready = AgentUpdater.readyVersion
|
||||
findViewById<Button>(R.id.btnUpdate).apply {
|
||||
visibility = if (ready.isNotEmpty()) android.view.View.VISIBLE else android.view.View.GONE
|
||||
text = "Update auf $ready installieren"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent == null) return
|
||||
setIntent(intent)
|
||||
if (intent.action == ACTION_INSTALL_UPDATE) { AgentUpdater.install(this); return }
|
||||
applyProvisioning(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ class RvsClient(
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
onStatus(true, "verbunden")
|
||||
sendHello(webSocket)
|
||||
sendUpdateCheck(webSocket)
|
||||
startPing(webSocket)
|
||||
}
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
@@ -141,9 +142,17 @@ class RvsClient(
|
||||
localIp()?.let { p.put("ip", it) }
|
||||
p.put("caps", JSONArray(caps))
|
||||
p.put("control", config.controlEnabled)
|
||||
p.put("version", AgentUpdater.installedVersion(appCtx))
|
||||
send(webSocket, "host_hello", p)
|
||||
}
|
||||
|
||||
/** Beim RVS nach einer neueren Agent-Version fragen (Antwort: agent_update_available) */
|
||||
private fun sendUpdateCheck(webSocket: WebSocket) {
|
||||
send(webSocket, "agent_update_check", JSONObject()
|
||||
.put("version", AgentUpdater.installedVersion(appCtx))
|
||||
.put("platform", "android"))
|
||||
}
|
||||
|
||||
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
|
||||
private fun localIp(): String? = try {
|
||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||
@@ -153,6 +162,8 @@ class RvsClient(
|
||||
?.hostAddress
|
||||
} catch (_: Throwable) { null }
|
||||
|
||||
private var pingCount = 0
|
||||
|
||||
private fun startPing(webSocket: WebSocket) {
|
||||
pingThread?.interrupt()
|
||||
pingThread = Thread {
|
||||
@@ -161,6 +172,8 @@ class RvsClient(
|
||||
val p = JSONObject().put("hostId", config.hostId())
|
||||
send(webSocket, "host_ping", p)
|
||||
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
||||
// Alle ~6 h nach Updates fragen (25 s × 864)
|
||||
if (++pingCount % 864 == 0) sendUpdateCheck(webSocket)
|
||||
}
|
||||
}.also { it.start() }
|
||||
}
|
||||
@@ -171,6 +184,11 @@ class RvsClient(
|
||||
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
|
||||
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
|
||||
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
|
||||
if (type == "agent_update_available") {
|
||||
val p = msg.optJSONObject("payload") ?: JSONObject()
|
||||
AgentUpdater.onAvailable(appCtx, config, p.optString("version"), p.optLong("size"))
|
||||
return
|
||||
}
|
||||
if (type != "host_command") {
|
||||
if (type in VOICE_TYPES) {
|
||||
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
android:text="Status: —"
|
||||
android:paddingBottom="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnUpdate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="false"
|
||||
android:visibility="gone"
|
||||
android:text="Update installieren" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnScan"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Auto-Update: nur der Update-Ordner im Cache wird an den Installer freigegeben -->
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
|
||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||
|
||||
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||
AGENT_VERSION = "0.0.1.5"
|
||||
AGENT_VERSION = "0.0.1.8"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@ else
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now aria-host-agent
|
||||
systemctl enable aria-host-agent
|
||||
# restart statt "enable --now": bei einem Update laeuft der Dienst schon und
|
||||
# muss die NEUE Binary laden
|
||||
systemctl restart aria-host-agent
|
||||
|
||||
echo
|
||||
echo "✓ Installiert & gestartet."
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
|
||||
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
|
||||
#
|
||||
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
|
||||
# Verwendung: ./release_agent.sh (Version automatisch +1)
|
||||
# ./release_agent.sh 0.1.0.0 (Version selbst setzen)
|
||||
#
|
||||
# Artefakte:
|
||||
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
|
||||
@@ -31,8 +32,38 @@ ROOT_DIR="$(cd .. && pwd)"
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
|
||||
# ── Parameter ────────────────────────────────────────────────────────
|
||||
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
|
||||
# Ohne Angabe: letzte Stelle automatisch hochzaehlen. Mit Angabe: genau diese.
|
||||
|
||||
# Naechste Version: hoechste aus Datei-Version und letztem Git-Tag, dann wie
|
||||
# ein Kilometerzaehler +1: hintere Stellen laufen 0..9 und uebertragen nach
|
||||
# vorne (0.2.6.9 → 0.2.7.0, 0.2.9.9 → 0.3.0.0), die vorderste zaehlt einfach
|
||||
# weiter (9.9.9.9 → 10.0.0.0). Aufruf: next_version <aktuelle> <tag-prefix>
|
||||
next_version() {
|
||||
local cur="$1" prefix="$2" tagv best
|
||||
tagv=$(git tag -l "${prefix}*" | sed "s/^${prefix}//" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
|
||||
best=$(printf '%s\n%s\n' "$cur" "$tagv" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
|
||||
[ -z "$best" ] && best="0.0.0.0"
|
||||
awk -F. -v OFS=. '{
|
||||
for (i = NF; i >= 1; i--) {
|
||||
if (i == 1 || $i < 9) { $i = $i + 1; break }
|
||||
$i = 0 # 9 → 0, Uebertrag nach vorne
|
||||
}
|
||||
print
|
||||
}' <<< "$best"
|
||||
}
|
||||
|
||||
CURRENT=$(grep -m1 "versionName" android/app/build.gradle | sed -E "s/.*versionName '([^']*)'.*/\1/")
|
||||
VERSION=${1:-$(next_version "$CURRENT" "agent-v")}
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
|
||||
echo -e "${RED}Ungueltige Version: $VERSION (z.B. 0.0.1.7)${NC}"; exit 1
|
||||
fi
|
||||
TAG="agent-v$VERSION"
|
||||
if git rev-parse "$TAG" &>/dev/null; then
|
||||
echo -e "${RED}Tag $TAG gibt es schon — andere Version angeben.${NC}"; exit 1
|
||||
fi
|
||||
echo -e "${CYAN}Agent-Version: $CURRENT → ${GREEN}$VERSION${NC}"
|
||||
read -p "Release $VERSION bauen? [J/n] " OK
|
||||
[[ "$OK" =~ ^[nN] ]] && { echo "Abgebrochen."; exit 0; }
|
||||
|
||||
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
|
||||
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
|
||||
@@ -75,6 +106,38 @@ LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
|
||||
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
|
||||
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
|
||||
|
||||
# Linux-Paket: Binary + Dienst-Installer + Unit + .env-Vorlage in EINER Zip —
|
||||
# entpacken, .env anlegen, optional als Dienst installieren.
|
||||
LINUX_PKG_NAME="aria-host-agent-linux-x64-v$VERSION"
|
||||
LINUX_ZIP="$SCRIPT_DIR/dist/$LINUX_PKG_NAME.zip"
|
||||
PKG_TMP="$(mktemp -d)"
|
||||
mkdir -p "$PKG_TMP/$LINUX_PKG_NAME"
|
||||
install -m 0755 "$LINUX_BIN" "$PKG_TMP/$LINUX_PKG_NAME/aria-host-agent"
|
||||
install -m 0755 install-service.sh "$PKG_TMP/$LINUX_PKG_NAME/install-service.sh"
|
||||
install -m 0644 aria-host-agent.service "$PKG_TMP/$LINUX_PKG_NAME/aria-host-agent.service"
|
||||
install -m 0644 .env.example "$PKG_TMP/$LINUX_PKG_NAME/.env.example"
|
||||
cat > "$PKG_TMP/$LINUX_PKG_NAME/LIESMICH.txt" <<EOF
|
||||
ARIA Host-Agent $VERSION (Linux x86_64)
|
||||
|
||||
1) Zugangsdaten eintragen:
|
||||
cp .env.example .env && nano .env (RVS_HOST, RVS_PORT, RVS_TOKEN, ...)
|
||||
|
||||
2a) Einfach starten (laeuft, solange das Terminal offen ist):
|
||||
./aria-host-agent
|
||||
|
||||
2b) ODER als Dienst installieren (startet automatisch mit dem Rechner):
|
||||
sudo ./install-service.sh ./.env
|
||||
Danach: systemctl status aria-host-agent · journalctl -u aria-host-agent -f
|
||||
Update: neue Zip entpacken, nochmal sudo ./install-service.sh /etc/aria-host-agent/.env
|
||||
|
||||
Screenshots brauchen eine grafische Sitzung — als Dienst gehen sie nur mit
|
||||
DISPLAY/XAUTHORITY in der Unit (siehe aria-host-agent.service).
|
||||
EOF
|
||||
rm -f "$LINUX_ZIP"
|
||||
( cd "$PKG_TMP" && zip -qr "$LINUX_ZIP" "$LINUX_PKG_NAME" )
|
||||
rm -rf "$PKG_TMP"
|
||||
echo -e " ${GREEN}✓${NC} Linux-Paket $LINUX_PKG_NAME.zip ($(du -h "$LINUX_ZIP" | cut -f1))"
|
||||
|
||||
( cd android && ./build.sh >/dev/null )
|
||||
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
|
||||
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
|
||||
@@ -86,18 +149,20 @@ if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
|
||||
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
|
||||
else
|
||||
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
|
||||
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
|
||||
WIN_LOG="$SCRIPT_DIR/dist/build-win.log"
|
||||
if ./build-win.sh "$VERSION" >"$WIN_LOG" 2>&1; then
|
||||
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
|
||||
else
|
||||
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
|
||||
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
|
||||
echo -e " ${YELLOW}Letzte Zeilen (ganzes Log: $WIN_LOG):${NC}"
|
||||
tail -n 8 "$WIN_LOG" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
|
||||
ASSETS=(
|
||||
"$LINUX_BIN::aria-host-agent-linux-x64"
|
||||
"$LINUX_ZIP::$LINUX_PKG_NAME.zip"
|
||||
"$APK::aria-host-agent-android-$TAG.apk"
|
||||
)
|
||||
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
|
||||
@@ -143,6 +208,21 @@ for entry in "${ASSETS[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Auto-Update: Android-APK auf den RVS (eigener Ordner updates/agent/) ──
|
||||
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
|
||||
if [ -n "$RVS_UPDATE_HOST" ]; then
|
||||
echo -e "\n${GREEN}[+] Android-APK auf den RVS kopieren (Auto-Update)...${NC}"
|
||||
if ssh "$RVS_UPDATE_HOST" "mkdir -p ~/ARIA-AGENT/rvs/updates/agent && rm -f ~/ARIA-AGENT/rvs/updates/agent/*.apk" 2>/dev/null \
|
||||
&& scp "$APK" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} Agents bekommen v$VERSION beim naechsten Verbinden angeboten"
|
||||
else
|
||||
echo -e " ${YELLOW}Kopieren fehlgeschlagen (RVS_UPDATE_HOST=$RVS_UPDATE_HOST). Manuell:${NC}"
|
||||
echo -e " ${YELLOW}scp $APK $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "\n${YELLOW}Auto-Update uebersprungen (RVS_UPDATE_HOST nicht gesetzt)${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
||||
|
||||
@@ -99,7 +99,20 @@ function _parseToolCalls(text) {
|
||||
return { tool_calls: tcs, rest: restParts.join("").trim() };
|
||||
}
|
||||
|
||||
export function cliResultToOpenai(result, requestId) {
|
||||
// Laengere Texte, die Claude im Turn VOR der letzten Antwort geschrieben hat
|
||||
// (z.B. ein Bericht, danach noch Warten/Werkzeuge, dann eine kurze
|
||||
// Schlussnotiz). result.result enthaelt nur den LETZTEN Textblock — der
|
||||
// Bericht ging so verloren (28.10., 2-Stunden-Turn). Kurze Zwischensaetze
|
||||
// ("Ich schaue nach …") bleiben weg, Sprachausgabe nur aus der letzten Antwort.
|
||||
const EARLIER_TEXT_MIN = 300;
|
||||
function _mergeEarlier(finalText, earlier) {
|
||||
const keep = (earlier || [])
|
||||
.map(t => String(t || "").replace(/<voice>[\s\S]*?<\/voice>/g, "").replace(/\[\[[A-Z_]+\]\]/g, "").trim())
|
||||
.filter(t => t.length >= EARLIER_TEXT_MIN && !finalText.includes(t));
|
||||
return keep.length ? keep.join("\n\n") + "\n\n" + finalText : finalText;
|
||||
}
|
||||
|
||||
export function cliResultToOpenai(result, requestId, earlierTexts = []) {
|
||||
// modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
|
||||
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version
|
||||
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call
|
||||
@@ -121,7 +134,7 @@ export function cliResultToOpenai(result, requestId) {
|
||||
message.content = rest || null;
|
||||
finishReason = "tool_calls";
|
||||
} else {
|
||||
message.content = rawText;
|
||||
message.content = _mergeEarlier(rawText, earlierTexts);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+59
-2
@@ -118,7 +118,52 @@ function _emitToolEvent(toolName, projectId) {
|
||||
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
|
||||
*/
|
||||
function _emitStreamEvent(requestId, kind, fields) {
|
||||
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
|
||||
const ev = { requestId, kind, ts: Date.now(), ...fields };
|
||||
_postJson(STREAM_HOOK_URL, ev);
|
||||
_persistToolEvent(ev);
|
||||
}
|
||||
|
||||
// ── Werkzeug-Protokoll (Beleg) ────────────────────────────────
|
||||
// Was ARIA SELBST im Proxy ausfuehrt (Bash/ssh, Read, Edit, …) war bisher
|
||||
// nur live sichtbar. Jetzt zusaetzlich dauerhaft: eine JSONL-Datei pro Tag
|
||||
// unter /shared/logs/aria-tools/. Die Diagnostic zeigt pro ARIA-Antwort die
|
||||
// zugehoerigen Aufrufe ("Belege"). Nur start/tool_use/tool_result/end —
|
||||
// keine Text-Deltas. Fail-open: ein Schreibfehler stoert ARIA nie.
|
||||
const TOOL_LOG_DIR = process.env.ARIA_TOOL_LOG_DIR || "/shared/logs/aria-tools";
|
||||
const TOOL_LOG_KINDS = new Set(["start", "tool_use", "tool_result", "text", "end"]);
|
||||
const TOOL_LOG_KEEP_DAYS = 30;
|
||||
let _toolLogCleaned = "";
|
||||
// Offensichtliche Geheimnisse nicht dauerhaft ablegen (Passwoerter, Tokens,
|
||||
// API-Keys, private Schluessel) — Ausgaben wie "cat .env" landen sonst im Log.
|
||||
const SECRET_PATTERNS = [
|
||||
[/(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g, "$1 *** $2"],
|
||||
[/((?:pass(?:word|wd)?|token|secret|api[_-]?key|auth|credential)[\w-]*["']?\s*[=:]\s*["']?)[^\s"',;]{4,}/gi, "$1***"],
|
||||
[/\b(hf_|ghp_|gho_|github_pat_|sk-|xox[bp]-|glpat-)[A-Za-z0-9_-]{8,}/g, "$1***"],
|
||||
[/(https?:\/\/[^\s:@\/]+:)[^\s@\/]+@/g, "$1***@"],
|
||||
];
|
||||
function _redact(v) {
|
||||
if (typeof v !== "string") return v;
|
||||
for (const [re, rep] of SECRET_PATTERNS) v = v.replace(re, rep);
|
||||
return v;
|
||||
}
|
||||
function _persistToolEvent(ev) {
|
||||
if (!TOOL_LOG_KINDS.has(ev.kind)) return;
|
||||
try {
|
||||
ev = { ...ev, input: _redact(ev.input), content: _redact(ev.content),
|
||||
text: typeof ev.text === "string" ? _redact(ev.text).slice(0, 20000) : ev.text };
|
||||
const day = new Date(ev.ts).toISOString().slice(0, 10);
|
||||
fs.mkdirSync(TOOL_LOG_DIR, { recursive: true });
|
||||
fs.appendFile(`${TOOL_LOG_DIR}/${day}.jsonl`, JSON.stringify(ev) + "\n", () => {});
|
||||
if (_toolLogCleaned !== day) { // einmal am Tag: alte Tage weg
|
||||
_toolLogCleaned = day;
|
||||
const cutoff = new Date(Date.now() - TOOL_LOG_KEEP_DAYS * 86400000).toISOString().slice(0, 10);
|
||||
for (const f of fs.readdirSync(TOOL_LOG_DIR)) {
|
||||
if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) < cutoff) {
|
||||
try { fs.unlinkSync(`${TOOL_LOG_DIR}/${f}`); } catch (_) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) { /* fail-open */ }
|
||||
}
|
||||
|
||||
function _truncate(str, max) {
|
||||
@@ -442,6 +487,16 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
|
||||
subprocess.on("result", (result) => {
|
||||
finalResult = result;
|
||||
});
|
||||
// Alle Textbloecke des Turns sammeln — die letzte Antwort allein
|
||||
// (result.result) verliert sonst einen frueher geschriebenen Bericht.
|
||||
const turnTexts = [];
|
||||
subprocess.on("assistant", (message) => {
|
||||
try {
|
||||
for (const b of message?.message?.content || []) {
|
||||
if (b && b.type === "text" && b.text) turnTexts.push(b.text);
|
||||
}
|
||||
} catch (_) { }
|
||||
});
|
||||
subprocess.on("error", (error) => {
|
||||
console.error("[NonStreaming] Error:", error.message);
|
||||
isComplete = true;
|
||||
@@ -464,7 +519,9 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
|
||||
return;
|
||||
}
|
||||
if (finalResult) {
|
||||
res.json(cliResultToOpenai(finalResult, requestId));
|
||||
const last = String(finalResult.result || "").trim();
|
||||
const earlier = turnTexts.filter(t => t.trim() && t.trim() !== last);
|
||||
res.json(cliResultToOpenai(finalResult, requestId, earlier));
|
||||
}
|
||||
else if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
|
||||
+33
-2
@@ -2,7 +2,8 @@
|
||||
# ════════════════════════════════════════════════
|
||||
# ARIA — Release Script
|
||||
# Baut APK, taggt, erstellt Gitea Release
|
||||
# Verwendung: ./release.sh 1.2.0
|
||||
# Verwendung: ./release.sh (Version automatisch +1)
|
||||
# ./release.sh 0.3.0.0 (Version selbst setzen)
|
||||
# Gitea-Kennwort wird interaktiv abgefragt.
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
@@ -16,8 +17,38 @@ CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ── Parameter ─────────────────────────────────
|
||||
VERSION=${1:?"Usage: ./release.sh <version> (z.B. 1.2.0)"}
|
||||
# Ohne Angabe: letzte Stelle automatisch hochzaehlen. Mit Angabe: genau diese.
|
||||
|
||||
# Naechste Version: hoechste aus Datei-Version und letztem Git-Tag, dann wie
|
||||
# ein Kilometerzaehler +1: hintere Stellen laufen 0..9 und uebertragen nach
|
||||
# vorne (0.2.6.9 → 0.2.7.0, 0.2.9.9 → 0.3.0.0), die vorderste zaehlt einfach
|
||||
# weiter (9.9.9.9 → 10.0.0.0). Aufruf: next_version <aktuelle> <tag-prefix>
|
||||
next_version() {
|
||||
local cur="$1" prefix="$2" tagv best
|
||||
tagv=$(git tag -l "${prefix}*" | sed "s/^${prefix}//" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
|
||||
best=$(printf '%s\n%s\n' "$cur" "$tagv" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
|
||||
[ -z "$best" ] && best="0.0.0.0"
|
||||
awk -F. -v OFS=. '{
|
||||
for (i = NF; i >= 1; i--) {
|
||||
if (i == 1 || $i < 9) { $i = $i + 1; break }
|
||||
$i = 0 # 9 → 0, Uebertrag nach vorne
|
||||
}
|
||||
print
|
||||
}' <<< "$best"
|
||||
}
|
||||
|
||||
CURRENT=$(grep -m1 '"version"' android/package.json | sed -E 's/.*"version": *"([^"]*)".*/\1/')
|
||||
VERSION=${1:-$(next_version "$CURRENT" "v")}
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
|
||||
echo -e "${RED}Ungueltige Version: $VERSION (z.B. 0.2.6.2)${NC}"; exit 1
|
||||
fi
|
||||
TAG="v$VERSION"
|
||||
if git rev-parse "$TAG" &>/dev/null; then
|
||||
echo -e "${RED}Tag $TAG gibt es schon — andere Version angeben.${NC}"; exit 1
|
||||
fi
|
||||
echo -e "${CYAN}App-Version: $CURRENT → ${GREEN}$VERSION${NC}"
|
||||
read -p "Release $VERSION bauen? [J/n] " OK
|
||||
[[ "$OK" =~ ^[nN] ]] && { echo "Abgebrochen."; exit 0; }
|
||||
|
||||
# ── Gitea-Konfiguration ──────────────────────
|
||||
# Aus .env lesen falls vorhanden (GITEA_URL, GITEA_REPO, GITEA_USER)
|
||||
|
||||
+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)
|
||||
|
||||
+229
-9
@@ -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,15 @@ 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";
|
||||
// Host-Agent (Android) hat einen EIGENEN Ordner — sonst bekaeme die Cockpit-App
|
||||
// die Agent-APK angeboten (die Versions-Erkennung schaut nur auf den Dateinamen).
|
||||
const AGENT_UPDATES_DIR = path.join(UPDATES_DIR, "agent");
|
||||
// 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
|
||||
@@ -16,7 +27,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
|
||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||
"update_check", "update_available", "update_download", "update_data",
|
||||
"update_check", "update_available", "update_none", "update_download", "update_data",
|
||||
"agent_activity", "cancel_request", "interject",
|
||||
"audio_pcm",
|
||||
"file_from_aria",
|
||||
@@ -133,6 +144,15 @@ const ALLOWED_TYPES = new Set([
|
||||
// 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",
|
||||
// Host-Agent (Android): Update-Pruefung, beantwortet der RVS selbst
|
||||
"agent_update_check", "agent_update_available",
|
||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||
"node_stats_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
@@ -168,6 +188,9 @@ const TARGETED_REPLIES = new Set(TARGETED.values());
|
||||
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
|
||||
const pendingReplies = new Map();
|
||||
const PENDING_TTL_MS = 180_000;
|
||||
// Antworten bis zu dieser Groesse gehen an den Raum, wenn der Fragende nur
|
||||
// neu verbunden ist (siehe Weiterleitung). Dateien sind groesser.
|
||||
const REJOIN_BROADCAST_MAX = 2 * 1024 * 1024;
|
||||
|
||||
function pendingKey(replyType, requestId) {
|
||||
return `${replyType}\u0000${requestId}`;
|
||||
@@ -270,7 +293,25 @@ function cleanupRooms() {
|
||||
// WICHTIG: Node-Default-Heap ist ~1.5 GB. Fuer 1 GB-Files muss der
|
||||
// Container mit --max-old-space-size=4096 (oder NODE_OPTIONS env var)
|
||||
// gestartet werden, sonst OOM-Crash beim Empfang.
|
||||
const httpServer = http.createServer(handleHttpRequest);
|
||||
/** Zertifikat fuer den internen HTTPS-Port: vorhandenes aus TLS_DIR nehmen
|
||||
* (eigenes darf man dort ablegen), sonst einmalig selbstsigniert erzeugen. */
|
||||
function loadInternalTls() {
|
||||
const key = path.join(TLS_DIR, "rvs.key");
|
||||
const crt = path.join(TLS_DIR, "rvs.crt");
|
||||
if (!fs.existsSync(key) || !fs.existsSync(crt)) {
|
||||
fs.mkdirSync(TLS_DIR, { recursive: true });
|
||||
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256",
|
||||
"-days", "3650", "-subj", "/CN=aria-rvs-intern", "-keyout", key, "-out", crt],
|
||||
{ stdio: "ignore" });
|
||||
fs.chmodSync(key, 0o600);
|
||||
log(`Internes TLS-Zertifikat erzeugt: ${crt} (selbstsigniert, 10 Jahre)`);
|
||||
}
|
||||
return { key: fs.readFileSync(key), cert: fs.readFileSync(crt) };
|
||||
}
|
||||
|
||||
const httpServer = HTTP_TLS
|
||||
? https.createServer(loadInternalTls(), handleHttpRequest)
|
||||
: http.createServer(handleHttpRequest);
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 1500 * 1024 * 1024 });
|
||||
|
||||
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
|
||||
@@ -281,7 +322,7 @@ httpServer.on("upgrade", (req, socket, head) => {
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`);
|
||||
log(`RVS läuft auf Port ${PORT} (${HTTP_TLS ? "HTTPS + WSS, internes Zertifikat" : "HTTP + WS"}) | Max Sessions: ${MAX_SESSIONS}`);
|
||||
// Beim Start pruefen ob eine APK da ist
|
||||
const apkInfo = getLatestAPK();
|
||||
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
@@ -370,6 +411,23 @@ function handleHttpRequest(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Updates als normaler HTTP-Download (statt eines riesigen WS-Blocks):
|
||||
// Cockpit-App unter /update/latest.apk, Host-Agent (Android) unter
|
||||
// /update/agent/latest.apk. Nur mit gueltigem Raum-Token (X-RVS-Token).
|
||||
if ((req.method === "GET" || req.method === "HEAD")
|
||||
&& (pathname === "/update/latest.apk" || pathname === "/update/agent/latest.apk")) {
|
||||
const tok = String(req.headers["x-rvs-token"] || "");
|
||||
if (!tok || !rooms.has(tok)) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||
res.end("Forbidden\n");
|
||||
return;
|
||||
}
|
||||
const isAgent = pathname.startsWith("/update/agent/");
|
||||
serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(),
|
||||
isAgent ? "aria-host-agent" : "ARIA");
|
||||
return;
|
||||
}
|
||||
|
||||
// Health-Endpoint
|
||||
if (req.method === "GET" && pathname === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -392,6 +450,11 @@ function escapeHtml(s) {
|
||||
}
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
// Fuer die Raum-Liste: Herkunft (hinter einem Reverse-Proxy steht die echte
|
||||
// Adresse in X-Forwarded-For) und Programm-Kennung
|
||||
const xff = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
||||
ws._ip = (xff || req.socket.remoteAddress || "").replace(/^::ffff:/, "");
|
||||
ws._ua = String(req.headers["user-agent"] || "").slice(0, 120);
|
||||
// Token aus URL-Query lesen: ws://host:port/?token=abc123
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
let token = url.searchParams.get("token");
|
||||
@@ -416,6 +479,81 @@ wss.on("connection", (ws, req) => {
|
||||
registerClient(ws, token);
|
||||
});
|
||||
|
||||
/** Wer steckt hinter einer Verbindung? Aus Nachrichten, die ohnehin fliessen
|
||||
* (sat_hello, host_hello, worker_hello, update_check der App, Cluster-
|
||||
* Anfragen) oder aus einem expliziten client_hello. Nur zur Anzeige. */
|
||||
function learnIdentity(ws, msg) {
|
||||
const p = msg.payload || {};
|
||||
const id = ws._ident;
|
||||
const set = (kind, name, detail, version) => {
|
||||
// client_hello gewinnt; abgeleitete Angaben ueberschreiben es nicht
|
||||
if (id.explicit && msg.type !== "client_hello") return;
|
||||
id.kind = kind;
|
||||
id.name = String(name || "").slice(0, 80);
|
||||
id.detail = String(detail || "").slice(0, 120);
|
||||
if (version) id.version = String(version).slice(0, 40);
|
||||
};
|
||||
switch (msg.type) {
|
||||
case "client_hello":
|
||||
id.explicit = true;
|
||||
set(p.kind || "Client", p.name, p.detail, p.version);
|
||||
break;
|
||||
case "sat_hello":
|
||||
set("Satellit", p.id, p.location, "");
|
||||
break;
|
||||
case "host_hello":
|
||||
set("Host-Agent", p.name || p.hostId, p.os, p.version);
|
||||
break;
|
||||
case "worker_hello": {
|
||||
const svc = p.service || "?";
|
||||
if (!id.services) id.services = new Set();
|
||||
id.services.add(svc);
|
||||
set("KI-Box", p.node || p.instanceId, [...id.services].join(", ") + (p.gpus ? ` · GPU ${p.gpus}` : ""), "");
|
||||
break;
|
||||
}
|
||||
case "update_check":
|
||||
if (!id.kind) set("App", "", "", p.version);
|
||||
else if (p.version && !id.version) id.version = String(p.version).slice(0, 40);
|
||||
break;
|
||||
// Nachrichten, die nur die ARIA-App schickt — erkennt auch alte APKs
|
||||
// ohne client_hello (sobald sie irgendetwas davon senden)
|
||||
case "app_log":
|
||||
if (!id.kind && /^(android|ios)$/.test(String(p.platform || ""))) set("App", "", `${p.platform}`, "");
|
||||
break;
|
||||
case "location_update":
|
||||
case "stt_lease_request":
|
||||
case "update_download":
|
||||
case "vnc_open":
|
||||
if (!id.kind) set("App", "", "", "");
|
||||
break;
|
||||
case "llm_cluster_request":
|
||||
case "llm_cluster_models_request":
|
||||
if (!id.kind) {
|
||||
const rid = String(p.requestId || "");
|
||||
set("LLM-Client", rid.includes("-") ? rid.split("-")[0] : "", "", "");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function roomClients(ws) {
|
||||
const room = rooms.get(ws._token);
|
||||
const out = [];
|
||||
if (!room) return out;
|
||||
for (const c of room.clients) {
|
||||
if (c.readyState !== 1) continue;
|
||||
const id = c._ident || {};
|
||||
out.push({
|
||||
kind: id.kind || "", name: id.name || "", detail: id.detail || "",
|
||||
version: id.version || "", ip: c._ip || "", userAgent: c._ua || "",
|
||||
connectedAt: c._connectedAt || 0, lastSeen: c._lastSeen || 0,
|
||||
you: c === ws,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => (a.kind || "~").localeCompare(b.kind || "~") || a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
function registerClient(ws, token) {
|
||||
// Maximale Anzahl aktiver Sessions prüfen
|
||||
if (!rooms.has(token) && rooms.size >= MAX_SESSIONS) {
|
||||
@@ -433,6 +571,9 @@ function registerClient(ws, token) {
|
||||
const room = rooms.get(token);
|
||||
room.clients.add(ws);
|
||||
ws._token = token;
|
||||
ws._ident = {};
|
||||
ws._connectedAt = Date.now();
|
||||
ws._lastSeen = ws._connectedAt;
|
||||
|
||||
log(`Client verbunden: ${token.slice(0, 8)}... (${room.clients.size} im Raum)`);
|
||||
|
||||
@@ -449,6 +590,18 @@ function registerClient(ws, token) {
|
||||
if (!msg.type || !ALLOWED_TYPES.has(msg.type)) {
|
||||
return;
|
||||
}
|
||||
ws._lastSeen = Date.now();
|
||||
try { learnIdentity(ws, msg); } catch (_) {}
|
||||
|
||||
// Vorstellung bleibt beim RVS, Raum-Liste nur an den Fragenden
|
||||
if (msg.type === "client_hello") return;
|
||||
if (msg.type === "room_clients_query") {
|
||||
ws.send(JSON.stringify({ type: "room_clients", payload: {
|
||||
requestId: (msg.payload && msg.payload.requestId) || "", clients: roomClients(ws),
|
||||
}, timestamp: Date.now() }));
|
||||
return;
|
||||
}
|
||||
if (msg.type === "room_clients") return; // kommt nur vom RVS
|
||||
|
||||
// Update-Check: direkt an den anfragenden Client antworten (nicht relay'en)
|
||||
if (msg.type === "update_check") {
|
||||
@@ -464,6 +617,29 @@ function registerClient(ws, token) {
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} else {
|
||||
// Auch "nichts Neues" beantworten — die App zeigt dann, dass der
|
||||
// Update-Server erreichbar ist und welche Version er hat
|
||||
ws.send(JSON.stringify({
|
||||
type: "update_none",
|
||||
payload: { latest: apkInfo ? apkInfo.version : "", current: clientVersion },
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Host-Agent (Android) fragt nach einem Update — direkt antworten
|
||||
if (msg.type === "agent_update_check") {
|
||||
const have = String(msg.payload?.version || "0.0.0.0");
|
||||
const apk = getLatestAPK(AGENT_UPDATES_DIR);
|
||||
if (apk && compareVersions(apk.version, have) > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "agent_update_available",
|
||||
payload: { version: apk.version, downloadUrl: "/update/agent/latest.apk",
|
||||
size: fs.statSync(apk.path).size },
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -523,7 +699,16 @@ function registerClient(ws, token) {
|
||||
rememberRequester(ws, msg);
|
||||
|
||||
// Gezielte Antwort? Dann NUR an den Fragenden — nicht an den ganzen Raum.
|
||||
const targets = targetedRecipients(msg);
|
||||
let targets = targetedRecipients(msg);
|
||||
// Fragender inzwischen neu verbunden (RVS-/Proxy-Neustart waehrend die
|
||||
// Antwort noch lief): seine alte Verbindung gibt es nicht mehr, die neue
|
||||
// kennen wir nicht. Kleine Antworten (LLM-Antwort, Status) dann an den
|
||||
// Raum — der Fragende erkennt sie an der requestId. Grosse (Dateien)
|
||||
// bleiben verworfen, sonst gingen hunderte MB an alle.
|
||||
if (targets && targets.length === 0 && raw.length <= REJOIN_BROADCAST_MAX) {
|
||||
log(`${msg.type}: Fragender neu verbunden — ${(raw.length / 1024).toFixed(0)}KB an den Raum`);
|
||||
targets = null;
|
||||
}
|
||||
if (targets) {
|
||||
const data = raw.toString();
|
||||
let sent = 0;
|
||||
@@ -608,15 +793,50 @@ wss.on("close", () => {
|
||||
|
||||
let latestVersion = null;
|
||||
|
||||
function getLatestAPK() {
|
||||
/** APK per HTTP ausliefern: Content-Length (Fortschritt), Range (Fortsetzen). */
|
||||
function serveApk(req, res, apk, label) {
|
||||
if (!apk) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Keine APK verfuegbar\n");
|
||||
return;
|
||||
}
|
||||
const size = fs.statSync(apk.path).size;
|
||||
const headers = {
|
||||
"Content-Type": "application/vnd.android.package-archive",
|
||||
"Content-Disposition": `attachment; filename="${label}-v${apk.version}.apk"`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"X-APK-Version": apk.version,
|
||||
};
|
||||
let start = 0, end = size - 1, status = 200;
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ""));
|
||||
if (m && (m[1] || m[2])) {
|
||||
if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); }
|
||||
else { start = Math.max(0, size - parseInt(m[2], 10)); }
|
||||
if (start > end || start >= size) {
|
||||
res.writeHead(416, { "Content-Range": `bytes */${size}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
status = 206;
|
||||
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
|
||||
}
|
||||
headers["Content-Length"] = end - start + 1;
|
||||
res.writeHead(status, headers);
|
||||
if (req.method === "HEAD") { res.end(); return; }
|
||||
log(`APK-Download (HTTP) ${label} v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
|
||||
fs.createReadStream(apk.path, { start, end }).pipe(res);
|
||||
}
|
||||
|
||||
function getLatestAPK(dir = UPDATES_DIR) {
|
||||
try {
|
||||
if (!fs.existsSync(UPDATES_DIR)) return null;
|
||||
const files = fs.readdirSync(UPDATES_DIR)
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith(".apk"))
|
||||
.map(f => {
|
||||
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
||||
const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/);
|
||||
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
|
||||
const match = f.match(/(\d+(?:\.\d+){2,})/);
|
||||
return { file: f, path: path.join(dir, f), version: match ? match[1] : null };
|
||||
})
|
||||
.filter(f => f.version)
|
||||
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
|
||||
|
||||
@@ -40,6 +40,14 @@ CONTROL_ENABLED=true
|
||||
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
|
||||
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
|
||||
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
|
||||
# fritzbox.describe FritzBox: was DIESE Box kann (alle TR-064-Aktionen, ohne Login)
|
||||
# fritzbox.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true
|
||||
# fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten
|
||||
# fritzbox.backup / backups / backup_view / restore / backup_delete
|
||||
# FritzBox: Einstellungen sichern (Taste an der Box, 2 min),
|
||||
# auflisten, lesbar ansehen, wieder einspielen, loeschen.
|
||||
# Sicherungen liegen unter FB_BACKUP_DIR (/data/fritzbox-backups).
|
||||
# (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen)
|
||||
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
|
||||
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
|
||||
# adb.connect ADB-Verbindung aufbauen (params: ip)
|
||||
@@ -52,7 +60,7 @@ CONTROL_ENABLED=true
|
||||
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
|
||||
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
|
||||
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.*,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
|
||||
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
|
||||
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
|
||||
|
||||
+902
-64
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,33 @@ GPU-Test + Stack-Start.
|
||||
| `--up` | am Ende `docker compose up -d --build` (Default-Profil) |
|
||||
| `--token <TOK>` | `RVS_TOKEN` in `xtts/.env` eintragen (auch via `RVS_TOKEN=…` env) |
|
||||
| `--rvs-host <H>` | `RVS_HOST` setzen |
|
||||
| `--upgrade-driver` | NVIDIA-Treiber aus **NVIDIAs** Paketquelle für Debian 13 (siehe unten). Spielt erst durch, zeigt den Plan, fragt nach. Danach Reboot. |
|
||||
| `--yes` | bei `--upgrade-driver` nicht nachfragen |
|
||||
|
||||
## Treiber für das lokale LLM: `--upgrade-driver`
|
||||
|
||||
Debian hat höchstens Treiber **550** (CUDA 12.4). Die aktuellen llama.cpp- und
|
||||
llama-swap-Images brauchen CUDA 12.8/12.9. Mit 550 meldet llama.cpp beim Start
|
||||
`failed to initialize CUDA … forward compatibility …`, findet **keine** Karte und
|
||||
rechnet still auf der **CPU** — das Modell meldet trotzdem „bereit". Erkennbar
|
||||
daran, dass `nvidia-smi` auf der LLM-Karte kaum Speicher belegt zeigt.
|
||||
|
||||
```bash
|
||||
cd ARIA-AGENT/xtts
|
||||
sudo ./bootstrap.sh --upgrade-driver
|
||||
sudo reboot
|
||||
nvidia-smi # zeigt den neuen Treiber und eine CUDA-Version >= 12.9
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Das Script trägt NVIDIAs Paketquelle ein und aktualisiert Treiber, Kernelmodul,
|
||||
`nvidia-smi` und `libcuda1` an Ort und Stelle (gleiche Paketnamen, neuere
|
||||
Version). Alte Debian-Teilpakete, die in den neuen aufgegangen sind, werden
|
||||
entfernt. Das **NVIDIA-Container-Toolkit bleibt unberührt** — würde apt es
|
||||
anfassen wollen, bricht das Script ab, ohne etwas zu ändern. Ebenso, wenn apt
|
||||
keinen sauberen Weg findet; die Paketquelle wird dann wieder entfernt.
|
||||
Whisper und F5-TTS laufen mit dem neuen Treiber weiter (neuere Treiber können
|
||||
ältere CUDA-Versionen).
|
||||
|
||||
## Wichtig
|
||||
|
||||
|
||||
+188
-33
@@ -11,8 +11,13 @@
|
||||
#
|
||||
# Optionen:
|
||||
# --up am Ende den xtts-Stack starten (Default-Profil, OHNE voxtral)
|
||||
# --upgrade-driver NVIDIA-Treiber aus trixie-backports (modernes CUDA fuer Voxtral).
|
||||
# Danach REBOOT noetig. Default-Bootstrap laesst 550 unangetastet.
|
||||
# --upgrade-driver NVIDIA-Treiber aus NVIDIAs eigener Paketquelle fuer Debian 13
|
||||
# (Debian selbst hat max. 550 = CUDA 12.4 — zu alt fuer die
|
||||
# aktuellen llama.cpp/llama-swap-Images, die CUDA 12.8/12.9
|
||||
# brauchen; ohne Upgrade rechnet das LLM still auf der CPU).
|
||||
# Spielt erst durch, zeigt den Plan, fragt nach. Danach REBOOT.
|
||||
# Default-Bootstrap laesst den Treiber unangetastet.
|
||||
# --yes bei --upgrade-driver nicht nachfragen
|
||||
# --token <TOK> RVS_TOKEN in xtts/.env eintragen (alternativ: env RVS_TOKEN=...)
|
||||
# --rvs-host <H> RVS_HOST setzen (Default aus .env.example)
|
||||
#
|
||||
@@ -24,12 +29,14 @@ set -euo pipefail
|
||||
# ── CLI ──
|
||||
DO_UP=0
|
||||
DO_UPGRADE_DRIVER=0
|
||||
ASSUME_YES=0
|
||||
RVS_TOKEN_ARG="${RVS_TOKEN:-}"
|
||||
RVS_HOST_ARG="${RVS_HOST:-}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--up) DO_UP=1; shift ;;
|
||||
--upgrade-driver) DO_UPGRADE_DRIVER=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;;
|
||||
--rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
@@ -94,50 +101,198 @@ fi
|
||||
[[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv"
|
||||
apt-get update -qq # immer neu einlesen, damit der Kandidat sicher da ist
|
||||
|
||||
# ── 2b. (opt-in) Treiber-Upgrade via trixie-backports — fuer Voxtral/modernes CUDA ──
|
||||
# Der Trixie-Standardtreiber (550, CUDA 12.4) ist zu alt fuer den modernen Stack
|
||||
# (torch 2.13, vLLM). Backports bringt einen neueren, apt-verwalteten Treiber.
|
||||
# ── 2b. (opt-in) Treiber-Upgrade ueber NVIDIAs Paketquelle fuer Debian 13 ──
|
||||
# Debian (auch trixie-backports) hat hoechstens 550 = CUDA 12.4. Die aktuellen
|
||||
# llama.cpp-/llama-swap-Images brauchen CUDA 12.8/12.9 → mit 550 meldet
|
||||
# llama.cpp "failed to initialize CUDA … forward compatibility …", findet keine
|
||||
# Karte und rechnet STILL auf der CPU ("ready" meldet es trotzdem).
|
||||
# NVIDIAs Quelle hat die Pakete unter denselben Namen, nur neuer — apt
|
||||
# aktualisiert an Ort und Stelle. Alte Debian-Teilpakete (nvidia-driver-bin,
|
||||
# nvidia-alternative, …) sind in den neuen aufgegangen und werden entfernt.
|
||||
# Das Container-Toolkit bleibt unberuehrt — ohne es saehe kein Container die GPU,
|
||||
# darum bricht das Script ab, falls apt es anfassen wollte.
|
||||
# NUR mit --upgrade-driver, damit ein laufendes Setup nicht ungewollt angefasst wird.
|
||||
if [[ "$DO_UPGRADE_DRIVER" -eq 1 ]]; then
|
||||
step "NVIDIA-Treiber-Upgrade (trixie-backports)"
|
||||
BP_FILE="/etc/apt/sources.list.d/backports.sources"
|
||||
if ! grep -rqs "trixie-backports" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
|
||||
cat > "$BP_FILE" <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian
|
||||
Suites: trixie-backports
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
ok "trixie-backports hinzugefuegt"
|
||||
else
|
||||
ok "trixie-backports bereits aktiv"
|
||||
NV_REPO="https://developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64"
|
||||
MIN_DRIVER_MAJOR=575 # CUDA 12.9 braucht mindestens Treiber 575
|
||||
|
||||
# Nachbereitung nach dem Wechsel auf NVIDIAs Pakete. Zwei Stolperfallen, die
|
||||
# erst bei der echten Installation sichtbar wurden (nicht in apt -s):
|
||||
# 1. NVIDIAs Paket "nvidia-smi" ist nur noch ein leeres Doku-Paket — das
|
||||
# Programm (und Bibliotheken, die das Container-Toolkit braucht) steckt in
|
||||
# "nvidia-driver-cuda". Ohne: kein nvidia-smi, Container ohne GPU.
|
||||
# 2. /etc/modprobe.d/nvidia.conf (sperrt den freien nouveau-Treiber) war
|
||||
# vorher ein Verweis ueber das Alternativen-System. Die entfernten
|
||||
# Altpakete nehmen ihn mit, dpkg legt die neue Datei nur als .dpkg-new ab.
|
||||
# Ohne Sperre laedt nach dem Reboot nouveau statt nvidia.
|
||||
# Idempotent — repariert auch eine Box, die schon auf dem neuen Treiber ist.
|
||||
nv_postfix() {
|
||||
if ! command -v nvidia-smi >/dev/null 2>&1 && apt-cache show nvidia-driver-cuda >/dev/null 2>&1; then
|
||||
warn "nvidia-smi fehlt — installiere nvidia-driver-cuda (enthaelt es)"
|
||||
apt-get install -y nvidia-driver-cuda
|
||||
fi
|
||||
apt-get update
|
||||
apt-get install -y linux-headers-amd64 || true
|
||||
apt-get install -y "linux-headers-$(uname -r)" || true
|
||||
if apt-get install -y -t trixie-backports nvidia-driver; then
|
||||
find /etc/modprobe.d -maxdepth 1 -name 'nvidia*.conf' -xtype l -print -delete 2>/dev/null \
|
||||
| sed 's/^/ toter Verweis entfernt: /' || true
|
||||
if [[ ! -e /etc/modprobe.d/nvidia.conf && -e /etc/modprobe.d/nvidia.conf.dpkg-new ]]; then
|
||||
mv /etc/modprobe.d/nvidia.conf.dpkg-new /etc/modprobe.d/nvidia.conf
|
||||
ok "/etc/modprobe.d/nvidia.conf aktiviert (sperrt nouveau)"
|
||||
NV_INITRAMFS=1
|
||||
fi
|
||||
if ! grep -rqs "^blacklist nouveau" /etc/modprobe.d /usr/lib/modprobe.d /lib/modprobe.d; then
|
||||
mkdir -p /etc/modprobe.d
|
||||
if printf 'blacklist nouveau\nblacklist nova-core\n' > /etc/modprobe.d/aria-blacklist-nouveau.conf; then
|
||||
ok "nouveau gesperrt (/etc/modprobe.d/aria-blacklist-nouveau.conf)"
|
||||
NV_INITRAMFS=1
|
||||
else
|
||||
warn "nouveau-Sperre konnte nicht geschrieben werden — nach dem Reboot laedt evtl. nouveau statt nvidia"
|
||||
fi
|
||||
fi
|
||||
if [[ "${NV_INITRAMFS:-0}" -eq 1 ]] || lsmod | grep -q '^nouveau'; then
|
||||
if update-initramfs -u -k all >/dev/null 2>&1; then
|
||||
ok "initramfs neu gebaut (Sperre greift schon beim Booten)"
|
||||
else
|
||||
warn "update-initramfs fehlgeschlagen — bitte von Hand: update-initramfs -u -k all"
|
||||
fi
|
||||
NV_NEED_REBOOT=1
|
||||
fi
|
||||
}
|
||||
if [[ "$DO_UPGRADE_DRIVER" -eq 1 ]]; then
|
||||
step "NVIDIA-Treiber-Upgrade (NVIDIAs Paketquelle fuer Debian 13)"
|
||||
CUR="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$CUR" ]]; then CUR="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || true)"; fi
|
||||
CUR_MAJOR="${CUR%%.*}"
|
||||
if [[ "$CUR_MAJOR" =~ ^[0-9]+$ && "$CUR_MAJOR" -ge "$MIN_DRIVER_MAJOR" ]]; then
|
||||
ok "Treiber ${CUR} ist neu genug (>= ${MIN_DRIVER_MAJOR})"
|
||||
nv_postfix
|
||||
if lsmod | grep -q '^nouveau' || ! nvidia-smi >/dev/null 2>&1; then
|
||||
echo
|
||||
echo -e "${c_y}==> REBOOT noetig (nouveau ist noch geladen / Treiber noch nicht aktiv):${c_0}"
|
||||
echo -e "${c_y} sudo reboot → danach: nvidia-smi und cd xtts && docker compose up -d${c_0}"
|
||||
exit 0
|
||||
fi
|
||||
ok "nvidia-smi laeuft, nouveau nicht geladen — alles in Ordnung"
|
||||
else
|
||||
ok "aktuell: ${CUR:-kein Treiber} — Ziel: >= ${MIN_DRIVER_MAJOR}"
|
||||
KEYRING_NEW=0
|
||||
if ! dpkg -s cuda-keyring >/dev/null 2>&1; then
|
||||
curl -fsSL "$NV_REPO/cuda-keyring_1.1-1_all.deb" -o /tmp/cuda-keyring.deb \
|
||||
|| die "NVIDIA-Paketquelle nicht erreichbar ($NV_REPO)"
|
||||
dpkg -i /tmp/cuda-keyring.deb >/dev/null && KEYRING_NEW=1
|
||||
ok "NVIDIA-Paketquelle eingetragen"
|
||||
fi
|
||||
undo_keyring() { [[ "$KEYRING_NEW" -eq 1 ]] && dpkg -r cuda-keyring >/dev/null 2>&1 && apt-get update -qq || true; }
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq linux-headers-amd64 >/dev/null 2>&1 || true
|
||||
apt-get install -y -qq "linux-headers-$(uname -r)" >/dev/null 2>&1 || true
|
||||
|
||||
NV_PKGS="nvidia-driver nvidia-kernel-dkms libcuda1"
|
||||
# Bei NVIDIA steckt nvidia-smi in nvidia-driver-cuda (das Paket "nvidia-smi"
|
||||
# ist dort leer). Fallback fuer eine Quelle, die es anders schneidet.
|
||||
if apt-cache show nvidia-driver-cuda >/dev/null 2>&1; then
|
||||
NV_PKGS="$NV_PKGS nvidia-driver-cuda"
|
||||
else
|
||||
NV_PKGS="$NV_PKGS nvidia-smi"
|
||||
fi
|
||||
for p in nvidia-persistenced nvidia-modprobe; do
|
||||
if dpkg -s "$p" >/dev/null 2>&1; then NV_PKGS="$NV_PKGS $p"; fi
|
||||
done
|
||||
|
||||
# Erst durchspielen — aendert nichts.
|
||||
if ! SIM="$(apt-get -s install -y $NV_PKGS 2>&1)"; then
|
||||
echo "$SIM" | grep -E "^E:|Kollidiert|Hängt ab|Depends|Conflicts" | head -15 || true
|
||||
undo_keyring
|
||||
die "apt findet keinen sauberen Weg — NICHTS geaendert (Quelle wieder entfernt)."
|
||||
fi
|
||||
if echo "$SIM" | grep -qE '^(Remv|Inst) (nvidia-container|libnvidia-container)'; then
|
||||
undo_keyring
|
||||
die "Das Upgrade wuerde das NVIDIA-Container-Toolkit anfassen — abgebrochen, NICHTS geaendert."
|
||||
fi
|
||||
NEWV="$(echo "$SIM" | sed -nE 's/^Inst nvidia-driver (\[[^]]*\] )?\(([^ ]+).*/\2/p' | head -1 || true)"
|
||||
echo
|
||||
echo -e " Plan: Treiber ${CUR:-?} → ${c_g}${NEWV:-?}${c_0}"
|
||||
echo "$SIM" | grep -E "^[0-9]+ (aktualisiert|upgraded)" | head -1 | sed 's/^/ /' || true
|
||||
REMV="$(echo "$SIM" | sed -nE 's/^Remv ([^ ]+).*/\1/p' | paste -sd' ' || true)"
|
||||
if [[ -n "$REMV" ]]; then echo " entfernt (in den neuen Paketen aufgegangen): $REMV"; fi
|
||||
echo " Container-Toolkit: bleibt unberuehrt"
|
||||
echo " Danach ist ein REBOOT noetig (neues Kernelmodul)."
|
||||
if [[ "$ASSUME_YES" -ne 1 && -t 0 ]]; then
|
||||
read -r -p " Jetzt ausfuehren? [j/N] " ans
|
||||
if [[ ! "$ans" =~ ^[jJyY] ]]; then
|
||||
undo_keyring
|
||||
die "Abgebrochen — NICHTS geaendert."
|
||||
fi
|
||||
fi
|
||||
|
||||
apt-get install -y $NV_PKGS
|
||||
dkms autoinstall >/dev/null 2>&1 || true
|
||||
NEWV="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || echo '?')"
|
||||
ok "nvidia-driver aus backports installiert: ${NEWV}"
|
||||
nv_postfix
|
||||
GOT="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || echo '?')"
|
||||
[[ "${GOT%%.*}" =~ ^[0-9]+$ && "${GOT%%.*}" -ge "$MIN_DRIVER_MAJOR" ]] \
|
||||
|| die "Installiert ist ${GOT} — erwartet >= ${MIN_DRIVER_MAJOR}. Bitte Ausgabe oben pruefen."
|
||||
# -F: der Kernelname enthaelt "+" — als Regex gelesen passte er nie.
|
||||
# Geprueft wird jeder installierte Kernel (apt kann gerade einen neueren
|
||||
# mitgebracht haben, der beim Reboot startet).
|
||||
for k in /lib/modules/*/; do
|
||||
k="$(basename "$k")"
|
||||
if [[ -d "/usr/src/linux-headers-$k" ]]; then
|
||||
if dkms status 2>/dev/null | grep -F "$k" | grep -q installed; then
|
||||
ok "Kernelmodul fuer $k gebaut"
|
||||
else
|
||||
warn "Kernelmodul fuer $k NICHT gebaut — vor dem Reboot: dkms autoinstall -k $k"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
command -v nvidia-smi >/dev/null 2>&1 || warn "nvidia-smi fehlt nach der Installation — bitte melden"
|
||||
ok "nvidia-driver ${GOT} installiert"
|
||||
echo
|
||||
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
|
||||
echo -e "${c_y} sudo reboot && danach: cd xtts && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
|
||||
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
|
||||
echo -e "${c_y} sudo reboot${c_0}"
|
||||
echo -e "${c_y} danach: nvidia-smi (zeigt ${GOT%%-*} und eine CUDA-Version >= 12.9)${c_0}"
|
||||
echo -e "${c_y} und: cd xtts && docker compose up -d${c_0}"
|
||||
exit 0
|
||||
else
|
||||
warn "backports-Install fehlgeschlagen — 550er bleibt aktiv."
|
||||
warn "Alternative fuer den neuesten Treiber: NVIDIAs CUDA-Repo fuer debian13"
|
||||
warn " (developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64) → Paket 'cuda-drivers'."
|
||||
die "Treiber-Upgrade nicht moeglich — siehe oben."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2c. Docker wartet beim Booten auf DNS ──
|
||||
# Debian traegt die Netzwerkkarte als "allow-hotplug" ein — darauf wartet
|
||||
# network-online.target NICHT. Docker startet die Container dann, bevor DHCP den
|
||||
# DNS-Server geliefert hat ("DNS resolver has no external nameservers"), und die
|
||||
# Container behalten diesen leeren Stand: kein RVS, keine Verbindung, bis man
|
||||
# sie neu erzeugt. Passiert bei jedem Reboot.
|
||||
# Abhilfe ohne die Netzkonfiguration anzufassen: Docker wartet vor dem Start bis
|
||||
# zu 90 s, bis /etc/resolv.conf einen Nameserver hat.
|
||||
step "Docker wartet beim Booten auf DNS"
|
||||
DROPIN=/etc/systemd/system/docker.service.d/aria-wait-dns.conf
|
||||
if [[ -f "$DROPIN" ]]; then
|
||||
ok "schon eingerichtet ($DROPIN)"
|
||||
else
|
||||
mkdir -p "$(dirname "$DROPIN")"
|
||||
cat > "$DROPIN" <<'EOF'
|
||||
# ARIA bootstrap.sh: Container erst starten, wenn DNS da ist (sonst merken sie
|
||||
# sich "keine Nameserver" und erreichen den RVS nie). Siehe xtts/bootstrap.sh.
|
||||
[Unit]
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
[Service]
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 90); do grep -q "^nameserver" /etc/resolv.conf && exit 0; sleep 1; done; echo "kein DNS nach 90 s — starte trotzdem"; exit 0'
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
ok "eingerichtet — greift ab dem naechsten Boot"
|
||||
fi
|
||||
|
||||
# ── 3. NVIDIA-Treiber ──
|
||||
step "NVIDIA-Treiber"
|
||||
if nvidia-smi >/dev/null 2>&1; then
|
||||
ok "Treiber aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
|
||||
DRV="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1 || true)"
|
||||
ok "Treiber ${DRV} aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
|
||||
DRIVER_ACTIVE=1
|
||||
# Fuer das lokale LLM zu alt? Dann laeuft es still auf der CPU — das soll man
|
||||
# hier sehen, nicht erst an einer leeren Karte in nvidia-smi.
|
||||
PROFILES="$(grep -E '^COMPOSE_PROFILES=' "$XTTS_DIR/.env" 2>/dev/null | cut -d= -f2- || true)"
|
||||
if [[ "${DRV%%.*}" =~ ^[0-9]+$ && "${DRV%%.*}" -lt "$MIN_DRIVER_MAJOR" && ( -z "$PROFILES" || "$PROFILES" == *llm* ) ]]; then
|
||||
warn "Treiber ${DRV} ist zu alt fuer das lokale LLM (braucht >= ${MIN_DRIVER_MAJOR})."
|
||||
warn " Folge: llama.cpp findet keine Karte und rechnet auf der CPU."
|
||||
warn " Abhilfe: sudo ./bootstrap.sh --upgrade-driver (zeigt erst den Plan, fragt nach)"
|
||||
fi
|
||||
else
|
||||
if dpkg -l | grep -q '^ii nvidia-driver '; then
|
||||
warn "Treiber installiert, aber nvidia-smi antwortet nicht → REBOOT noetig."
|
||||
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
environment:
|
||||
# GPU-Wahl: NVIDIA_VISIBLE_DEVICES (mehrere via "0,1"). Default GPU 0.
|
||||
- NVIDIA_VISIBLE_DEVICES=${F5TTS_GPU:-0}
|
||||
- ARIA_GPU=${F5TTS_GPU:-0} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- NODE_NAME=${NODE_NAME:-node} # erscheint in Diagnostic + Logs
|
||||
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
|
||||
@@ -70,6 +71,7 @@ services:
|
||||
runtime: nvidia
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=${WHISPER_GPU:-1}
|
||||
- ARIA_GPU=${WHISPER_GPU:-1} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- NODE_NAME=${NODE_NAME:-node}
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
@@ -105,6 +107,7 @@ services:
|
||||
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=${VOXTRAL_GPU:-1} # STT-3B ~9 GB → 12-GB-Karte
|
||||
- ARIA_GPU=${VOXTRAL_GPU:-1} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- NODE_NAME=${NODE_NAME:-node}
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
@@ -141,6 +144,14 @@ services:
|
||||
# CUDA zaehlt Karten sonst "schnellste zuerst" — dann waere CUDA-Karte 0
|
||||
# nicht unbedingt nvidia-smi-Karte 0. PCI-Reihenfolge = wie nvidia-smi.
|
||||
- CUDA_DEVICE_ORDER=PCI_BUS_ID
|
||||
# Echten Treiber vom Host zuerst laden. Das Image traegt in seiner
|
||||
# ld.so.conf die eigene CUDA-Kompatibilitaets-libcuda (fuer Treiber 575)
|
||||
# VOR dem eingehaengten Host-Treiber ein. Die hilft nur Rechenzentrums-
|
||||
# karten; bei GeForce scheitert llama.cpp damit ("forward compatibility …"
|
||||
# bei aelterem, "unsupported display driver / cuda driver combination" bei
|
||||
# neuerem Treiber) und rechnet still auf der CPU. Auf der Box getestet:
|
||||
# so sieht llama.cpp beide Karten.
|
||||
- LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu:/usr/local/cuda/lib64
|
||||
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
|
||||
# Liest die vom llm-adapter generierte Config. --watch-config: Aenderungen
|
||||
# (Modell in Karte geladen/entladen) greifen von selbst — einen Reload-
|
||||
@@ -170,6 +181,7 @@ services:
|
||||
volumes:
|
||||
- ./models:/models # generierte Config + Registry + Cache
|
||||
- ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml)
|
||||
- /sys:/hostsys:ro # nur lesen: Tempo der Netzwerkkarte (Download-Fenster)
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=all # alle Karten sichtbar (nur nvidia-smi)
|
||||
- NVIDIA_DRIVER_CAPABILITIES=utility # utility = nvidia-smi, KEIN VRAM/Compute
|
||||
|
||||
@@ -78,7 +78,11 @@ F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
|
||||
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
|
||||
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
|
||||
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
|
||||
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
|
||||
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
|
||||
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
|
||||
# durch "void", taugt also nicht zur Anzeige.
|
||||
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
|
||||
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
|
||||
WORKER_SERVICE = "f5tts"
|
||||
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
|
||||
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
|
||||
|
||||
@@ -96,6 +96,31 @@ class NodeStats:
|
||||
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||
|
||||
async def _query_cards(self) -> list:
|
||||
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
|
||||
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
|
||||
Dienste einer Box sie melden."""
|
||||
out = await _run_cmd(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits")
|
||||
cards = []
|
||||
for line in out.strip().splitlines():
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 9:
|
||||
continue
|
||||
|
||||
def num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
|
||||
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
|
||||
"power": num(p[7]), "powerLimit": num(p[8])})
|
||||
return cards
|
||||
|
||||
async def _nvidia_smi_text(self) -> str:
|
||||
txt = await _run_cmd("nvidia-smi")
|
||||
return txt or "nvidia-smi nicht verfuegbar"
|
||||
@@ -128,9 +153,10 @@ class NodeStats:
|
||||
while time.time() - t0 < STREAM_MAX_SEC:
|
||||
g = await self._query_gpu()
|
||||
smi = await self._nvidia_smi_text()
|
||||
cards = await self._query_cards()
|
||||
await send(ws, "node_stats", {
|
||||
"instanceId": self.instance_id, "node": self.node_name,
|
||||
"nvidiaSmi": smi, **g,
|
||||
"nvidiaSmi": smi, "cards": cards, **g,
|
||||
})
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
+967
-51
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,31 @@ class NodeStats:
|
||||
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||
|
||||
async def _query_cards(self) -> list:
|
||||
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
|
||||
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
|
||||
Dienste einer Box sie melden."""
|
||||
out = await _run_cmd(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits")
|
||||
cards = []
|
||||
for line in out.strip().splitlines():
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 9:
|
||||
continue
|
||||
|
||||
def num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
|
||||
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
|
||||
"power": num(p[7]), "powerLimit": num(p[8])})
|
||||
return cards
|
||||
|
||||
async def _nvidia_smi_text(self) -> str:
|
||||
txt = await _run_cmd("nvidia-smi")
|
||||
return txt or "nvidia-smi nicht verfuegbar"
|
||||
@@ -128,9 +153,10 @@ class NodeStats:
|
||||
while time.time() - t0 < STREAM_MAX_SEC:
|
||||
g = await self._query_gpu()
|
||||
smi = await self._nvidia_smi_text()
|
||||
cards = await self._query_cards()
|
||||
await send(ws, "node_stats", {
|
||||
"instanceId": self.instance_id, "node": self.node_name,
|
||||
"nvidiaSmi": smi, **g,
|
||||
"nvidiaSmi": smi, "cards": cards, **g,
|
||||
})
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# ── Verbindung zum RVS ───────────────────────────────────────────────
|
||||
# Dieselben Werte wie RVS_HOST / RVS_PORT / RVS_TOKEN in ARIAs .env.
|
||||
# Der Wrapper braucht nur eine AUSGEHENDE Verbindung dorthin — keine
|
||||
# Portfreigabe, kein VPN, egal in welchem Netz dieser Rechner steht.
|
||||
RVS_HOST=mobil.example.org
|
||||
RVS_PORT=444
|
||||
RVS_TLS=true
|
||||
RVS_TOKEN=
|
||||
|
||||
# ── Endpunkt fuer die Programme auf diesem Rechner ───────────────────
|
||||
# Port am Host (Base URL: http://127.0.0.1:<Port>/v1)
|
||||
WRAPPER_PORT=8099
|
||||
# 127.0.0.1 = nur dieser Rechner (empfohlen). 0.0.0.0 = auch aus dem LAN —
|
||||
# dann unbedingt einen API-Key setzen.
|
||||
WRAPPER_BIND=127.0.0.1
|
||||
# Optional. Leer = jeder Key wird akzeptiert (ok, solange nur lokal erreichbar).
|
||||
WRAPPER_API_KEY=
|
||||
|
||||
# Wie lange auf eine Antwort gewartet wird (muss laenger sein als
|
||||
# CLUSTER_LLM_TIMEOUT_S der ARIA-Bridge, Standard 600).
|
||||
#REQUEST_TIMEOUT_S=660
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY wrapper.py .
|
||||
EXPOSE 8099
|
||||
CMD ["python", "-u", "wrapper.py"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# OpenAI-RVS-Wrapper
|
||||
|
||||
Macht den ARIA-LLM-Cluster für Programme, die nur „OpenAI-kompatibel" können
|
||||
(OpenHands, Continue, Open WebUI …), zu einem ganz normalen OpenAI-Endpunkt
|
||||
auf **diesem** Rechner:
|
||||
|
||||
```
|
||||
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
|
||||
```
|
||||
|
||||
Der Wrapper braucht nur eine ausgehende Verbindung zum RVS. Das Programm kann
|
||||
also stehen, wo es will — keine Portfreigabe, kein VPN.
|
||||
|
||||
## Einrichten
|
||||
|
||||
```bash
|
||||
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Dann im Browser **http://127.0.0.1:8099/** öffnen. Die Hilfsseite zeigt, ob
|
||||
die Verbindung steht, welche Modelle es gibt und was man einträgt.
|
||||
|
||||
## 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, z.B. `qwen3-8b` |
|
||||
| Kontextfenster | der Wert von der Hilfsseite (nicht mehr eintragen, die Box gibt nur so viel her) |
|
||||
|
||||
**Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
|
||||
Container. Dann das Netz `aria-llm` anhängen und `http://openai-rvs-wrapper:8099/v1`
|
||||
nehmen:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openhands:
|
||||
# …
|
||||
networks: [default, aria-llm]
|
||||
networks:
|
||||
aria-llm:
|
||||
external: true
|
||||
```
|
||||
|
||||
## Was der Cluster dahinter macht
|
||||
|
||||
- Jedes Modell steht in der Liste **einmal**, egal auf wie vielen Karten es liegt.
|
||||
- Liegt es auf mehreren Karten, verteilt der Cluster bei Last. Fällt eine Box
|
||||
mitten in der Anfrage aus, übernimmt eine andere mit demselben Modell.
|
||||
- Welche Modelle es gibt, legt nur die ARIA-Diagnostic fest
|
||||
(Einstellungen → LLM-Karten). Ein Programm kann nichts laden oder entladen.
|
||||
- Fehler (Modell nicht geladen, Cluster nicht erreichbar) kommen als
|
||||
HTTP-Fehler mit OpenAI-Fehlerobjekt, nie als normale Antwort.
|
||||
- `stream: true` geht; die Antwort kommt derzeit in einem Rutsch statt Wort für Wort.
|
||||
|
||||
Endpunkte: `GET /v1/models`, `POST /v1/chat/completions`, `GET /health`, `GET /` (Hilfsseite).
|
||||
Die Schnittstelle dahinter: [`docs/LLM-SCHNITTSTELLE.md`](../../docs/LLM-SCHNITTSTELLE.md).
|
||||
@@ -0,0 +1,25 @@
|
||||
# OpenAI-RVS-Wrapper — der ARIA-LLM-Cluster als OpenAI-Endpunkt auf diesem Rechner.
|
||||
# Laeuft neben dem Programm, das ihn nutzen soll (OpenHands, Continue, …).
|
||||
#
|
||||
# cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
|
||||
# docker compose up -d --build
|
||||
# → Hilfsseite: http://127.0.0.1:8099/
|
||||
services:
|
||||
openai-rvs-wrapper:
|
||||
build: .
|
||||
container_name: openai-rvs-wrapper
|
||||
env_file: .env
|
||||
ports:
|
||||
# Standard: nur dieser Rechner. Fuer Zugriff aus dem LAN WRAPPER_BIND=0.0.0.0
|
||||
# setzen — dann unbedingt WRAPPER_API_KEY vergeben.
|
||||
- "${WRAPPER_BIND:-127.0.0.1}:${WRAPPER_PORT:-8099}:8099"
|
||||
environment:
|
||||
- WRAPPER_PORT=8099
|
||||
networks: [aria-llm]
|
||||
restart: unless-stopped
|
||||
|
||||
# Programme, die selbst in Docker laufen, erreichen 127.0.0.1 des Hosts nicht —
|
||||
# sie haengen sich an dieses Netz und nehmen http://openai-rvs-wrapper:8099/v1
|
||||
networks:
|
||||
aria-llm:
|
||||
name: aria-llm
|
||||
@@ -0,0 +1 @@
|
||||
websockets==13.1
|
||||
@@ -0,0 +1,376 @@
|
||||
"""OpenAI-RVS-Wrapper — der ARIA-LLM-Cluster als OpenAI-Endpunkt auf 127.0.0.1.
|
||||
|
||||
Laeuft als kleiner Container NEBEN einem Programm, das nur "OpenAI-kompatibel"
|
||||
kann (OpenHands, Continue, Open WebUI …). Fuer das Programm ist der Cluster
|
||||
dann einfach http://127.0.0.1:8099/v1. Der Wrapper selbst braucht nur eine
|
||||
ausgehende Verbindung zum RVS — wie jede KI-Box auch.
|
||||
|
||||
Programm ──HTTP──▶ Wrapper ──RVS──▶ ARIA-Bridge (Router) ──RVS──▶ KI-Box / Karte
|
||||
|
||||
GET / Hilfsseite: welche Modelle gibt es, was eintragen
|
||||
GET /v1/models jedes geladene Modell EINMAL (egal auf wie vielen Karten)
|
||||
POST /v1/chat/completions auch mit stream=true und tools
|
||||
GET /health RVS verbunden? Antwortet die ARIA-Bridge?
|
||||
|
||||
Welche Box/Karte antwortet, entscheidet die ARIA-Bridge (Last, Ausfall-Ersatz).
|
||||
Fehler kommen als HTTP-Fehler mit OpenAI-Fehlerobjekt zurueck, nie als normale
|
||||
Antwort — ein Programm wuerde einen Fehlertext sonst als Aussage der KI in
|
||||
seinen Verlauf schreiben.
|
||||
|
||||
Schnittstelle dahinter: docs/LLM-SCHNITTSTELLE.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import websockets
|
||||
|
||||
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("openai-rvs-wrapper")
|
||||
|
||||
RVS_HOST = os.getenv("RVS_HOST", "").strip()
|
||||
RVS_PORT = os.getenv("RVS_PORT", "443").strip()
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").strip().lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
PORT = int(os.getenv("WRAPPER_PORT", "8099"))
|
||||
API_KEY = os.getenv("WRAPPER_API_KEY", "").strip()
|
||||
REQUEST_TIMEOUT_S = float(os.getenv("REQUEST_TIMEOUT_S", "660")) # > CLUSTER_LLM_TIMEOUT_S der Bridge
|
||||
MODELS_CACHE_S = float(os.getenv("MODELS_CACHE_S", "10"))
|
||||
MAX_BODY = 32 * 1024 * 1024
|
||||
|
||||
|
||||
# ── RVS ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Rvs:
|
||||
"""Eine Verbindung, ein Leser. Antworten werden per (Typ, requestId) an
|
||||
die wartende Anfrage gegeben — der RVS-Raum ist ein Verteiler, wir sehen
|
||||
auch Nachrichten fuer andere Teilnehmer und ignorieren sie."""
|
||||
|
||||
def __init__(self):
|
||||
self.ws = None
|
||||
self.wait: dict = {}
|
||||
self.connected_since = 0.0
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self.ws is not None
|
||||
|
||||
async def run(self) -> None:
|
||||
scheme = "wss" if RVS_TLS else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/?token={RVS_TOKEN}"
|
||||
backoff = 1
|
||||
while True:
|
||||
try:
|
||||
ctx = ssl.create_default_context() if RVS_TLS else None
|
||||
async with websockets.connect(url, ssl=ctx, max_size=64 * 1024 * 1024,
|
||||
ping_interval=20, ping_timeout=20) as ws:
|
||||
self.ws, self.connected_since, backoff = ws, time.time(), 1
|
||||
logger.info("RVS verbunden: %s://%s:%s", scheme, RVS_HOST, RVS_PORT)
|
||||
async for raw in ws:
|
||||
try:
|
||||
m = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
p = m.get("payload") or {}
|
||||
f = self.wait.get((m.get("type"), p.get("requestId")))
|
||||
if f and not f.done():
|
||||
f.set_result(p)
|
||||
except Exception as e:
|
||||
logger.warning("RVS-Verbindung weg (%s) — neuer Versuch in %ds", e, backoff)
|
||||
finally:
|
||||
self.ws = None
|
||||
for f in list(self.wait.values()):
|
||||
if not f.done():
|
||||
f.set_exception(ConnectionError("RVS-Verbindung unterbrochen"))
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
async def call(self, mtype: str, answer: str, payload: dict, timeout: float) -> dict:
|
||||
if not self.ws:
|
||||
raise ConnectionError("keine Verbindung zum RVS")
|
||||
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),
|
||||
"timestamp": int(time.time() * 1000)}))
|
||||
return await asyncio.wait_for(fut, timeout)
|
||||
finally:
|
||||
self.wait.pop((answer, rid), None)
|
||||
|
||||
|
||||
rvs = Rvs()
|
||||
_models_cache = {"at": 0.0, "models": None}
|
||||
|
||||
|
||||
async def cluster_models() -> list:
|
||||
"""[{model, ctx, available, cards}] — jedes Modell einmal. Kurz gecacht,
|
||||
damit ein Programm, das /v1/models dauernd fragt, den Cluster nicht flutet."""
|
||||
if _models_cache["models"] is not None and time.time() - _models_cache["at"] < MODELS_CACHE_S:
|
||||
return _models_cache["models"]
|
||||
res = await rvs.call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
|
||||
_models_cache.update(at=time.time(), models=res.get("models") or [])
|
||||
return _models_cache["models"]
|
||||
|
||||
|
||||
# ── HTTP ────────────────────────────────────────────────────────────
|
||||
|
||||
def oai_error(message: str, code: str, etype: str = "invalid_request_error") -> dict:
|
||||
return {"error": {"message": message, "type": etype, "param": None, "code": code}}
|
||||
|
||||
|
||||
async def reply(writer, status: int, payload, ctype="application/json; charset=utf-8", headers=None):
|
||||
body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
reason = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 404: "Not Found", 413: "Payload Too Large",
|
||||
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout"}.get(status, "Error")
|
||||
head = (f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {len(body)}\r\n"
|
||||
"Connection: close\r\nAccess-Control-Allow-Origin: *\r\n")
|
||||
for k, v in (headers or {}).items():
|
||||
head += f"{k}: {v}\r\n"
|
||||
writer.write((head + "\r\n").encode("utf-8") + body)
|
||||
await writer.drain()
|
||||
|
||||
|
||||
def authorized(headers: dict) -> bool:
|
||||
if not API_KEY:
|
||||
return True
|
||||
auth = headers.get("authorization", "")
|
||||
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
return bool(token) and hmac.compare_digest(token, API_KEY)
|
||||
|
||||
|
||||
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
line = await asyncio.wait_for(reader.readline(), 15)
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
method, path, _ = line.decode("utf-8", "ignore").strip().split(" ", 2)
|
||||
except ValueError:
|
||||
return await reply(writer, 400, oai_error("bad request line", "bad_request"))
|
||||
headers = {}
|
||||
while True:
|
||||
h = await asyncio.wait_for(reader.readline(), 10)
|
||||
if not h or h in (b"\r\n", b"\n"):
|
||||
break
|
||||
k, _, v = h.decode("utf-8", "ignore").partition(":")
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
n = int(headers.get("content-length", "0") or "0")
|
||||
if n > MAX_BODY:
|
||||
return await reply(writer, 413, oai_error("Anfrage zu gross", "too_large"))
|
||||
body = await reader.readexactly(n) if n else b""
|
||||
path = path.split("?", 1)[0].rstrip("/") or "/"
|
||||
|
||||
if method == "OPTIONS":
|
||||
return await reply(writer, 200, b"", "text/plain", {
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"})
|
||||
if method == "GET" and path == "/":
|
||||
return await reply(writer, 200, HELP_PAGE.encode("utf-8"), "text/html; charset=utf-8")
|
||||
if method == "GET" and path == "/health":
|
||||
bridge_ok = False
|
||||
if rvs.connected:
|
||||
try:
|
||||
await rvs.call("llm_cluster_models_request", "llm_cluster_models", {}, 4)
|
||||
bridge_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
return await reply(writer, 200 if bridge_ok else 503, {
|
||||
"rvs": rvs.connected, "bridge": bridge_ok,
|
||||
"rvsUrl": f"{'wss' if RVS_TLS else 'ws'}://{RVS_HOST}:{RVS_PORT}", "apiKey": bool(API_KEY)})
|
||||
if method == "GET" and path == "/api/models":
|
||||
# Fuer die Hilfsseite. Nur Modellnamen — kein Schutz noetig.
|
||||
try:
|
||||
return await reply(writer, 200, {"ok": True, "models": await cluster_models()})
|
||||
except Exception as e:
|
||||
return await reply(writer, 503, {"ok": False, "error": _unreachable(e)})
|
||||
|
||||
if not path.startswith("/v1/"):
|
||||
return await reply(writer, 404, oai_error(f"{method} {path} gibt es nicht", "not_found"))
|
||||
if not authorized(headers):
|
||||
return await reply(writer, 401, oai_error("API-Key fehlt oder ist falsch (Authorization: Bearer <KEY>).",
|
||||
"invalid_api_key", "authentication_error"))
|
||||
|
||||
if method == "GET" and path == "/v1/models":
|
||||
try:
|
||||
models = await cluster_models()
|
||||
except Exception as e:
|
||||
return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error"))
|
||||
now = int(time.time())
|
||||
return await reply(writer, 200, {"object": "list", "data": [
|
||||
{"id": m["model"], "object": "model", "created": now, "owned_by": "aria-cluster",
|
||||
"context_length": m.get("ctx") or None} for m in models]})
|
||||
|
||||
if method == "POST" and path == "/v1/chat/completions":
|
||||
try:
|
||||
req = json.loads(body.decode("utf-8", "ignore") or "{}")
|
||||
except Exception as e:
|
||||
return await reply(writer, 400, oai_error(f"kein gueltiges JSON: {e}", "bad_json"))
|
||||
payload = {"model": req.get("model") or "", "messages": req.get("messages"),
|
||||
"max_tokens": req.get("max_completion_tokens") or req.get("max_tokens"),
|
||||
"temperature": req.get("temperature"), "stop": req.get("stop"), "tools": req.get("tools")}
|
||||
try:
|
||||
res = await rvs.call("llm_cluster_request", "llm_cluster_response",
|
||||
{k: v for k, v in payload.items() if v is not None}, REQUEST_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
return await reply(writer, 504, oai_error(
|
||||
"Der Cluster hat nicht rechtzeitig geantwortet.", "timeout", "server_error"))
|
||||
except Exception as e:
|
||||
return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error"))
|
||||
if not res.get("ok"):
|
||||
code = res.get("errorCode") or "model_error"
|
||||
if code == "context_exceeded":
|
||||
# Wie OpenAI: 400 + context_length_exceeded und die Zahlen im
|
||||
# Text — daran erkennen OpenAI-Clients (litellm/OpenHands & Co.)
|
||||
# einen Kontext-Ueberlauf und kuerzen/verdichten selbst.
|
||||
n_ctx, n_req = res.get("ctx"), res.get("promptTokens")
|
||||
msg = (f"This model's maximum context length is {n_ctx} tokens. However, your "
|
||||
f"messages resulted in {n_req} tokens. Please reduce the length of the messages."
|
||||
if n_ctx and n_req else (res.get("error") or "context length exceeded"))
|
||||
return await reply(writer, 400, oai_error(msg, "context_length_exceeded"))
|
||||
status = {"bad_request": 400, "model_unavailable": 404, "timeout": 504}.get(code, 502)
|
||||
return await reply(writer, status, oai_error(
|
||||
res.get("error") or "Fehler", "model_not_found" if code == "model_unavailable" else code,
|
||||
"invalid_request_error" if status < 500 else "server_error"))
|
||||
return await completion(writer, req, res)
|
||||
|
||||
return await reply(writer, 404, oai_error(f"{method} {path} gibt es nicht", "not_found"))
|
||||
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError):
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("HTTP-Fehler")
|
||||
try:
|
||||
await reply(writer, 502, oai_error("interner Fehler im Wrapper", "internal", "server_error"))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _unreachable(e: Exception) -> str:
|
||||
if not rvs.connected:
|
||||
return f"Keine Verbindung zum RVS ({RVS_HOST}:{RVS_PORT}) — RVS_HOST/RVS_TOKEN in der .env pruefen."
|
||||
if isinstance(e, asyncio.TimeoutError):
|
||||
return "RVS verbunden, aber die ARIA-Bridge antwortet nicht (laeuft ARIA? stimmt das RVS_TOKEN?)."
|
||||
return str(e)
|
||||
|
||||
|
||||
async def completion(writer, req: dict, res: dict) -> None:
|
||||
cid = "chatcmpl-" + uuid.uuid4().hex[:24]
|
||||
created = int(time.time())
|
||||
model = res.get("model") or req.get("model") or ""
|
||||
content = res.get("content") or ""
|
||||
tcs = res.get("tool_calls") or None
|
||||
finish = "tool_calls" if tcs else "stop"
|
||||
usage = res.get("usage") or {}
|
||||
extra = {"X-Aria-Node": str(res.get("node", "")), "X-Aria-Gpu": str(res.get("gpu", "")),
|
||||
"X-Aria-Attempts": str(res.get("attempts", 1))}
|
||||
if not req.get("stream"):
|
||||
msg = {"role": "assistant", "content": content if (content or not tcs) else None}
|
||||
if tcs:
|
||||
msg["tool_calls"] = tcs
|
||||
return await reply(writer, 200, {
|
||||
"id": cid, "object": "chat.completion", "created": created, "model": model,
|
||||
"choices": [{"index": 0, "message": msg, "finish_reason": finish}], "usage": usage}, headers=extra)
|
||||
# stream=true: die Boxen liefern (noch) am Stueck — die fertige Antwort geht
|
||||
# im Stream-Format in einem Rutsch raus. Funktioniert mit jedem Client.
|
||||
head = ("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\n"
|
||||
"Connection: close\r\nAccess-Control-Allow-Origin: *\r\n"
|
||||
+ "".join(f"{k}: {v}\r\n" for k, v in extra.items()) + "\r\n")
|
||||
writer.write(head.encode("utf-8"))
|
||||
|
||||
def chunk(delta: dict, fin=None) -> bytes:
|
||||
return ("data: " + json.dumps({"id": cid, "object": "chat.completion.chunk", "created": created,
|
||||
"model": model, "choices": [{"index": 0, "delta": delta,
|
||||
"finish_reason": fin}]},
|
||||
ensure_ascii=False) + "\n\n").encode("utf-8")
|
||||
delta = {"role": "assistant", "content": content}
|
||||
if tcs:
|
||||
delta["tool_calls"] = [dict(tc, index=i) for i, tc in enumerate(tcs)]
|
||||
writer.write(chunk(delta))
|
||||
writer.write(chunk({}, finish))
|
||||
if usage and (req.get("stream_options") or {}).get("include_usage"):
|
||||
writer.write(("data: " + json.dumps({"id": cid, "object": "chat.completion.chunk", "created": created,
|
||||
"model": model, "choices": [], "usage": usage}) + "\n\n").encode())
|
||||
writer.write(b"data: [DONE]\n\n")
|
||||
await writer.drain()
|
||||
|
||||
|
||||
HELP_PAGE = """<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ARIA LLM-Cluster</title>
|
||||
<style>
|
||||
body{margin:0;background:#0A0A14;color:#E0E0F0;font:14px/1.5 -apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
main{max-width:760px;margin:0 auto;padding:24px 16px}
|
||||
h1{font-size:20px;margin:0 0 4px} .sub{color:#8888AA;margin:0 0 20px}
|
||||
.card{background:#12121E;border:1px solid #2A2A3E;border-radius:10px;padding:14px 16px;margin-bottom:14px}
|
||||
.k{color:#8888AA;font-size:12px} code{background:#0A0A14;border:1px solid #2A2A3E;border-radius:5px;padding:2px 6px}
|
||||
table{width:100%;border-collapse:collapse} td,th{padding:7px 6px;text-align:left;border-bottom:1px solid #1E1E2E}
|
||||
th{color:#8888AA;font-weight:500;font-size:12px} button{background:#1E1E2E;color:#E0E0F0;border:1px solid #333;
|
||||
border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px} button:hover{border-color:#0096FF}
|
||||
.ok{color:#34C759}.err{color:#FF6B6B}.mute{color:#555570}
|
||||
</style></head><body><main>
|
||||
<h1>ARIA LLM-Cluster</h1>
|
||||
<p class="sub">OpenAI-Endpunkt für Programme auf diesem Rechner — dahinter alle Modelle, die auf den KI-Boxen geladen sind.</p>
|
||||
<div class="card"><div class="k">Status</div><div id="status" class="mute">prüfe …</div></div>
|
||||
<div class="card">
|
||||
<div class="k">Im Programm eintragen (OpenAI-kompatibel)</div>
|
||||
<p style="margin:6px 0">Base URL: <code id="base"></code> <button onclick="copy(document.getElementById('base').textContent)">kopieren</button></p>
|
||||
<p style="margin:6px 0">API-Key: <span id="keyinfo" class="mute"></span></p>
|
||||
<p style="margin:6px 0" class="k">Läuft das Programm selbst in Docker, statt 127.0.0.1 den Namen
|
||||
<code>openai-rvs-wrapper</code> nehmen und das Netz <code>aria-llm</code> anhängen (siehe README).</p>
|
||||
</div>
|
||||
<div class="card"><div class="k">Modelle im Cluster — Name genau so eintragen</div>
|
||||
<table><thead><tr><th>Modell</th><th>Kontext</th><th>Karten</th><th></th></tr></thead><tbody id="models">
|
||||
<tr><td colspan="4" class="mute">lade …</td></tr></tbody></table></div>
|
||||
<p class="mute" style="font-size:12px">Auf welcher Box und Karte ein Auftrag läuft, entscheidet der Cluster — bei
|
||||
Last verteilt, bei Ausfall einer Box übernimmt eine andere mit demselben Modell.</p>
|
||||
</main><script>
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function copy(t){navigator.clipboard&&navigator.clipboard.writeText(t)}
|
||||
document.getElementById('base').textContent=location.origin+'/v1';
|
||||
async function load(){
|
||||
try{const h=await (await fetch('/health')).json();
|
||||
document.getElementById('status').innerHTML=h.bridge?'<span class="ok">✓ verbunden mit dem Cluster</span> <span class="mute">('+esc(h.rvsUrl)+')</span>'
|
||||
:(h.rvs?'<span class="err">RVS verbunden, aber die ARIA-Bridge antwortet nicht</span>':'<span class="err">keine Verbindung zum RVS — .env prüfen</span>');
|
||||
document.getElementById('keyinfo').innerHTML=h.apiKey?'der WRAPPER_API_KEY aus der .env':'beliebig (kein Key gesetzt — der Wrapper ist nur lokal erreichbar)';
|
||||
}catch(e){document.getElementById('status').innerHTML='<span class="err">Wrapper antwortet nicht</span>'}
|
||||
try{const d=await (await fetch('/api/models')).json();const tb=document.getElementById('models');
|
||||
if(!d.ok){tb.innerHTML='<tr><td colspan="4" class="err">'+esc(d.error)+'</td></tr>';return}
|
||||
if(!d.models.length){tb.innerHTML='<tr><td colspan="4" class="mute">Kein Modell geladen — in der ARIA-Diagnostic unter Einstellungen → LLM-Karten.</td></tr>';return}
|
||||
tb.innerHTML=d.models.map(m=>'<tr><td><code>'+esc(m.model)+'</code></td><td>'+(m.ctx?esc(m.ctx)+' Tokens':'<span class="mute">?</span>')
|
||||
+'</td><td>'+esc(m.available)+'</td><td><button onclick="copy(\\''+esc(m.model)+'\\')">kopieren</button></td></tr>').join('');
|
||||
}catch(e){}
|
||||
}
|
||||
load();setInterval(load,10000);
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if not RVS_HOST or not RVS_TOKEN:
|
||||
logger.error("RVS_HOST und RVS_TOKEN muessen in der .env stehen — Abbruch")
|
||||
return
|
||||
asyncio.create_task(rvs.run())
|
||||
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
|
||||
logger.info("OpenAI-Endpunkt auf :%d (%s) — Hilfsseite: http://127.0.0.1:%d/", PORT,
|
||||
"mit API-Key" if API_KEY else "ohne API-Key", PORT)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+13
-3
@@ -78,7 +78,11 @@ VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
|
||||
# Registry per periodischem worker_ping frisch. INSTANCE_ID adressiert diesen
|
||||
# Worker bei Redundanz (targetInstance-Routing, Stage 3).
|
||||
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
|
||||
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
|
||||
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
|
||||
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
|
||||
# durch "void", taugt also nicht zur Anzeige.
|
||||
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
|
||||
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
|
||||
WORKER_SERVICE = "voxtral"
|
||||
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
|
||||
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
|
||||
@@ -323,6 +327,7 @@ class StreamSession:
|
||||
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
|
||||
noise_floor: float = 0.0
|
||||
closed: bool = False
|
||||
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
|
||||
endpoint_sent: bool = False
|
||||
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
||||
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
||||
@@ -392,10 +397,12 @@ class SessionManager:
|
||||
sess.last_chunk_at = time.time()
|
||||
return True
|
||||
|
||||
def end_session(self, request_id: str) -> None:
|
||||
def end_session(self, request_id: str, reason: str = "") -> None:
|
||||
sess = self._sessions.get(request_id)
|
||||
if sess is not None:
|
||||
sess.closed = True
|
||||
if str(reason).startswith("cancel"):
|
||||
sess.discard = True # Muelleimer in der App: verwerfen
|
||||
|
||||
def drop(self, request_id: str) -> None:
|
||||
self._sessions.pop(request_id, None)
|
||||
@@ -616,6 +623,9 @@ class SessionManager:
|
||||
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
||||
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
||||
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
||||
if sess.discard:
|
||||
reason = "cancelled"
|
||||
_min_voiced = 10 ** 9 # verworfen: nie transkribieren, leerer Text
|
||||
if sess.voiced_frames < _min_voiced:
|
||||
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
||||
@@ -806,7 +816,7 @@ async def run_loop(sessions: SessionManager) -> None:
|
||||
elif mtype == "stt_audio_chunk":
|
||||
sessions.feed_chunk(payload)
|
||||
elif mtype == "stt_stream_end":
|
||||
sessions.end_session(payload.get("requestId", ""))
|
||||
sessions.end_session(payload.get("requestId", ""), payload.get("reason", ""))
|
||||
elif mtype == "stt_transcribe_blob":
|
||||
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
|
||||
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
|
||||
|
||||
@@ -96,6 +96,31 @@ class NodeStats:
|
||||
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||
|
||||
async def _query_cards(self) -> list:
|
||||
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
|
||||
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
|
||||
Dienste einer Box sie melden."""
|
||||
out = await _run_cmd(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits")
|
||||
cards = []
|
||||
for line in out.strip().splitlines():
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 9:
|
||||
continue
|
||||
|
||||
def num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
|
||||
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
|
||||
"power": num(p[7]), "powerLimit": num(p[8])})
|
||||
return cards
|
||||
|
||||
async def _nvidia_smi_text(self) -> str:
|
||||
txt = await _run_cmd("nvidia-smi")
|
||||
return txt or "nvidia-smi nicht verfuegbar"
|
||||
@@ -128,9 +153,10 @@ class NodeStats:
|
||||
while time.time() - t0 < STREAM_MAX_SEC:
|
||||
g = await self._query_gpu()
|
||||
smi = await self._nvidia_smi_text()
|
||||
cards = await self._query_cards()
|
||||
await send(ws, "node_stats", {
|
||||
"instanceId": self.instance_id, "node": self.node_name,
|
||||
"nvidiaSmi": smi, **g,
|
||||
"nvidiaSmi": smi, "cards": cards, **g,
|
||||
})
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
+16
-4
@@ -77,7 +77,11 @@ WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
||||
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
|
||||
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
|
||||
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
|
||||
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
|
||||
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
|
||||
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
|
||||
# durch "void", taugt also nicht zur Anzeige.
|
||||
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
|
||||
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
|
||||
WORKER_SERVICE = "whisper"
|
||||
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
|
||||
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
|
||||
@@ -386,6 +390,7 @@ class StreamSession:
|
||||
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
||||
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
||||
closed: bool = False # nach stream_end gesetzt
|
||||
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
|
||||
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
|
||||
@@ -479,13 +484,16 @@ class SessionManager:
|
||||
session.last_chunk_at = time.time()
|
||||
return True
|
||||
|
||||
def end_session(self, request_id: str) -> Optional[StreamSession]:
|
||||
def end_session(self, request_id: str, reason: str = "") -> Optional[StreamSession]:
|
||||
"""Markiert Session als geschlossen. Der Endpointer-Loop macht das
|
||||
Final-Transcribe + Cleanup."""
|
||||
Final-Transcribe + Cleanup. reason "cancel:*" (z.B. Muelleimer in der
|
||||
App) = verwerfen: kein Transcribe, leerer Text — nichts geht an ARIA."""
|
||||
session = self._sessions.get(request_id)
|
||||
if session is None:
|
||||
return None
|
||||
session.closed = True
|
||||
if str(reason).startswith("cancel"):
|
||||
session.discard = True
|
||||
return session
|
||||
|
||||
def drop(self, request_id: str) -> None:
|
||||
@@ -803,6 +811,10 @@ class SessionManager:
|
||||
if sess.partial_task is not None and not sess.partial_task.done():
|
||||
sess.partial_task.cancel()
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
if sess.discard:
|
||||
logger.info("Stream %s: verworfen (cancel) — kein Transcribe", sess.request_id[:8])
|
||||
audio = audio[:0]
|
||||
reason = "cancelled"
|
||||
if audio.size == 0:
|
||||
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
|
||||
final_text = ""
|
||||
@@ -1076,7 +1088,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
req_id[:8], payload.get("reason", ""))
|
||||
await _debug_log(ws, "stream.end",
|
||||
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
|
||||
sessions.end_session(req_id)
|
||||
sessions.end_session(req_id, payload.get("reason", ""))
|
||||
|
||||
elif mtype == "voice_id_status_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
|
||||
@@ -96,6 +96,31 @@ class NodeStats:
|
||||
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||
|
||||
async def _query_cards(self) -> list:
|
||||
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
|
||||
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
|
||||
Dienste einer Box sie melden."""
|
||||
out = await _run_cmd(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits")
|
||||
cards = []
|
||||
for line in out.strip().splitlines():
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 9:
|
||||
continue
|
||||
|
||||
def num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
|
||||
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
|
||||
"power": num(p[7]), "powerLimit": num(p[8])})
|
||||
return cards
|
||||
|
||||
async def _nvidia_smi_text(self) -> str:
|
||||
txt = await _run_cmd("nvidia-smi")
|
||||
return txt or "nvidia-smi nicht verfuegbar"
|
||||
@@ -128,9 +153,10 @@ class NodeStats:
|
||||
while time.time() - t0 < STREAM_MAX_SEC:
|
||||
g = await self._query_gpu()
|
||||
smi = await self._nvidia_smi_text()
|
||||
cards = await self._query_cards()
|
||||
await send(ws, "node_stats", {
|
||||
"instanceId": self.instance_id, "node": self.node_name,
|
||||
"nvidiaSmi": smi, **g,
|
||||
"nvidiaSmi": smi, "cards": cards, **g,
|
||||
})
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
Reference in New Issue
Block a user