Compare commits
47
Commits
v0.2.5.7
...
2a0c8b8192
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0c8b8192 | ||
|
|
aeabba10de | ||
|
|
3edcc36a74 | ||
|
|
126e33c4c0 | ||
|
|
e3fe93e0e9 | ||
|
|
93a3070e5c | ||
|
|
5783076e27 | ||
|
|
c511a6b5ae | ||
|
|
87f521ee61 | ||
|
|
739ab85deb | ||
|
|
1e5afe7f65 | ||
|
|
e219382a51 | ||
|
|
8576d4676e | ||
|
|
35d0ea8c41 | ||
|
|
0f8debbf99 | ||
|
|
f8ad877881 | ||
|
|
dd5d1fd2cb | ||
|
|
26ad872c6c | ||
|
|
f479a0781d | ||
|
|
4d590df866 | ||
|
|
b93b62eea9 | ||
|
|
48fc88d430 | ||
|
|
c1b06aa26a | ||
|
|
12f3cd3e55 | ||
|
|
f7f9275b31 | ||
|
|
294917fe36 | ||
|
|
d3f36d8292 | ||
|
|
bbeea24759 | ||
|
|
2a18280863 | ||
|
|
d474447e54 | ||
|
|
15ec8f1be1 | ||
|
|
f5f287da54 | ||
|
|
9c4b8cbdd7 | ||
|
|
9ad402876d | ||
|
|
55b7a02b85 | ||
|
|
ae2024ef55 | ||
|
|
27f4fbdd12 | ||
|
|
0acf96860e | ||
|
|
c9988e7244 | ||
|
|
a00199c121 | ||
|
|
baf9e93c2a | ||
|
|
db2055ff3b | ||
|
|
5a59ea3930 | ||
|
|
7b4a1f4429 | ||
|
|
2267f0a726 | ||
|
|
8cc85c1fe1 | ||
|
|
88036bcad3 |
@@ -672,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:
|
||||
@@ -706,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`
|
||||
@@ -825,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:**
|
||||
@@ -853,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1161,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`
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -193,6 +194,7 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
<UpdateProgress />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20507
|
||||
versionName "0.2.5.7"
|
||||
versionCode 20601
|
||||
versionName "0.2.6.1"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.7",
|
||||
"version": "0.2.6.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -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;
|
||||
@@ -2539,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,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { NativeModules } from 'react-native';
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
const TlsTrust: any = NativeModules.TlsTrust;
|
||||
|
||||
@@ -247,6 +247,7 @@ class RVSConnection {
|
||||
this.log('info', `Verbunden${tlsInfo}`);
|
||||
this.setState('connected');
|
||||
this.updateSecurityOnOpen(useTLS);
|
||||
this.sendClientHello();
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.startHeartbeat();
|
||||
};
|
||||
@@ -323,6 +324,18 @@ 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 {
|
||||
|
||||
+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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-10
@@ -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
|
||||
@@ -1015,6 +1016,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 +1048,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 +1095,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"},
|
||||
@@ -2359,7 +2383,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 +2395,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",
|
||||
@@ -2476,9 +2504,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 +2644,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)
|
||||
@@ -4391,6 +4438,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 +4474,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 +4510,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 +4766,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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
+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("")
|
||||
|
||||
+22
-5
@@ -720,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)
|
||||
@@ -2272,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
|
||||
@@ -2727,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
|
||||
@@ -4264,7 +4271,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}."""
|
||||
@@ -4279,7 +4286,7 @@ class ARIABridge:
|
||||
res = await self._llm_cluster.route({
|
||||
"model": model or "", "messages": messages, "max_tokens": max_tokens,
|
||||
"temperature": temperature, "stop": stop, "tools": tools,
|
||||
}, timeout=self._LLM_TIMEOUT_S)
|
||||
}, 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":
|
||||
@@ -4766,6 +4773,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()})
|
||||
@@ -4927,10 +4938,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)
|
||||
|
||||
+995
-192
File diff suppressed because it is too large
Load Diff
+166
-3
@@ -554,6 +554,107 @@ async function refreshLlmCatalogFromHF() {
|
||||
return { models: merged, added };
|
||||
}
|
||||
|
||||
// ── 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")
|
||||
async function addHfRepoToCatalog(repo) {
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo");
|
||||
const q = await hfRepoQuant(repo, "Q4_K_M");
|
||||
if (!q) throw new Error("kein ladbares GGUF-Modell in diesem Repo");
|
||||
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: "Aus den HuggingFace-Trends übernommen", source: "hf" });
|
||||
saveLlmCatalog(cat);
|
||||
return { models: cat, id, existed: false };
|
||||
}
|
||||
|
||||
// ── File-Project-Manifest ───────────────────────────────────────────
|
||||
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
|
||||
// Wird vom files-list-Endpoint + files-set-project gepflegt.
|
||||
@@ -773,6 +874,7 @@ function workerList() {
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
@@ -1216,6 +1318,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(() => {
|
||||
@@ -1294,6 +1402,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 || {} });
|
||||
@@ -1333,6 +1444,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).
|
||||
@@ -2324,6 +2436,46 @@ const server = http.createServer((req, res) => {
|
||||
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 === "/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 = "";
|
||||
try { repo = JSON.parse(body || "{}").hfRepo; } catch {}
|
||||
addHfRepoToCatalog(repo)
|
||||
.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 }));
|
||||
});
|
||||
});
|
||||
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readLocalLlmConfig()));
|
||||
@@ -3181,6 +3333,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.
|
||||
@@ -3439,12 +3598,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") {
|
||||
@@ -3453,9 +3614,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();
|
||||
|
||||
@@ -296,7 +296,10 @@ steht und welche Modelle es gibt. Im Programm eintragen:
|
||||
- `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
|
||||
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
|
||||
@@ -333,6 +336,25 @@ 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+225
-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",
|
||||
@@ -137,6 +148,11 @@ const ALLOWED_TYPES = new Set([
|
||||
// (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",
|
||||
@@ -172,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}`;
|
||||
@@ -274,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
|
||||
@@ -285,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)`);
|
||||
@@ -374,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" });
|
||||
@@ -396,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");
|
||||
@@ -420,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) {
|
||||
@@ -437,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)`);
|
||||
|
||||
@@ -453,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") {
|
||||
@@ -468,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;
|
||||
}
|
||||
@@ -527,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;
|
||||
@@ -612,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
|
||||
|
||||
+57
-25
@@ -181,6 +181,9 @@ APK_MAX_BYTES = int(os.environ.get("APK_MAX_BYTES", str(64 * 1024 * 1024)) or st
|
||||
|
||||
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
|
||||
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
|
||||
# Obergrenze fuer einen ganzen Scan. Haengt ein Schritt (mDNS, SNMP, ADB …),
|
||||
# kommt nach dieser Zeit die letzte bekannte Liste zurueck statt gar nichts.
|
||||
SCAN_MAX_SEC = float(os.environ.get("SCAN_MAX_SEC", "90") or "90")
|
||||
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
|
||||
|
||||
# http.get/http.post: Body-Ausschnitt. Default grosszuegig (ganze Statusseiten
|
||||
@@ -466,18 +469,28 @@ def _discover_mdns(timeout: float) -> list[dict]:
|
||||
pass
|
||||
|
||||
zc = None
|
||||
browsers = []
|
||||
try:
|
||||
zc = Zeroconf()
|
||||
listener = _Listener()
|
||||
for t in MDNS_TYPES:
|
||||
try:
|
||||
ServiceBrowser(zc, t, listener)
|
||||
browsers.append(ServiceBrowser(zc, t, listener))
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("mDNS-Sweep-Fehler: %s", exc)
|
||||
finally:
|
||||
# Jeder ServiceBrowser ist ein eigener Thread, den zc.close() NICHT
|
||||
# beendet — ohne cancel() blieben pro Scan 14 Threads liegen. Nach
|
||||
# ~5 Tagen war das Thread-Limit des Containers voll (18.841), danach
|
||||
# scheiterte jeder Scan mit "[Errno 11] Resource temporarily unavailable".
|
||||
for b in browsers:
|
||||
try:
|
||||
b.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if zc:
|
||||
zc.close()
|
||||
@@ -1797,6 +1810,7 @@ class Satellite:
|
||||
self._devices: list[dict] = []
|
||||
self._devices_ts: float = 0.0
|
||||
self._scanning = False
|
||||
self._scan_stage = ""
|
||||
# Offene Port-Tunnel: tunnelId -> {"writer", "task", "last", "ziel"}
|
||||
self._tunnels: dict[str, dict] = {}
|
||||
|
||||
@@ -1812,26 +1826,40 @@ class Satellite:
|
||||
break
|
||||
return self._devices
|
||||
self._scanning = True
|
||||
self._scan_stage = "start"
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
_self = [_self_device()] if LIST_SELF else []
|
||||
_self = [d for d in _self if d]
|
||||
self._devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
|
||||
if SNMP_DISCOVERY:
|
||||
await self._enrich_snmp(self._devices)
|
||||
await self._enrich_adb(self._devices)
|
||||
self._devices_ts = time.time()
|
||||
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
|
||||
n_adb = sum(1 for d in self._devices if d.get("adbCapable"))
|
||||
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d, adb=%d)",
|
||||
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp, n_adb)
|
||||
await asyncio.wait_for(self._scan_inner(), SCAN_MAX_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[scan] nach %.0fs abgebrochen — haengt bei: %s. Liefere die letzte Liste (%d Geraete).",
|
||||
SCAN_MAX_SEC, self._scan_stage, len(self._devices))
|
||||
finally:
|
||||
self._scanning = False
|
||||
return self._devices
|
||||
|
||||
async def _scan_inner(self) -> None:
|
||||
"""Ein kompletter Scan; _scan begrenzt ihn auf SCAN_MAX_SEC."""
|
||||
loop = asyncio.get_event_loop()
|
||||
self._scan_stage = "mdns"
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "ssdp"
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "arp"
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
_self = [_self_device()] if LIST_SELF else []
|
||||
_self = [d for d in _self if d]
|
||||
devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
|
||||
if SNMP_DISCOVERY:
|
||||
self._scan_stage = "snmp"
|
||||
await self._enrich_snmp(devices)
|
||||
self._scan_stage = "adb"
|
||||
await self._enrich_adb(devices)
|
||||
self._devices = devices
|
||||
self._devices_ts = time.time()
|
||||
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
|
||||
n_adb = sum(1 for d in self._devices if d.get("adbCapable"))
|
||||
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d, adb=%d)",
|
||||
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp, n_adb)
|
||||
|
||||
async def _enrich_snmp(self, devices: list) -> None:
|
||||
"""Fragt jedes Geraet mit IP parallel per SNMP-System-Group ab und haengt
|
||||
die Infos an. Verbessert Name/Typ, wenn bisher nur eine IP bekannt war."""
|
||||
@@ -2020,6 +2048,16 @@ class Satellite:
|
||||
return True
|
||||
return target in (SATELLITE_ID.lower(), SATELLITE_LOCATION.lower())
|
||||
|
||||
async def _discover_reply(self, payload: dict) -> None:
|
||||
devices = await self._scan(force=bool(payload.get("force")))
|
||||
await self._send({
|
||||
"type": "sat_devices",
|
||||
"payload": {"requestId": payload.get("requestId", ""), "satellite": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION, "devices": devices,
|
||||
"net": NET},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _handle(self, raw: str) -> None:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
@@ -2031,15 +2069,9 @@ class Satellite:
|
||||
if mtype == "sat_discover":
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
devices = await self._scan(force=bool(payload.get("force")))
|
||||
await self._send({
|
||||
"type": "sat_devices",
|
||||
"payload": {"requestId": req_id, "satellite": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION, "devices": devices,
|
||||
"net": NET},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
# Eigener Task: ein langer Scan darf den Nachrichten-Loop (Tunnel,
|
||||
# Befehle, weitere Scans) nicht anhalten.
|
||||
asyncio.create_task(self._discover_reply(payload))
|
||||
|
||||
elif mtype == "sat_tunnel_open":
|
||||
if not self._for_me(payload):
|
||||
|
||||
@@ -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}
|
||||
@@ -178,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:
|
||||
|
||||
+743
-19
@@ -28,7 +28,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import queue
|
||||
import shlex
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
@@ -226,6 +230,31 @@ def _base_config() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _cpu_moe_flag(v) -> str:
|
||||
"""MoE-Modelle: Experten in den Arbeitsspeicher. -1 = alle (--cpu-moe),
|
||||
N > 0 = die der ersten N Schichten (--n-cpu-moe N), 0/leer = alles auf die Karte.
|
||||
Der Rest (Attention, gemeinsame Teile, KV-Cache) bleibt auf der Karte — pro
|
||||
Wort rechnet ein MoE nur wenige Experten, darum bleibt es brauchbar schnell."""
|
||||
try:
|
||||
n = int(v or 0)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if n < 0:
|
||||
return " --cpu-moe"
|
||||
return f" --n-cpu-moe {n}" if n > 0 else ""
|
||||
|
||||
|
||||
def _model_cpu_moe(key: str) -> int:
|
||||
for e in _load_registry():
|
||||
if (e.get("key") or "").strip() == key:
|
||||
try:
|
||||
return int(e.get("cpuMoe") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
m = re.search(r"--n-cpu-moe\s+(\d+)", _model_cmd(key) or "")
|
||||
return int(m.group(1)) if m else (-1 if "--cpu-moe" in (_model_cmd(key) or "") else 0)
|
||||
|
||||
|
||||
def _model_cmd(key: str) -> str:
|
||||
"""llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die
|
||||
Diagnostic geladen) oder aus dem Basis-Template (qwen3-8b & Co.)."""
|
||||
@@ -234,7 +263,8 @@ def _model_cmd(key: str) -> str:
|
||||
quant = (e.get("quant") or "Q4_K_M").strip()
|
||||
return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
|
||||
f"-hf {e['hfRepo'].strip()}:{quant}\n"
|
||||
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja")
|
||||
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja"
|
||||
+ _cpu_moe_flag(e.get("cpuMoe")))
|
||||
spec = (_base_config().get("models") or {}).get(key) or {}
|
||||
return str(spec.get("cmd") or "")
|
||||
|
||||
@@ -306,15 +336,49 @@ async def _reload_llama() -> None:
|
||||
await asyncio.sleep(3) # Poll-Intervall + Puffer, bevor jemand fragt
|
||||
|
||||
|
||||
# Antworten, die nicht verloren gehen duerfen: Bricht die RVS-Verbindung ab,
|
||||
# waehrend eine Anfrage noch rechnet (z.B. RVS-/Proxy-Neustart), wuerde die
|
||||
# fertige Antwort sonst ueber die tote Verbindung geschickt und verworfen — der
|
||||
# Fragende (Schmiede, ARIA) wartet dann bis zu seiner Frist ins Leere (am
|
||||
# 28.10. 11 Minuten). Jetzt: ueber die NEUE Verbindung senden, notfalls bis zum
|
||||
# Wiederverbinden aufheben.
|
||||
_RELIABLE_TYPES = {"llm_response"}
|
||||
_OUTBOX_TTL_S = 300
|
||||
_outbox: list = [] # [(ts, mtype, payload)]
|
||||
_current_ws = None # die aktuell offene RVS-Verbindung
|
||||
|
||||
|
||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
"type": mtype,
|
||||
"payload": payload,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
||||
data = json.dumps({"type": mtype, "payload": payload, "timestamp": int(time.time() * 1000)})
|
||||
targets = [ws] if ws is _current_ws or _current_ws is None else [ws, _current_ws]
|
||||
last_err = None
|
||||
for t in targets:
|
||||
try:
|
||||
await t.send(data)
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if mtype in _RELIABLE_TYPES:
|
||||
_outbox.append((time.time(), mtype, payload))
|
||||
logger.warning("Send fehlgeschlagen (%s): %s — aufgehoben, geht nach dem Wiederverbinden raus",
|
||||
mtype, last_err)
|
||||
else:
|
||||
logger.warning("Send fehlgeschlagen (%s): %s", mtype, last_err)
|
||||
|
||||
|
||||
async def _flush_outbox(ws) -> None:
|
||||
"""Nach dem (Wieder-)Verbinden: aufgehobene Antworten nachschicken."""
|
||||
now = time.time()
|
||||
keep = [(t, m, p) for (t, m, p) in _outbox if now - t <= _OUTBOX_TTL_S]
|
||||
_outbox.clear()
|
||||
for t, m, p in keep:
|
||||
try:
|
||||
await ws.send(json.dumps({"type": m, "payload": p, "timestamp": int(time.time() * 1000)}))
|
||||
logger.info("Aufgehobene Antwort nachgeschickt (%s, %.0f s alt)", m, now - t)
|
||||
except Exception as e:
|
||||
_outbox.append((t, m, p))
|
||||
logger.warning("Nachschicken fehlgeschlagen (%s): %s", m, e)
|
||||
return
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list) -> list:
|
||||
@@ -517,6 +581,237 @@ def _model_ctx(key: str) -> int:
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
# ── Kontextfenster: was das Modell kann + was es an Grafikspeicher kostet ──
|
||||
#
|
||||
# Aus dem Kopf der GGUF-Datei (nur Metadaten, die Gewichte werden nicht
|
||||
# gelesen): trainiertes Maximum (context_length) und die Groesse des KV-Caches
|
||||
# pro Token. Modelle mit Sliding-Window-Attention (Gemma) halten in den
|
||||
# "lokalen" Schichten nur ein festes Fenster — nur die globalen Schichten
|
||||
# wachsen mit dem Kontext. Annahme: KV-Cache in f16 (llama.cpp-Standard).
|
||||
|
||||
_GGUF_T = {0: "B", 1: "b", 2: "H", 3: "h", 4: "I", 5: "i", 6: "f", 7: "?", 10: "Q", 11: "q", 12: "d"}
|
||||
_gguf_cache: dict = {} # path → (mtime, meta)
|
||||
CTX_STEPS = [4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576]
|
||||
# Fester Aufschlag (CUDA-Kontext + Rechenpuffer von llama.cpp) — gemessen auf
|
||||
# einer RTX 3060 mit Gemma 4 12B bei 32k: ~1,4 GB ueber Modell + KV-Cache.
|
||||
VRAM_OVERHEAD_MB = 1400
|
||||
UBATCH = 512
|
||||
|
||||
|
||||
def _gguf_meta(path: str, fsize: int = 0) -> dict:
|
||||
"""Metadaten-Block einer GGUF-Datei (gecacht). Lange String-Listen (der
|
||||
Tokenizer) werden uebersprungen, nicht gespeichert. fsize: wahre Groesse,
|
||||
wenn path nur der Anfang der Datei ist (Kopf per HTTP-Range geholt)."""
|
||||
try:
|
||||
mt = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return {}
|
||||
hit = _gguf_cache.get(path)
|
||||
if hit and hit[0] == mt and not fsize:
|
||||
return hit[1]
|
||||
import struct
|
||||
meta: dict = {}
|
||||
|
||||
def rd(f, fmt):
|
||||
return struct.unpack("<" + fmt, f.read(struct.calcsize("<" + fmt)))[0]
|
||||
|
||||
def rstr(f):
|
||||
return f.read(rd(f, "Q")).decode("utf-8", "replace")
|
||||
|
||||
def rval(f, t):
|
||||
if t == 8:
|
||||
return rstr(f)
|
||||
if t == 9:
|
||||
et, n = rd(f, "I"), rd(f, "Q")
|
||||
if et == 8:
|
||||
for _ in range(n):
|
||||
f.seek(rd(f, "Q"), 1)
|
||||
return None
|
||||
if et == 9:
|
||||
raise ValueError("verschachtelte Arrays")
|
||||
fmt = _GGUF_T[et]
|
||||
if n > 4096:
|
||||
f.seek(struct.calcsize("<" + fmt) * n, 1)
|
||||
return None
|
||||
return [rd(f, fmt) for _ in range(n)]
|
||||
return rd(f, _GGUF_T[t])
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
if f.read(4) != b"GGUF":
|
||||
return {}
|
||||
rd(f, "I")
|
||||
n_tensors = rd(f, "Q")
|
||||
for _ in range(rd(f, "Q")):
|
||||
k = rstr(f)
|
||||
v = rval(f, rd(f, "I"))
|
||||
if v is not None:
|
||||
meta[k] = v
|
||||
# Tensor-Verzeichnis: Groesse der MoE-Experten (…_exps) je Schicht —
|
||||
# fuer die Schaetzung "was bleibt auf der Karte, was geht in den RAM"
|
||||
infos = []
|
||||
for _ in range(n_tensors):
|
||||
name = rstr(f)
|
||||
nd = rd(f, "I")
|
||||
for _ in range(nd):
|
||||
rd(f, "Q")
|
||||
rd(f, "I")
|
||||
infos.append((rd(f, "Q"), name))
|
||||
align = int(meta.get("general.alignment") or 32)
|
||||
data_start = (f.tell() + align - 1) // align * align
|
||||
fsize = fsize or os.path.getsize(path)
|
||||
infos.sort()
|
||||
exp_by_layer: dict = {}
|
||||
for i, (off, name) in enumerate(infos):
|
||||
nxt = infos[i + 1][0] if i + 1 < len(infos) else fsize - data_start
|
||||
if "_exps" in name:
|
||||
mm = re.match(r"blk\.(\d+)\.", name)
|
||||
lay = int(mm.group(1)) if mm else -1
|
||||
exp_by_layer[lay] = exp_by_layer.get(lay, 0) + max(0, nxt - off)
|
||||
meta["_expert_bytes_by_layer"] = exp_by_layer
|
||||
except Exception as e:
|
||||
logger.debug("GGUF %s nicht lesbar: %s", path, e)
|
||||
meta = {}
|
||||
_gguf_cache[path] = (mt, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def _kv_bytes(meta: dict, ctx: int) -> int:
|
||||
"""KV-Cache (f16) fuer ctx Tokens laut GGUF-Metadaten — Sliding-Window-
|
||||
Schichten (Gemma) halten nur ihr Fenster."""
|
||||
arch = meta.get("general.architecture")
|
||||
if not arch:
|
||||
return 0
|
||||
g = lambda k, d=None: meta.get(f"{arch}.{k}", d)
|
||||
n_layer = int(g("block_count", 0) or 0)
|
||||
if not n_layer:
|
||||
return 0
|
||||
|
||||
def per_layer(v, d=0):
|
||||
return list(v) if isinstance(v, list) else [v if v is not None else d] * n_layer
|
||||
kv_heads = per_layer(g("attention.head_count_kv", g("attention.head_count", 0)))
|
||||
k_len = int(g("attention.key_length", 0) or 0)
|
||||
v_len = int(g("attention.value_length", 0) or 0)
|
||||
if not (k_len and v_len):
|
||||
emb, heads = int(g("embedding_length", 0) or 0), int(g("attention.head_count", 0) or 0)
|
||||
k_len = v_len = emb // heads if heads else 0
|
||||
k_swa = int(g("attention.key_length_swa", k_len) or k_len)
|
||||
v_swa = int(g("attention.value_length_swa", v_len) or v_len)
|
||||
window = int(g("attention.sliding_window", 0) or 0)
|
||||
pattern = g("attention.sliding_window_pattern")
|
||||
swa = per_layer(pattern, False) if isinstance(pattern, list) and window else [False] * n_layer
|
||||
per_tok = sum(int(kv_heads[i]) * (k_len + v_len) * 2 for i in range(n_layer) if not swa[i])
|
||||
swa_tok = sum(int(kv_heads[i]) * (k_swa + v_swa) * 2 for i in range(n_layer) if swa[i])
|
||||
return per_tok * ctx + swa_tok * min(ctx, window + UBATCH)
|
||||
|
||||
|
||||
async def _auto_cpu_moe(repo: str, file: dict, vram_mb: int, ctx: int) -> int:
|
||||
"""Wie viele Schichten mit Experten muessen in den RAM, damit der Rest auf
|
||||
die Karte passt? Holt nur den Kopf der GGUF (Metadaten + Tensor-Verzeichnis,
|
||||
wenige MB per HTTP-Range) — die Datei selbst laedt erst llama.cpp.
|
||||
0 = passt komplett, -1 = alle Experten in den RAM (oder unbekannt)."""
|
||||
import tempfile
|
||||
url = f"https://huggingface.co/{repo}/resolve/main/{file['path']}"
|
||||
headers = {"Range": "bytes=0-41943039"}
|
||||
if HF_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as c:
|
||||
r = await c.get(url, headers=headers)
|
||||
r.raise_for_status()
|
||||
with tempfile.NamedTemporaryFile(suffix=".gguf", delete=False) as tf:
|
||||
tf.write(r.content)
|
||||
tmp = tf.name
|
||||
try:
|
||||
meta = _gguf_meta(tmp, fsize=int(file.get("size") or 0))
|
||||
finally:
|
||||
_gguf_cache.pop(tmp, None)
|
||||
os.unlink(tmp)
|
||||
except Exception as e:
|
||||
logger.warning("MoE-Aufteilung fuer %s nicht berechenbar (%s) — alle Experten in den RAM", repo, e)
|
||||
return -1
|
||||
exp = meta.get("_expert_bytes_by_layer") or {}
|
||||
if not exp:
|
||||
return 0 # kein MoE
|
||||
total_mb = int(file.get("size") or 0) / 1048576
|
||||
exp_mb = sum(exp.values()) / 1048576
|
||||
budget = vram_mb * 0.92 - VRAM_OVERHEAD_MB - _kv_bytes(meta, ctx) / 1048576 - (total_mb - exp_mb)
|
||||
per_layer = exp_mb / len(exp)
|
||||
on_gpu = max(0, int(budget // per_layer)) if per_layer else 0
|
||||
n = max(0, len(exp) - on_gpu)
|
||||
logger.info("MoE %s: %d von %d Schichten mit Experten in den RAM (Karte %d MB, ctx %d)",
|
||||
repo, n, len(exp), vram_mb, ctx)
|
||||
return -1 if n >= len(exp) else n
|
||||
|
||||
|
||||
def _model_files(key: str) -> tuple:
|
||||
"""(Gewichte.gguf, mmproj.gguf|None) eines Modells im HF-Cache unter /models
|
||||
— aus "-hf org/repo:QUANT" bzw. "-m pfad" im llama-server-Aufruf."""
|
||||
import glob
|
||||
cmd = _model_cmd(key) or ""
|
||||
m = re.search(r"(?:^|\s)-m\s+(\S+)", cmd)
|
||||
if m and os.path.isfile(m.group(1)):
|
||||
return m.group(1), None
|
||||
m = re.search(r"(?:^|\s)-hf\s+([^\s:]+)(?::(\S+))?", cmd)
|
||||
if not m:
|
||||
return None, None
|
||||
repo, quant = m.group(1), (m.group(2) or "").lower()
|
||||
base = os.path.join(MODELS_DIR, "models--" + repo.replace("/", "--"), "snapshots")
|
||||
files = [p for p in glob.glob(os.path.join(base, "*", "*.gguf")) if os.path.isfile(p)]
|
||||
weights = [p for p in files if "mmproj" not in os.path.basename(p).lower()]
|
||||
if quant:
|
||||
weights = [p for p in weights if quant in os.path.basename(p).lower()] or weights
|
||||
mmproj = [p for p in files if "mmproj" in os.path.basename(p).lower()]
|
||||
return (weights[0] if weights else None), (mmproj[0] if mmproj else None)
|
||||
|
||||
|
||||
def _ctx_profile(key: str) -> dict:
|
||||
"""{ctxMax, options: [{ctx, estMB}]} fuer die Kontext-Auswahl der Diagnostic.
|
||||
Leer, wenn die Modelldatei (noch) nicht da oder nicht lesbar ist."""
|
||||
weights, mmproj = _model_files(key)
|
||||
if not weights:
|
||||
return {}
|
||||
return _profile_of_files(weights, mmproj, lambda: _model_cpu_moe(key))
|
||||
|
||||
|
||||
def _profile_of_files(weights: str, mmproj, cpu_moe_of=lambda: 0) -> dict:
|
||||
"""Wie _ctx_profile, aber fuer beliebige Dateien — auch heruntergeladene
|
||||
Modelle, die (noch) auf keiner Karte liegen. cpu_moe_of: Aufteilung der
|
||||
Experten (0 = Schaetzung "alles auf der Karte")."""
|
||||
meta = _gguf_meta(weights)
|
||||
arch = meta.get("general.architecture")
|
||||
if not arch:
|
||||
return {}
|
||||
g = lambda k, d=None: meta.get(f"{arch}.{k}", d)
|
||||
n_layer = int(g("block_count", 0) or 0)
|
||||
if not n_layer:
|
||||
return {}
|
||||
|
||||
files_mb = (os.path.getsize(weights) + (os.path.getsize(mmproj) if mmproj else 0)) / 1048576
|
||||
ctx_max = int(g("context_length", 0) or 0)
|
||||
exp_by_layer = meta.get("_expert_bytes_by_layer") or {}
|
||||
moe = bool(exp_by_layer) or int(g("expert_count", 0) or 0) > 0
|
||||
cpu_moe = cpu_moe_of() if moe else 0
|
||||
if cpu_moe:
|
||||
layers = sorted(exp_by_layer) if cpu_moe < 0 else sorted(exp_by_layer)[:cpu_moe]
|
||||
ram_mb = sum(exp_by_layer[l] for l in layers) / 1048576
|
||||
else:
|
||||
ram_mb = 0.0
|
||||
gpu_files_mb = files_mb - ram_mb
|
||||
steps = [c for c in CTX_STEPS if not ctx_max or c <= ctx_max]
|
||||
if ctx_max and ctx_max not in steps:
|
||||
steps.append(ctx_max) # krummes Maximum (z.B. 40960) mit anbieten
|
||||
opts = []
|
||||
for c in steps:
|
||||
kv_mb = _kv_bytes(meta, c) / 1048576
|
||||
opts.append({"ctx": c, "estMB": int(gpu_files_mb + kv_mb + VRAM_OVERHEAD_MB)})
|
||||
out = {"ctxMax": ctx_max, "options": opts, "moe": moe, "cpuMoe": cpu_moe}
|
||||
if moe:
|
||||
out["expertsMB"] = int(sum(exp_by_layer.values()) / 1048576)
|
||||
out["ramMB"] = int(ram_mb)
|
||||
return out
|
||||
|
||||
|
||||
async def _vram_used() -> dict:
|
||||
"""{index: belegte MB} — frisch bei jedem Aufruf (aendert sich laufend)."""
|
||||
try:
|
||||
@@ -555,7 +850,8 @@ async def _slots_info() -> list:
|
||||
on_cpu = bool(m and state == "ready" and u is not None and u < ON_CPU_BELOW_MB)
|
||||
out.append({"gpu": card, "gpuName": g.get("name", ""), "vramMB": g.get("vramMB", 0),
|
||||
"vramUsedMB": u if u is not None else -1, "onCpu": on_cpu,
|
||||
"model": m, "state": state, "ctx": _model_ctx(m) if m else 0})
|
||||
"model": m, "state": state, "ctx": _model_ctx(m) if m else 0,
|
||||
**(_ctx_profile(m) if m else {})})
|
||||
return out
|
||||
|
||||
|
||||
@@ -593,6 +889,7 @@ async def _announce(ws) -> None:
|
||||
"models": models,
|
||||
"slots": info, # pro LLM-Karte: Modell + Zustand (Diagnostic)
|
||||
"knownModels": _known_models(), # was in eine Karte geladen werden KOENNTE
|
||||
"disk": await asyncio.to_thread(_disk_info), # frei + heruntergeladene Modelle
|
||||
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
|
||||
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
|
||||
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
|
||||
@@ -841,9 +1138,12 @@ def _scan_download(oids: set) -> tuple:
|
||||
inprog = fn.endswith(_INPROGRESS)
|
||||
base = fn[:-len(_INPROGRESS)] if inprog else fn
|
||||
try:
|
||||
size = os.path.getsize(os.path.join(root, fn))
|
||||
st = os.stat(os.path.join(root, fn))
|
||||
except OSError:
|
||||
continue
|
||||
# Der eigene Parallel-Download legt die Datei in voller Groesse an
|
||||
# (sparse) — dann zaehlt nur, was wirklich geschrieben ist.
|
||||
size = min(st.st_size, st.st_blocks * 512) if inprog else st.st_size
|
||||
if base in oids:
|
||||
done += size
|
||||
found += 1
|
||||
@@ -856,6 +1156,264 @@ def _scan_download(oids: set) -> tuple:
|
||||
return done, active, found
|
||||
|
||||
|
||||
# Freier Platz vor dem Download: llama.cpp bricht bei voller Platte mitten
|
||||
# im Download ab, und der Grund kommt ueber llama-swap nicht an. Vorher
|
||||
# rechnen: was fehlt noch (fertige Blobs zaehlen nicht) gegen den freien
|
||||
# Platz minus Puffer. Halbe Downloads, an denen seit STALE_PARTIAL_S niemand
|
||||
# mehr schreibt, sind Reste abgebrochener Versuche — die duerfen weg.
|
||||
DISK_RESERVE_BYTES = int(float(os.getenv("LLM_DISK_RESERVE_GB", "2")) * 1024**3)
|
||||
STALE_PARTIAL_S = 30 * 60
|
||||
|
||||
|
||||
def _blob_state(oids: set) -> tuple:
|
||||
"""({oid: Bytes fertig}, [(pfad, bytes, mtime)] aller halben Downloads)."""
|
||||
done, partial = {}, []
|
||||
for root, _dirs, fns in os.walk(MODELS_DIR):
|
||||
for fn in fns:
|
||||
path = os.path.join(root, fn)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if fn.endswith(_INPROGRESS):
|
||||
partial.append((path, st.st_size, st.st_mtime))
|
||||
elif fn in oids:
|
||||
done[fn] = st.st_size
|
||||
return done, partial
|
||||
|
||||
|
||||
def _check_disk(files: list) -> tuple:
|
||||
"""(ok, Meldung). Ohne HF-Dateiliste (files leer) wird nicht geprueft."""
|
||||
if not files:
|
||||
return True, ""
|
||||
done, partial = _blob_state({f["oid"] for f in files if f.get("oid")})
|
||||
need = sum(f["size"] for f in files if f.get("oid") not in done)
|
||||
if not need:
|
||||
return True, ""
|
||||
free = shutil.disk_usage(MODELS_DIR).free
|
||||
note = ""
|
||||
if need + DISK_RESERVE_BYTES > free:
|
||||
stale = [(p, b) for p, b, mt in partial if time.time() - mt > STALE_PARTIAL_S]
|
||||
for path, _b in stale:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
if stale:
|
||||
freed = sum(b for _p, b in stale)
|
||||
note = f"Reste abgebrochener Downloads geloescht ({len(stale)} Datei(en), {freed / 1024**3:.1f} GB)"
|
||||
logger.info("Speicherplatz: %s", note)
|
||||
free = shutil.disk_usage(MODELS_DIR).free
|
||||
if need + DISK_RESERVE_BYTES > free:
|
||||
gb = lambda n: f"{n / 1024**3:.1f}".replace(".", ",")
|
||||
return False, (f"Zu wenig Speicherplatz auf {NODE_NAME}: das Modell braucht ca. {gb(need)} GB, "
|
||||
f"frei sind {gb(free)} GB (dazu {gb(DISK_RESERVE_BYTES)} GB Puffer). "
|
||||
f"Nicht mehr gebrauchte Modelle im Katalog entfernen oder Platz auf der Box schaffen."
|
||||
+ (f" ({note})" if note else ""))
|
||||
return True, note
|
||||
|
||||
|
||||
# Paralleler Download: llama.cpp zieht eine Datei ueber EINE Verbindung, das
|
||||
# bleibt oft bei 10-50 MB/s haengen, auch bei 1-Gbit-Leitung. Wir holen die
|
||||
# Datei vorher selbst in Stuecken ueber mehrere Verbindungen — direkt ins
|
||||
# HF-Cache-Layout, das llama.cpp selbst anlegt (blobs/<sha256>, snapshots/
|
||||
# <commit>/<datei> → Symlink, refs/main). Danach findet llama.cpp sie als
|
||||
# "liegt schon da". Klappt es nicht, laedt llama.cpp wie bisher selbst.
|
||||
DOWNLOAD_CONNECTIONS = int(os.getenv("LLM_DOWNLOAD_CONNECTIONS", "8"))
|
||||
DOWNLOAD_PIECE = 64 * 1024**2
|
||||
_provisions: dict = {} # key → {"cancel": threading.Event, "task", "phase"}
|
||||
|
||||
|
||||
def _repo_dir(repo: str) -> str:
|
||||
return os.path.join(MODELS_DIR, "models--" + repo.replace("/", "--"))
|
||||
|
||||
|
||||
async def _hf_commit(repo: str) -> str:
|
||||
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
|
||||
r = await c.get(f"https://huggingface.co/api/models/{repo}", headers=headers)
|
||||
r.raise_for_status()
|
||||
return str(r.json().get("sha") or "")
|
||||
except Exception as e:
|
||||
logger.warning("HF-Commit fuer %s nicht abrufbar (%s)", repo, e)
|
||||
return ""
|
||||
|
||||
|
||||
def _link_snapshot(repo: str, commit: str, f: dict) -> None:
|
||||
rdir = _repo_dir(repo)
|
||||
link = os.path.join(rdir, "snapshots", commit, f["path"])
|
||||
os.makedirs(os.path.dirname(link), exist_ok=True)
|
||||
target = os.path.relpath(os.path.join(rdir, "blobs", f["oid"]), os.path.dirname(link))
|
||||
if os.path.islink(link) or os.path.exists(link):
|
||||
os.remove(link)
|
||||
os.symlink(target, link)
|
||||
os.makedirs(os.path.join(rdir, "refs"), exist_ok=True)
|
||||
with open(os.path.join(rdir, "refs", "main"), "w") as fh:
|
||||
fh.write(commit)
|
||||
|
||||
|
||||
def _download_parallel(repo: str, commit: str, f: dict, cancel: threading.Event) -> str:
|
||||
"""Eine Datei in Stuecken ueber mehrere Verbindungen. "" = fertig, sonst Grund."""
|
||||
blobs = os.path.join(_repo_dir(repo), "blobs")
|
||||
os.makedirs(blobs, exist_ok=True)
|
||||
final = os.path.join(blobs, f["oid"])
|
||||
part = final + _INPROGRESS
|
||||
if os.path.exists(final):
|
||||
return ""
|
||||
if os.path.exists(part) and time.time() - os.path.getmtime(part) < 120:
|
||||
return "es laeuft schon ein Download dieser Datei"
|
||||
size = f["size"]
|
||||
url = f"https://huggingface.co/{repo}/resolve/{commit}/{f['path']}"
|
||||
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
|
||||
with open(part, "wb") as fh:
|
||||
fh.truncate(size) # sparse: belegt erst beim Schreiben
|
||||
pieces: queue.Queue = queue.Queue()
|
||||
for start in range(0, size, DOWNLOAD_PIECE):
|
||||
pieces.put((start, min(size, start + DOWNLOAD_PIECE) - 1))
|
||||
errors: list = []
|
||||
|
||||
def worker() -> None:
|
||||
fd = os.open(part, os.O_WRONLY)
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(30, read=60), follow_redirects=True,
|
||||
headers=headers) as c:
|
||||
while not cancel.is_set() and not errors:
|
||||
try:
|
||||
start, end = pieces.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
pos = start
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with c.stream("GET", url, headers={"Range": f"bytes={pos}-{end}"}) as r:
|
||||
if r.status_code != 206:
|
||||
raise RuntimeError(f"HTTP {r.status_code} statt Teil-Download")
|
||||
for chunk in r.iter_bytes(1024 * 1024):
|
||||
if cancel.is_set():
|
||||
return
|
||||
os.pwrite(fd, chunk, pos)
|
||||
pos += len(chunk)
|
||||
if pos != end + 1:
|
||||
raise RuntimeError("Stueck unvollstaendig")
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 4 or cancel.is_set():
|
||||
errors.append(str(e)[:200])
|
||||
return
|
||||
time.sleep(2 * (attempt + 1)) # weiter ab pos
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
threads = [threading.Thread(target=worker, daemon=True) for _ in range(max(1, DOWNLOAD_CONNECTIONS))]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
if cancel.is_set() or errors:
|
||||
try:
|
||||
os.remove(part)
|
||||
except OSError:
|
||||
pass
|
||||
return "abgebrochen" if cancel.is_set() else errors[0]
|
||||
h = hashlib.sha256()
|
||||
with open(part, "rb") as fh:
|
||||
while chunk := fh.read(8 * 1024 * 1024):
|
||||
if cancel.is_set():
|
||||
os.remove(part)
|
||||
return "abgebrochen"
|
||||
h.update(chunk)
|
||||
if h.hexdigest() != f["oid"]:
|
||||
os.remove(part)
|
||||
return "Pruefsumme stimmt nicht — Datei verworfen"
|
||||
os.replace(part, final)
|
||||
return ""
|
||||
|
||||
|
||||
async def _prefetch(ws, key: str, repo: str, files: list, card: str, cancel: threading.Event,
|
||||
strict: bool = False) -> str:
|
||||
"""Laedt fehlende Dateien parallel vor. "" = alles da (oder Fallback auf
|
||||
llama.cpp), "abgebrochen" = Benutzer hat abgebrochen. strict (nur
|
||||
herunterladen): kein Fallback — jeder Fehler kommt als Grund zurueck."""
|
||||
files = [f for f in files if f.get("oid") and f.get("size")]
|
||||
if not files:
|
||||
return "keine Dateiliste von HuggingFace" if strict else ""
|
||||
if DOWNLOAD_CONNECTIONS < 2 and not strict:
|
||||
return ""
|
||||
missing = [f for f in files if not os.path.exists(os.path.join(_repo_dir(repo), "blobs", f["oid"]))]
|
||||
if not missing:
|
||||
return ""
|
||||
commit = await _hf_commit(repo)
|
||||
if not commit:
|
||||
return "HuggingFace nicht erreichbar" if strict else ""
|
||||
stop = asyncio.Event()
|
||||
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files), files=len(files), gpu=card,
|
||||
note=f"Lade mit {DOWNLOAD_CONNECTIONS} Verbindungen")
|
||||
watcher = asyncio.create_task(_watch_download(ws, key, files, stop, final_phase=False))
|
||||
err = ""
|
||||
try:
|
||||
for f in missing:
|
||||
err = await asyncio.to_thread(_download_parallel, repo, commit, f, cancel)
|
||||
if err:
|
||||
break
|
||||
if not err:
|
||||
for f in files:
|
||||
await asyncio.to_thread(_link_snapshot, repo, commit, f)
|
||||
finally:
|
||||
stop.set()
|
||||
try:
|
||||
await watcher
|
||||
except Exception:
|
||||
pass
|
||||
if err and err != "abgebrochen" and not strict:
|
||||
logger.warning("Paralleler Download von %s fehlgeschlagen (%s) — llama.cpp laedt selbst", repo, err)
|
||||
return ""
|
||||
return err
|
||||
|
||||
|
||||
def _disk_profiles(repo_dir: str) -> list:
|
||||
"""Kontext-Profil (Maximum + GB je Stufe, Annahme: alles auf der Karte) je
|
||||
heruntergeladener Quantisierung — damit die Diagnostic auch fuer Modelle,
|
||||
die auf keiner Karte liegen, zeigen kann, was sie koennen und kosten."""
|
||||
import glob
|
||||
files = [p for p in glob.glob(os.path.join(repo_dir, "snapshots", "*", "**", "*.gguf"), recursive=True)
|
||||
if os.path.isfile(p)]
|
||||
mmproj = next((p for p in files if "mmproj" in os.path.basename(p).lower()), None)
|
||||
out = []
|
||||
for w in files:
|
||||
if not _is_main_gguf(w) or _SPLIT_RE.search(w):
|
||||
continue # gesplittete Modelle: Groesse waere nur die des ersten Teils
|
||||
prof = _profile_of_files(w, mmproj)
|
||||
if prof:
|
||||
out.append({"quant": _quant_of(w), **{k: prof[k] for k in ("ctxMax", "options", "moe") if k in prof},
|
||||
**({"expertsMB": prof["expertsMB"]} if "expertsMB" in prof else {})})
|
||||
return out
|
||||
|
||||
|
||||
def _disk_info() -> dict:
|
||||
"""Freier Platz + fertig heruntergeladene Modelle (je Repo) fuer die Diagnostic."""
|
||||
out = {"freeBytes": 0, "models": []}
|
||||
try:
|
||||
out["freeBytes"] = shutil.disk_usage(MODELS_DIR).free
|
||||
for name in sorted(os.listdir(MODELS_DIR)):
|
||||
if not name.startswith("models--"):
|
||||
continue
|
||||
blobs = os.path.join(MODELS_DIR, name, "blobs")
|
||||
size = 0
|
||||
for fn in (os.listdir(blobs) if os.path.isdir(blobs) else []):
|
||||
if not fn.endswith(_INPROGRESS):
|
||||
try:
|
||||
size += os.path.getsize(os.path.join(blobs, fn))
|
||||
except OSError:
|
||||
pass
|
||||
if size:
|
||||
out["models"].append({"repo": name[len("models--"):].replace("--", "/", 1), "bytes": size,
|
||||
"profiles": _disk_profiles(os.path.join(MODELS_DIR, name))})
|
||||
except OSError as e:
|
||||
logger.info("Plattenbelegung nicht lesbar: %s", e)
|
||||
return out
|
||||
|
||||
|
||||
async def _gpu_info(card: str) -> dict:
|
||||
"""Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen')."""
|
||||
if not card:
|
||||
@@ -873,13 +1431,46 @@ async def _gpu_info(card: str) -> dict:
|
||||
return {"gpu": card}
|
||||
|
||||
|
||||
# Tempo der Netzwerkkarte(n) der Box — der Container sieht nur sein eigenes
|
||||
# eth0, darum liest er das /sys des Rechners (docker-compose: /sys:/hostsys:ro).
|
||||
# Nur echte Karten (mit device-Link), die "up" sind. Fehlt der Mount: [].
|
||||
HOST_SYS = os.getenv("HOST_SYS", "/hostsys")
|
||||
_link_cache: dict = {"ts": 0.0, "links": []}
|
||||
|
||||
|
||||
def _host_links() -> list:
|
||||
if time.time() - _link_cache["ts"] < 60:
|
||||
return _link_cache["links"]
|
||||
links = []
|
||||
base = os.path.join(HOST_SYS, "class", "net")
|
||||
try:
|
||||
for name in sorted(os.listdir(base)):
|
||||
d = os.path.join(base, name)
|
||||
try:
|
||||
if not os.path.exists(os.path.join(d, "device")):
|
||||
continue
|
||||
with open(os.path.join(d, "operstate")) as fh:
|
||||
if fh.read().strip() != "up":
|
||||
continue
|
||||
with open(os.path.join(d, "speed")) as fh:
|
||||
mbit = int(fh.read().strip())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if mbit > 0:
|
||||
links.append({"iface": name, "mbit": mbit})
|
||||
except OSError:
|
||||
pass
|
||||
_link_cache.update(ts=time.time(), links=links)
|
||||
return links
|
||||
|
||||
|
||||
async def _emit_progress(ws, key: str, phase: str, **extra) -> None:
|
||||
await _send(ws, "llm_provision_progress",
|
||||
{"instanceId": INSTANCE_ID, "node": NODE_NAME, "key": key,
|
||||
"phase": phase, "ts": int(time.time() * 1000), **extra})
|
||||
"phase": phase, "ts": int(time.time() * 1000), "links": _host_links(), **extra})
|
||||
|
||||
|
||||
async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> None:
|
||||
async def _watch_download(ws, key: str, files: list, stop: asyncio.Event, final_phase: bool = True) -> None:
|
||||
"""Meldet jede Sekunde den Download-Stand, bis der Warmup fertig ist.
|
||||
Ist alles da, wechselt die Phase auf 'load' (llama.cpp laedt in die Karte)."""
|
||||
oids = {f["oid"] for f in files if f.get("oid")}
|
||||
@@ -906,7 +1497,7 @@ async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> Non
|
||||
# es lag schon im Cache. Im Notfallmodus (keine HF-Liste) zaehlt
|
||||
# nur: es lief ein Download, und jetzt nicht mehr.
|
||||
all_there = bool(oids) and found >= len(oids)
|
||||
if phase != "load" and (all_there or downloaded):
|
||||
if final_phase and phase != "load" and (all_there or downloaded):
|
||||
phase = "load"
|
||||
await _emit_progress(ws, key, "load", bytes=total or b, total=total,
|
||||
percent=100, cached=not downloaded)
|
||||
@@ -922,12 +1513,56 @@ async def _provision_result(ws, key: str, ok: bool, **extra) -> None:
|
||||
|
||||
|
||||
async def _handle_provision(ws, payload: dict) -> None:
|
||||
key = (payload.get("key") or "").strip()
|
||||
if key in _provisions:
|
||||
return await _provision_result(ws, key, False, gpu=str(payload.get("gpu") or ""),
|
||||
error="Fuer dieses Modell laeuft schon ein Auftrag.")
|
||||
_provisions[key] = {"cancel": threading.Event(), "task": asyncio.current_task(), "phase": "prep"}
|
||||
try:
|
||||
await _do_provision(ws, payload, _provisions[key])
|
||||
finally:
|
||||
_provisions.pop(key, None)
|
||||
|
||||
|
||||
async def _download_only(ws, key: str, repo: str, wanted: str, job: dict) -> None:
|
||||
"""Nur auf die Platte der Box laden — keine Karte, keine Registry. Spaeter
|
||||
"Laden" findet die Datei dann als "liegt schon da"."""
|
||||
async def fail(msg: str) -> None:
|
||||
await _emit_progress(ws, key, "error", error=msg, downloadOnly=True)
|
||||
await _provision_result(ws, key, False, error=msg, downloadOnly=True)
|
||||
if not repo:
|
||||
return await fail("hfRepo fehlt")
|
||||
files = await _hf_repo_files(repo)
|
||||
if files is None:
|
||||
return await fail(f"{repo}: nicht gefunden oder HuggingFace nicht erreichbar")
|
||||
quant = _pick_quant(files, wanted) if files else ""
|
||||
if not quant:
|
||||
return await fail(f"In {repo} gibt es keine ladbare GGUF-Datei.")
|
||||
qfiles = _files_for_quant(files, quant)
|
||||
disk_ok, disk_note = await asyncio.to_thread(_check_disk, qfiles)
|
||||
if not disk_ok:
|
||||
return await fail(disk_note)
|
||||
job["phase"] = "download"
|
||||
err = await _prefetch(ws, key, repo, qfiles, "", job["cancel"], strict=True)
|
||||
if err or job["cancel"].is_set():
|
||||
return await fail("Abgebrochen" if (err == "abgebrochen" or job["cancel"].is_set()) else err)
|
||||
await _announce(ws)
|
||||
total = sum(f["size"] for f in qfiles)
|
||||
await _emit_progress(ws, key, "ready", percent=100, bytes=total, total=total, downloadOnly=True, quant=quant,
|
||||
note="; ".join(x for x in (disk_note, f"{quant} liegt auf der Box — in keine Karte geladen") if x))
|
||||
await _provision_result(ws, key, True, downloadOnly=True)
|
||||
logger.info("Nur heruntergeladen: %s (%s, %s)", key, repo, quant)
|
||||
|
||||
|
||||
async def _do_provision(ws, payload: dict, job: dict) -> None:
|
||||
"""Laedt ein Modell in eine LLM-Karte (nur aus der Diagnostic). Ersetzt,
|
||||
was dort lag. Ablauf: Registry + Belegung → Config (llama-swap laedt per
|
||||
--watch-config + Preload) → Warmup, der wartet bis es bereit ist; daneben
|
||||
misst _watch_download den Download."""
|
||||
key = (payload.get("key") or "").strip()
|
||||
repo = (payload.get("hfRepo") or "").strip()
|
||||
if payload.get("downloadOnly"):
|
||||
return await _download_only(ws, key, repo, (payload.get("quant") or "Q4_K_M").strip(), job)
|
||||
cards = _llm_cards()
|
||||
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
|
||||
if not card and len(cards) == 1:
|
||||
@@ -973,10 +1608,38 @@ async def _handle_provision(ws, payload: dict) -> None:
|
||||
if quant != wanted.upper():
|
||||
quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}"
|
||||
logger.info("Karte %s: %s", card, quant_note)
|
||||
# Vor jeder Aenderung an Registry/Belegung: passt es auf die Platte?
|
||||
disk_ok, disk_note = await asyncio.to_thread(_check_disk, _files_for_quant(repo_files, quant))
|
||||
if not disk_ok:
|
||||
await _emit_progress(ws, key, "error", gpu=card, error=disk_note)
|
||||
return await _provision_result(ws, key, False, gpu=card, error=disk_note)
|
||||
if disk_note:
|
||||
quant_note = "; ".join(x for x in (quant_note, disk_note) if x)
|
||||
# Vor dem Umstellen der Karte: die alte bleibt nutzbar, solange geladen wird.
|
||||
job["phase"] = "download"
|
||||
pf = await _prefetch(ws, key, repo, _files_for_quant(repo_files, quant), card, job["cancel"])
|
||||
if pf == "abgebrochen" or job["cancel"].is_set():
|
||||
msg = "Abgebrochen — Karte unveraendert"
|
||||
await _emit_progress(ws, key, "error", gpu=card, error=msg)
|
||||
return await _provision_result(ws, key, False, gpu=card, error=msg)
|
||||
ctx_req = int(payload.get("ctx") or 8192)
|
||||
try:
|
||||
cpu_moe = int(payload.get("cpuMoe") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cpu_moe = 0
|
||||
if cpu_moe == -2:
|
||||
# "automatisch verteilen": so viele Experten auf die Karte wie passen
|
||||
qfiles = _files_for_quant(repo_files, quant) if repo_files else []
|
||||
vram = int(((await _gpu_names()).get(card) or {}).get("vramMB") or 0)
|
||||
if len(qfiles) == 1 and vram:
|
||||
cpu_moe = await _auto_cpu_moe(repo, qfiles[0], vram, ctx_req)
|
||||
else:
|
||||
cpu_moe = -1 # gesplittete Datei / Karte unbekannt: alle in den RAM
|
||||
entry = {
|
||||
"key": key, "hfRepo": repo, "quant": quant,
|
||||
"ctx": int(payload.get("ctx") or 8192),
|
||||
"ctx": ctx_req,
|
||||
"ngl": int(payload.get("ngl") or 99),
|
||||
"cpuMoe": cpu_moe,
|
||||
}
|
||||
_save_registry([e for e in _load_registry() if e.get("key") != key] + [entry])
|
||||
elif not _model_cmd(key):
|
||||
@@ -999,9 +1662,16 @@ async def _handle_provision(ws, payload: dict) -> None:
|
||||
t0 = time.time()
|
||||
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
|
||||
# LLM_TIMEOUT_SEC meldete das frueher "Fehler", waehrend llama-swap weiterlud.
|
||||
res = await _call_llama([{"role": "user", "content": "hi"}],
|
||||
max_tokens=1, temperature=0.0, stop=None, model=key,
|
||||
timeout=WARMUP_TIMEOUT_S)
|
||||
job["phase"] = "llama"
|
||||
try:
|
||||
if job["cancel"].is_set():
|
||||
raise asyncio.CancelledError
|
||||
res = await _call_llama([{"role": "user", "content": "hi"}],
|
||||
max_tokens=1, temperature=0.0, stop=None, model=key,
|
||||
timeout=WARMUP_TIMEOUT_S)
|
||||
except asyncio.CancelledError:
|
||||
# Abbrechen-Knopf: wie ein Fehlschlag behandeln → Karte zuruecksetzen
|
||||
res = {"ok": False, "error": "Abgebrochen"}
|
||||
dt = time.time() - t0
|
||||
stop.set()
|
||||
try:
|
||||
@@ -1042,6 +1712,13 @@ async def _handle_provision(ws, payload: dict) -> None:
|
||||
_save_registry(reg)
|
||||
_generate_config()
|
||||
await _reload_llama()
|
||||
if job["cancel"].is_set():
|
||||
# llama.cpp ist gestoppt — seinen halben Download nicht liegen lassen
|
||||
for f in files:
|
||||
try:
|
||||
os.remove(os.path.join(_repo_dir(repo), "blobs", f["oid"] + _INPROGRESS))
|
||||
except OSError:
|
||||
pass
|
||||
await _announce(ws)
|
||||
note = f" — Karte {card} wieder auf {previous}" if previous else f" — Karte {card} wieder leer"
|
||||
await _emit_llm_status(ws, "error", key, error=err)
|
||||
@@ -1068,10 +1745,24 @@ async def _handle_unload(ws, payload: dict) -> None:
|
||||
|
||||
async def _handle_remove(ws, payload: dict) -> None:
|
||||
"""Modell aus der Registry entfernen (und von seiner Karte, falls belegt).
|
||||
Das GGUF bleibt im Cache."""
|
||||
Das GGUF bleibt im Cache — ausser deleteFiles. cancel = laufenden
|
||||
Download/Ladevorgang abbrechen (gleicher Nachrichtentyp, damit die
|
||||
RVS-Allow-List nicht erweitert werden muss)."""
|
||||
key = (payload.get("key") or "").strip()
|
||||
if not key:
|
||||
return
|
||||
if payload.get("cancel"):
|
||||
job = _provisions.get(key)
|
||||
if not job:
|
||||
# Nichts laeuft (mehr) — haengenden Eintrag im Download-Fenster schliessen
|
||||
return await _provision_result(ws, key, False, error="Abgebrochen (lief nicht mehr)")
|
||||
job["cancel"].set()
|
||||
if job["phase"] == "llama" and job.get("task"):
|
||||
job["task"].cancel()
|
||||
logger.info("Abbruch angefordert: %s (Phase %s)", key, job["phase"])
|
||||
return
|
||||
if payload.get("deleteFiles"):
|
||||
return await _delete_model_files(ws, key, (payload.get("hfRepo") or "").strip())
|
||||
_save_registry([e for e in _load_registry() if e.get("key") != key])
|
||||
slots = _load_slots()
|
||||
for c in [c for c, m in slots.items() if m == key]:
|
||||
@@ -1084,6 +1775,35 @@ async def _handle_remove(ws, payload: dict) -> None:
|
||||
logger.info("removed model %s", key)
|
||||
|
||||
|
||||
async def _delete_model_files(ws, key: str, repo: str) -> None:
|
||||
"""Heruntergeladene Dateien eines Repos von der Platte loeschen. Nicht,
|
||||
solange es auf einer Karte liegt oder gerade geladen wird."""
|
||||
reg = _load_registry()
|
||||
repo = repo or next((e.get("hfRepo") for e in reg if e.get("key") == key), "") or ""
|
||||
if not re.fullmatch(r"\w[\w.-]*/\w[\w.-]*", repo):
|
||||
return await _provision_result(ws, key, False, error=f"Unbekanntes Repo '{repo}' — nichts geloescht")
|
||||
repo_of = {e.get("key"): e.get("hfRepo") for e in reg}
|
||||
busy = [c for c, m in _load_slots().items() if m and repo_of.get(m) == repo]
|
||||
if busy:
|
||||
return await _provision_result(ws, key, False, error=(
|
||||
f"Liegt noch auf Karte {', '.join(busy)} — erst die Karte leeren oder ein anderes Modell laden."))
|
||||
if any(repo_of.get(k) == repo for k in _provisions):
|
||||
return await _provision_result(ws, key, False, error="Wird gerade geladen — erst abbrechen.")
|
||||
path = _repo_dir(repo)
|
||||
freed = sum(m["bytes"] for m in _disk_info()["models"] if m["repo"] == repo)
|
||||
try:
|
||||
await asyncio.to_thread(shutil.rmtree, path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
return await _provision_result(ws, key, False, error=f"Loeschen fehlgeschlagen: {e}")
|
||||
_save_registry([e for e in reg if e.get("hfRepo") != repo])
|
||||
_generate_config()
|
||||
await _announce(ws)
|
||||
await _provision_result(ws, key, True, removed=True, deleted=True, freedBytes=freed)
|
||||
logger.info("Dateien von %s geloescht (%.1f GB)", repo, freed / 1024**3)
|
||||
|
||||
|
||||
async def _handle_models_request(ws, payload: dict) -> None:
|
||||
"""Erkundung fuer Clients: welche Modelle kann ich bei DIESER Box fragen?
|
||||
Jede Box antwortet einzeln (RVS ist ein Broadcast-Raum) — der Client
|
||||
@@ -1099,6 +1819,7 @@ async def _handle_models_request(ws, payload: dict) -> None:
|
||||
|
||||
|
||||
async def _run() -> None:
|
||||
global _current_ws
|
||||
if not RVS_HOST:
|
||||
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
||||
return
|
||||
@@ -1136,6 +1857,9 @@ async def _run() -> None:
|
||||
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
logger.info("RVS verbunden — llm-adapter online")
|
||||
_current_ws = ws
|
||||
if _outbox:
|
||||
await _flush_outbox(ws)
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
ping_task = asyncio.create_task(_worker_register(ws))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -228,6 +228,15 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) ->
|
||||
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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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