Compare commits
@@ -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`
|
||||
@@ -1238,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`
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20601
|
||||
versionName "0.2.6.1"
|
||||
versionCode 20602
|
||||
versionName "0.2.6.2"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.6.1",
|
||||
"version": "0.2.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -35,6 +35,8 @@ interface VoiceButtonProps {
|
||||
onTapStart: () => Promise<boolean>;
|
||||
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
|
||||
onTapStop: () => Promise<void>;
|
||||
/** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */
|
||||
onTapCancel?: () => Promise<void>;
|
||||
/** Button deaktivieren */
|
||||
disabled?: boolean;
|
||||
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
|
||||
@@ -46,6 +48,7 @@ interface VoiceButtonProps {
|
||||
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
onTapStart,
|
||||
onTapStop,
|
||||
onTapCancel,
|
||||
disabled = false,
|
||||
wakeWordActive = false,
|
||||
}) => {
|
||||
@@ -136,6 +139,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
}
|
||||
}, [disabled, onTapStart, onTapStop]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (!onTapCancel || tapBusy.current) return;
|
||||
tapBusy.current = true;
|
||||
try {
|
||||
if (audioService.getRecordingState() === 'recording') await onTapCancel();
|
||||
} finally {
|
||||
tapBusy.current = false;
|
||||
}
|
||||
}, [onTapCancel]);
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const tenths = Math.floor((ms % 1000) / 100);
|
||||
@@ -154,6 +167,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
{wakeWordActive && !isRecording && (
|
||||
<View style={styles.wakeWordDot} />
|
||||
)}
|
||||
{isRecording && onTapCancel && (
|
||||
<TouchableOpacity
|
||||
onPress={handleCancel}
|
||||
accessibilityLabel="Aufnahme verwerfen"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
<Text style={styles.cancelIcon}>🗑</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.buttonOuter,
|
||||
@@ -197,6 +220,22 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#34C759',
|
||||
zIndex: 10,
|
||||
},
|
||||
// Muelleimer links neben dem Aufnahmeknopf — nur waehrend der Aufnahme
|
||||
cancelButton: {
|
||||
position: 'absolute',
|
||||
left: -46,
|
||||
top: 14,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(60, 60, 70, 0.9)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
cancelIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
buttonOuter: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
|
||||
@@ -2624,6 +2624,15 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Muelleimer waehrend der Aufnahme: verwerfen. cancelStreamingRecording
|
||||
// schickt stt_stream_end mit reason "cancel:*" — die STT-Bridge transkribiert
|
||||
// dann nicht, die aria-bridge wirft einen spaeten Text weg, und das leere
|
||||
// Endpoint raeumt die Sprechblase ab. Danach zurueck aufs Wake-Word.
|
||||
const handleVoiceButtonCancel = useCallback(async (): Promise<void> => {
|
||||
console.log('[Chat] Aufnahme verworfen (Muelleimer)');
|
||||
await ariaStopRecording('user-discard');
|
||||
}, [ariaStopRecording]);
|
||||
|
||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||
setShowFileUpload(false);
|
||||
@@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => {
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
onTapCancel={handleVoiceButtonCancel}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
|
||||
+251
-13
@@ -33,6 +33,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage
|
||||
import router as router_mod
|
||||
import metrics
|
||||
from local_llm import local_llm_chat
|
||||
import local_compact
|
||||
import skills as skills_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
@@ -47,6 +48,11 @@ BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
# Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
|
||||
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
|
||||
_NO_MODEL = {"model": "", "requested": "", "reason": ""}
|
||||
|
||||
# Lokale Modelle bewusst waehlen: pro Chat ("local:<modell>" im Modell-Override)
|
||||
# oder fuer eine Frage ("@lokal …" / "@lokal:<modell> …").
|
||||
LOCAL_MODEL_PREFIX = "local:"
|
||||
_AT_LOCAL_RE = re.compile(r"^\s*@lokal(?::([\w.\-]+))?(?=[\s:,]|$)[\s:,]*(.*)$", re.IGNORECASE | re.DOTALL)
|
||||
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
|
||||
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
|
||||
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
||||
@@ -1015,6 +1021,27 @@ META_TOOLS = [
|
||||
"category": {"type": "string", "description": "Neue Kategorie (optional)"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
|
||||
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
|
||||
"type": {"type": "string", "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
|
||||
"description": "Schublade aendern (optional), z.B. fact → tool fuer Infrastruktur. tool/rule/preference/skill/identity werden dabei angepinnt."},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "memory_delete",
|
||||
"description": (
|
||||
"Loesche einen Gedaechtnis-Eintrag, der FALSCH oder UEBERHOLT ist "
|
||||
"(ID aus memory_search oder Cold-Memory). Ist er nur unvollstaendig, "
|
||||
"lieber memory_update. identity-Eintraege kann nur Stefan loeschen."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Memory-ID (UUID)"},
|
||||
"reason": {"type": "string", "description": "Kurz warum (landet im Log)"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
@@ -1026,8 +1053,10 @@ META_TOOLS = [
|
||||
"name": "memory_save",
|
||||
"description": (
|
||||
"Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
|
||||
"Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges "
|
||||
"festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
|
||||
"Nutze das VON DIR AUS, sobald du etwas Nicht-Offensichtliches lernst "
|
||||
"(siehe 'DEIN GEDAECHTNIS' im Prompt) — nicht erst auf 'merk dir das'. "
|
||||
"VORHER memory_search: gibt es schon einen Eintrag, nimm memory_update. "
|
||||
"Keine live nachsehbaren, sich aendernden Staende speichern. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
|
||||
"File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
|
||||
"Type-Wahl:\n"
|
||||
"- identity: ARIAs Selbstbild / Wesensart (PINNED)\n"
|
||||
@@ -1071,7 +1100,7 @@ META_TOOLS = [
|
||||
},
|
||||
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
|
||||
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
|
||||
"pinned": {"type": "boolean", "description": "Weglassen = automatisch nach Schublade (identity/rule/preference/tool/skill angepinnt, fact nicht). Nur angeben, um davon abzuweichen."},
|
||||
"attach_paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
@@ -1268,8 +1297,51 @@ META_TOOLS = [
|
||||
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
|
||||
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
|
||||
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
|
||||
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
|
||||
"hinterlegten FritzBox-Login). 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
|
||||
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete MIT "
|
||||
"Verbindungsart (LAN/WLAN), lan_port (an welcher LAN-Buchse der Box) und "
|
||||
"speed_mbit; 'find' filtert nach Name/IP/MAC (braucht "
|
||||
"hinterlegten FritzBox-Login). FRITZ!BOX VOLLSTAENDIG STEUERN: "
|
||||
"'fritzbox.describe' (params={'ip':'<box oder Name>','filter':'WLAN'}) listet, "
|
||||
"was DIESE Box per TR-064 kann (je Aktion [liest]/[aendert]/[GEFAEHRLICH]); "
|
||||
"'fritzbox.call' (params={'ip':..,'service':'WLANConfiguration:3',"
|
||||
"'action':'GetInfo','args':{...}}) ruft jede davon auf; 'fritzbox.wlan' "
|
||||
"zeigt alle WLANs inkl. Gast (index, ssid, an) und schaltet sie "
|
||||
"(index + enable). Unbekannte Funktion (WPS, DECT, Anrufliste, Internet "
|
||||
"sperren …)? Erst describe mit passendem filter, dann call. REGEL: Alles "
|
||||
"was AENDERT (Set…, WPS, Neustart …) braucht confirmed=true — und das "
|
||||
"setzt Du NUR, nachdem Du Stefan gesagt hast, was genau passiert, und er "
|
||||
"ausdruecklich ja gesagt hat. Ohne confirmed kommt needs_confirmation "
|
||||
"zurueck: dann fragen, nicht raten. [GEFAEHRLICH] (Neustart, "
|
||||
"Werkseinstellung, Portfreigabe, Passwort) besonders deutlich ankuendigen. "
|
||||
"TASTE AN DER BOX: Manche Aktionen (z.B. Sicherung) muss man an der Box "
|
||||
"bestaetigen — dann kommt 'muss an der FritzBox bestaetigt werden'. Frag "
|
||||
"Stefan, ob er an der Box steht; nach seinem Ja erneut mit button_wait=true "
|
||||
"aufrufen, dann hat er 2 Minuten fuer einen Tastendruck (sonst Timeout, "
|
||||
"nichts passiert — einfach nachfragen und neu versuchen). 'fritzbox.backup' "
|
||||
"(params={'ip':..,'button_wait':true}) sichert die Einstellungen — OHNE "
|
||||
"'password': der Satellit nimmt das hinterlegte Sicherungs-Kennwort (das "
|
||||
"Du nicht kennst und nicht kennen sollst); 'password' nur, wenn Stefan "
|
||||
"ausdruecklich ein anderes will. SICHERUNGEN VERWALTEN: 'fritzbox.backups' "
|
||||
"(params={'ip':..}) listet die Sicherungen der Box (datei, erstellt, modell, "
|
||||
"firmware), neueste zuerst. 'fritzbox.backup_view' (params={'ip':..,'file':"
|
||||
"'<datei>'}) zeigt die wichtigsten Einstellungen daraus (Kennwoerter bleiben "
|
||||
"verschluesselt). 'fritzbox.restore' (params={'ip':..,'file':'<datei>',"
|
||||
"'confirmed':true,'button_wait':true}) spielt eine Sicherung ein — GEFAEHRLICH: "
|
||||
"Box uebernimmt alles und startet neu (1–3 min kein Netz); vorher Datum der "
|
||||
"Sicherung nennen und Stefans ausdrueckliches Ja holen, ebenso ob er an der "
|
||||
"Box steht. 'fritzbox.backup_delete' (params={'ip':..,'file':..,'confirmed':true}) "
|
||||
"loescht eine Sicherung (nach Ja). 'fritzbox.backup_get' (params={'ip':..,"
|
||||
"'file':..}) holt eine VORHANDENE Sicherung — sie liegt danach unter "
|
||||
"/shared/uploads/ und kommt mit [FILE: <pfad>] in den Chat. ALLE BOXEN SICHERN: 'fritzbox.backup_all' "
|
||||
"(params={} oder {'button_wait':true}) — pro Satellit einmal aufrufen (jeder "
|
||||
"Satellit sichert seine Boxen mit hinterlegtem Login); nicht erreichbare oder "
|
||||
"fehlerhafte werden uebersprungen, es geht mit der naechsten weiter — am Ende "
|
||||
"die Liste je Box (✓/✗ + Grund) an Stefan. TASTENBESTAETIGUNG: "
|
||||
"'fritzbox.second_factor' zeigt, ob sie an ist und ob sie sich per Schnittstelle "
|
||||
"schalten laesst; 'fritzbox.second_factor_set' (params={'ip':..,'enable':false,"
|
||||
"'confirmed':true,'button_wait':true}) schaltet sie — nur nach Stefans Ja; "
|
||||
"geht es nicht, den Hinweis (Weg ueber die Oberflaeche) weitergeben. die Datei liegt danach unter /shared/uploads/ "
|
||||
"und kommt mit [FILE: <pfad>] in den Chat. 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
|
||||
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
|
||||
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
|
||||
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
|
||||
@@ -2359,7 +2431,8 @@ class Agent:
|
||||
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
|
||||
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
|
||||
|
||||
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
|
||||
# Wie viele der letzten Turns ans lokale Modell gehen, regelt jetzt
|
||||
# local_compact (nach Tokens + LOCAL_MAX_TURNS, aeltere als Notizzettel).
|
||||
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
|
||||
|
||||
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
|
||||
@@ -2370,6 +2443,9 @@ class Agent:
|
||||
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
|
||||
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
|
||||
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
|
||||
# Schubladen, die angepinnt sind (immer im System-Prompt)
|
||||
_PINNED_TYPES = {"identity", "rule", "preference", "tool", "skill"}
|
||||
|
||||
_SOFT_FAIL_MARKERS = (
|
||||
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
|
||||
"konnte nicht", "keine verbindung", "nicht verfuegbar",
|
||||
@@ -2441,6 +2517,61 @@ class Agent:
|
||||
converse = flags["converse"]
|
||||
return speak, converse
|
||||
|
||||
def _local_area_route(self, user_message: str, project_id: str):
|
||||
"""(modell, frage, begruendung) wenn dieser Turn bewusst lokal laufen
|
||||
soll, sonst None. modell "" = das gerade geladene lokale Modell."""
|
||||
msg = router_mod._strip_leading_hint_blocks(user_message)
|
||||
m = _AT_LOCAL_RE.match(msg)
|
||||
if m:
|
||||
return (m.group(1) or "").strip(), (m.group(2) or "").strip() or msg, "einmalig"
|
||||
ov = context_model.get(project_id)
|
||||
if ov.startswith(LOCAL_MODEL_PREFIX):
|
||||
return ov[len(LOCAL_MODEL_PREFIX):], msg, context_model.REASON_PER_CHAT
|
||||
return None
|
||||
|
||||
def _local_area_turn(self, question: str, project_id: str, model: str) -> tuple:
|
||||
"""Ein ganzer Turn auf dem lokalen Modell — ohne Werkzeuge, ohne Claude.
|
||||
Gibt (antwort, benutztes_modell). Fehler werden NICHT als ARIAs Antwort
|
||||
in den Verlauf geschrieben (nur angezeigt)."""
|
||||
sys_prompt = "\n".join([
|
||||
IDENTITY_ANCHOR.strip(), "",
|
||||
"## LOKALES MODELL",
|
||||
"Du laeufst gerade als lokales Modell auf Stefans eigener Hardware — er hat "
|
||||
"dieses Modell fuer diese Frage bzw. diesen Chat bewusst gewaehlt. Antworte "
|
||||
"vollstaendig und sachlich auf Deutsch, als ARIA. Du hast hier KEINE "
|
||||
"Werkzeuge (kein Internet, kein Gedaechtnis, keine Aktionen): behaupte nie, "
|
||||
"etwas nachgesehen oder ausgefuehrt zu haben. Weisst du etwas nicht sicher, "
|
||||
"sag das. Normaler Text, kein <voice>, kein [FILE:], keine Tool-Aufrufe.",
|
||||
])
|
||||
notes = local_compact.summary_block(project_id)
|
||||
if notes:
|
||||
sys_prompt += "\n\n" + notes
|
||||
all_turns = self.conversation.window(project_id=project_id)
|
||||
ctx = local_compact.model_ctx(model) if model else local_compact.DEFAULT_CTX * 4
|
||||
budget = int(ctx * 0.6) - 2500 - local_compact.est_tokens(sys_prompt)
|
||||
window = local_compact.fit_window(all_turns, max(budget, 512))
|
||||
messages = [{"role": "system", "content": sys_prompt}]
|
||||
messages += [{"role": t.role, "content": t.content} for t in window]
|
||||
if question and len(messages) > 1 and messages[-1]["role"] == "user":
|
||||
messages[-1]["content"] = question # ohne "@lokal" davor
|
||||
res = local_llm_chat(messages, max_tokens=2000, temperature=0.6,
|
||||
model=model or None, timeout=600)
|
||||
used = res.get("model") or model or "lokales Modell"
|
||||
if not res.get("ok"):
|
||||
logger.warning("[lokal] %s nicht erreichbar: %s", model or "(geladenes)", res.get("error"))
|
||||
return (f"⚠ Das lokale Modell {model or ''} hat nicht geantwortet: "
|
||||
f"{res.get('error', 'unbekannt')}. Liegt es auf einer Karte? "
|
||||
"(Diagnostic → LLM-Karten)").replace(" ", " "), used
|
||||
try:
|
||||
metrics.log_local_call(used, messages, res.get("content") or "", res.get("usage"))
|
||||
except Exception:
|
||||
pass
|
||||
reply = re.sub(r"<think>[\s\S]*?</think>", "", res.get("content") or "")
|
||||
reply = re.sub(r"</?voice>", "", reply).strip() or "[Das lokale Modell lieferte keine Antwort.]"
|
||||
self.conversation.add("assistant", reply, project_id=project_id)
|
||||
logger.info("[lokal] %s beantwortet (%s)", used, project_id or "Hauptchat")
|
||||
return reply, used
|
||||
|
||||
def _try_local_fast_lane(self, user_message: str,
|
||||
active_project_id: str) -> Optional[str]:
|
||||
cfg = router_mod.load_config()
|
||||
@@ -2476,9 +2607,21 @@ class Agent:
|
||||
|
||||
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
|
||||
has_tools=bool(tools))
|
||||
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
|
||||
# wuerde das Prefill aufblaehen).
|
||||
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
|
||||
# Compacting (local_compact): aeltere Nachrichten als Notizzettel im
|
||||
# Systemprompt, dazu so viele der letzten Nachrichten, wie ins
|
||||
# Kontextfenster der Karte passen (hoechstens LOCAL_MAX_TURNS — Tempo).
|
||||
notes = local_compact.summary_block(active_project_id)
|
||||
if notes:
|
||||
sys_prompt += "\n\n" + notes
|
||||
all_turns = self.conversation.window(project_id=active_project_id)
|
||||
ctx = local_compact.model_ctx(local_model)
|
||||
budget = int(ctx * 0.75) - 500 - local_compact.est_tokens(sys_prompt)
|
||||
window = local_compact.fit_window(all_turns, max(budget, 256))
|
||||
try:
|
||||
local_compact.maybe_compact(active_project_id, all_turns, window,
|
||||
local_model, local_llm_chat)
|
||||
except Exception as exc:
|
||||
logger.debug("[compact] uebersprungen: %s", exc)
|
||||
messages = [{"role": "system", "content": sys_prompt}]
|
||||
messages += [{"role": t.role, "content": t.content} for t in window]
|
||||
|
||||
@@ -2604,6 +2747,13 @@ class Agent:
|
||||
result = self.proxy.chat_full(messages, tools=tools, model=model,
|
||||
project_id=project_id)
|
||||
if not safeguard.looks_like_block(result.content, result.is_error):
|
||||
if result.is_error:
|
||||
# Die CLI meldet selbst einen Fehler (Anmeldung abgelaufen,
|
||||
# Limit, …) — das ist KEINE Antwort von ARIA. Frueher landete
|
||||
# "Failed to authenticate: OAuth session expired" als ihre
|
||||
# Antwort im Verlauf. Jetzt: wie jede Ausnahme behandeln
|
||||
# (klar markierter [Fehler: …]-Eintrag, Stefan sieht den Fehler).
|
||||
raise RuntimeError(f"Claude meldet einen Fehler: {(result.content or '').strip()[:300]}")
|
||||
return result, model
|
||||
|
||||
code = safeguard.detail_code(result.content)
|
||||
@@ -2719,6 +2869,19 @@ class Agent:
|
||||
if active_project_id:
|
||||
projects_mod.touch_project(active_project_id)
|
||||
|
||||
# Lokales Modell bewusst gewaehlt — Claude ist fuer diesen Turn raus:
|
||||
# * "@lokal …" / "@lokal:<modell> …" nur fuer diese eine Frage
|
||||
# * Chat steht (🧠, pro Chat) auf "local:<modell>"
|
||||
# Stefan entscheidet das selbst (z.B. ein Pentest-Projekt auf einem
|
||||
# unzensierten Modell); nichts wird automatisch umgeleitet.
|
||||
area = self._local_area_route(user_message, active_project_id)
|
||||
if area is not None:
|
||||
model, question, why = area
|
||||
reply, used = self._local_area_turn(question, active_project_id, model)
|
||||
converse = not _wants_end
|
||||
return reply, "local", True, converse, False, {
|
||||
"model": used, "requested": model or "lokal", "reason": why}
|
||||
|
||||
# Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
|
||||
# /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
|
||||
# ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
|
||||
@@ -2791,6 +2954,14 @@ class Agent:
|
||||
oauth_callback_host=oauth_host,
|
||||
oauth_callback_port=oauth_port,
|
||||
oauth_callback_tls=oauth_tls)
|
||||
# Bei einer Ablehnung: Stefan kann es bewusst an ein lokales Modell geben.
|
||||
system_prompt += (
|
||||
"\n\n## LOKALE MODELLE ALS AUSWEG\n"
|
||||
"Lehnst du eine Anfrage ab (Richtlinien), sag Stefan kurz, dass er sie selbst "
|
||||
"an ein lokales Modell auf seiner eigenen Hardware geben kann: mit "
|
||||
"`@lokal <Frage>` fuer diese eine Frage, oder indem er den Chat oben (🧠) "
|
||||
"auf ein Modell unter „Lokal“ stellt. Leite NICHT selbst weiter und gib "
|
||||
"den Inhalt nicht selbst an ein Werkzeug — die Entscheidung trifft er.")
|
||||
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
|
||||
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
|
||||
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
|
||||
@@ -3178,13 +3349,22 @@ class Agent:
|
||||
if not sat or not action:
|
||||
return "FEHLER: satellite und action sind Pflicht."
|
||||
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
|
||||
# Kennwoerter im Klartext gehen nie ans Sprachmodell — die zeigt nur
|
||||
# das Diagnostic (👁) in Stefans Browser.
|
||||
if action in ("fritzbox.backup_password",):
|
||||
return ("FEHLER: Kennwoerter zeige ich nicht an — Stefan sieht es im Diagnostic "
|
||||
"(Satelliten → Geraet → 💾 → 👁 bei der Sicherung).")
|
||||
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
|
||||
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
|
||||
# obwohl die Installation noch laeuft.
|
||||
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
|
||||
# Installation koennen zusammen mehrere Minuten dauern.
|
||||
sat_timeout = (320.0 if action.startswith("adb.")
|
||||
else 200.0 if action == "ssh.exec" else 20.0)
|
||||
else 200.0 if action == "ssh.exec"
|
||||
# button_wait: bis 2 min auf die Taste an der FritzBox
|
||||
else 900.0 if action == "fritzbox.backup_all"
|
||||
else 200.0 if action.startswith("fritzbox.") and params.get("button_wait")
|
||||
else 60.0 if action.startswith("fritzbox.") else 20.0)
|
||||
result = _post("/internal/satellite", {
|
||||
"op": "command", "satellite": sat,
|
||||
"device": arguments.get("device") or "", "action": action, "params": params,
|
||||
@@ -3192,7 +3372,18 @@ class Agent:
|
||||
}, sat_timeout + 20)
|
||||
if result.get("ok"):
|
||||
r = result.get("result")
|
||||
if isinstance(r, dict) and r.get("file_b64"):
|
||||
# Datei vom Satelliten (z.B. FritzBox-Sicherung) → /shared/uploads/
|
||||
import base64
|
||||
fname = re.sub(r"[^A-Za-z0-9._-]", "_", str(r.get("filename") or "satellit_datei"))
|
||||
path = f"/shared/uploads/sat_{int(time.time())}_{fname}"
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(base64.b64decode(r.pop("file_b64")))
|
||||
r["im_chat_ablegbar"] = path
|
||||
r["hinweis_chat"] = f"In den Chat legen mit [FILE: {path}]"
|
||||
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
|
||||
if result.get("needs_button") or result.get("needs_confirmation") or result.get("timeout"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||
@@ -4391,6 +4582,15 @@ class Agent:
|
||||
existing.category = str(arguments["category"]).strip()
|
||||
if "tags" in arguments and arguments["tags"] is not None:
|
||||
existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
|
||||
if arguments.get("type"):
|
||||
new_type = str(arguments["type"]).strip()
|
||||
if new_type not in {"identity", "rule", "preference", "tool", "skill",
|
||||
"fact", "conversation", "reminder"}:
|
||||
return f"FEHLER: unbekannter type {new_type!r}."
|
||||
if new_type != existing.type:
|
||||
existing.type = new_type
|
||||
if arguments.get("pinned") is None:
|
||||
existing.pinned = new_type in self._PINNED_TYPES
|
||||
if "pinned" in arguments and arguments["pinned"] is not None:
|
||||
existing.pinned = bool(arguments["pinned"])
|
||||
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
@@ -4418,6 +4618,29 @@ class Agent:
|
||||
except Exception as e:
|
||||
logger.exception("memory_update fehlgeschlagen")
|
||||
return f"FEHLER: {e}"
|
||||
if name == "memory_delete":
|
||||
pid = (arguments.get("id") or "").strip()
|
||||
reason = (arguments.get("reason") or "").strip()
|
||||
if not pid:
|
||||
return "FEHLER: id ist Pflicht."
|
||||
existing = self.store.get(pid)
|
||||
if not existing:
|
||||
return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
|
||||
if existing.type == "identity":
|
||||
return "FEHLER: identity-Eintraege loescht nur Stefan (Diagnostic)."
|
||||
try:
|
||||
self.store.delete(pid)
|
||||
logger.info("[memory] geloescht: %r (%s) — Grund: %s", existing.title, pid[:8], reason or "-")
|
||||
self._pending_events.append({
|
||||
"type": "memory_saved", "action": "deleted",
|
||||
"memory": {"id": pid, "type": existing.type, "title": existing.title,
|
||||
"content_preview": (existing.content or "")[:140],
|
||||
"category": existing.category, "pinned": existing.pinned},
|
||||
})
|
||||
return f"OK — Memory '{existing.title}' geloescht."
|
||||
except Exception as e:
|
||||
logger.exception("memory_delete fehlgeschlagen")
|
||||
return f"FEHLER: {e}"
|
||||
if name == "memory_save":
|
||||
title = (arguments.get("title") or "").strip()
|
||||
content = (arguments.get("content") or "").strip()
|
||||
@@ -4431,7 +4654,13 @@ class Agent:
|
||||
category = (arguments.get("category") or "").strip()
|
||||
tags_in = arguments.get("tags") or []
|
||||
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
|
||||
pinned = bool(arguments.get("pinned", False))
|
||||
# Schubladen identity/rule/preference/tool/skill sind laut
|
||||
# Beschreibung ANGEPINNT — frueher nur, wenn pinned=true explizit
|
||||
# mitkam (Default false), Infrastruktur landete so im kalten Teil.
|
||||
if arguments.get("pinned") is None:
|
||||
pinned = mem_type in self._PINNED_TYPES
|
||||
else:
|
||||
pinned = bool(arguments.get("pinned"))
|
||||
attach_paths_in = arguments.get("attach_paths") or []
|
||||
attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
|
||||
try:
|
||||
@@ -4681,13 +4910,22 @@ class Agent:
|
||||
|
||||
# Konversation als Klartext bauen
|
||||
transcript = "\n".join(
|
||||
f"[{t.role.upper()}] {t.content}" for t in old_turns
|
||||
f"[{'STEFAN' if t.role == 'user' else 'ARIA'}] {t.content}" for t in old_turns
|
||||
)[:30000] # Cap auf 30k Zeichen damit der Prompt nicht explodiert
|
||||
|
||||
system = (
|
||||
"Du extrahierst aus einer Konversation zwischen Stefan und ARIA die "
|
||||
"wichtigsten dauerhaft relevanten Fakten — keine Smalltalk-Details, "
|
||||
"keine flüchtigen Zustände. Antworte AUSSCHLIESSLICH mit gültigem JSON "
|
||||
"keine flüchtigen Zustände.\n"
|
||||
"QUELLE: Fakten kommen aus dem, was STEFAN sagt, entscheidet oder "
|
||||
"bestätigt. ARIAs Nachrichten sind Berichte und können falsch sein — "
|
||||
"übernimm daraus nur, was Stefan danach bestätigt hat.\n"
|
||||
"NICHT aufnehmen: Systemzustände, die man live nachsehen kann und die "
|
||||
"sich ändern (Projekt-/Ticket-Nummern im Fluss, Ticket-Status, Zeilen-"
|
||||
"zahlen, Commit-IDs, wer gerade woran arbeitet, 'gerade nachgesehen'-"
|
||||
"Befunde). Gut sind: Entscheidungen und ihr Grund, Stefans Vorlieben, "
|
||||
"dauerhafte Regeln/Abläufe, wo etwas läuft (falls Stefan es bestätigt).\n"
|
||||
"Antworte AUSSCHLIESSLICH mit gültigem JSON "
|
||||
"im Format: {\"facts\": [{\"title\": \"kurz, max 80 Zeichen\", "
|
||||
"\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. "
|
||||
"Mindestens 0, höchstens 7 Facts. Wenn nichts wichtig genug ist: leeres Array."
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Compacting fuer ARIAs lokalen Modus (Fast-Lane / localOnly).
|
||||
|
||||
Ein lokales Modell hat ein kleines Kontextfenster (z.B. 32k auf einer
|
||||
12-GB-Karte). Frueher bekam es starr die letzten 8 Nachrichten — alles davor
|
||||
war fuer das lokale Modell einfach weg. Jetzt:
|
||||
|
||||
* Fenster nach TOKENS statt fester Anzahl: so viele der letzten Nachrichten,
|
||||
wie in ~75 % des Kontextfensters passen (Systemprompt + Antwortplatz
|
||||
abgezogen), hoechstens LOCAL_MAX_TURNS (Tempo).
|
||||
* Was aus dem Fenster faellt, fasst das lokale Modell im HINTERGRUND zu
|
||||
einer laufenden Zusammenfassung zusammen ("Notizzettel"). Die liegt pro
|
||||
Projekt in /data/local_summaries.json und geht bei jeder lokalen Anfrage
|
||||
als "Bisheriger Verlauf" in den Systemprompt.
|
||||
|
||||
Unabhaengig davon laeuft weiter das Destillat (agent.distill_old_turns):
|
||||
dauerhaft Wichtiges wandert als Fakt ins Gedaechtnis (Qdrant). Compacting =
|
||||
Notizzettel fuer DIESES Gespraech, Destillat = Karteikarten fuers Archiv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
SUMMARY_FILE = Path(os.environ.get("LOCAL_SUMMARY_FILE", "/data/local_summaries.json"))
|
||||
LOCAL_MAX_TURNS = int(os.environ.get("LOCAL_MAX_TURNS", "12"))
|
||||
DEFAULT_CTX = 8192
|
||||
CHARS_PER_TOKEN = 2.8 # Deutsch/Code: eher vorsichtig schaetzen
|
||||
COMPACT_MIN_TURNS = 4 # erst ab so vielen neu herausgefallenen Nachrichten
|
||||
SUMMARY_MAX_TOKENS = 450
|
||||
|
||||
_lock = threading.Lock()
|
||||
_running: set = set() # Projekte, fuer die gerade verdichtet wird
|
||||
_ctx_cache: dict = {"ts": 0.0, "models": {}}
|
||||
|
||||
|
||||
def est_tokens(text: str) -> int:
|
||||
return int(len(text or "") / CHARS_PER_TOKEN) + 4
|
||||
|
||||
|
||||
def model_ctx(model: str) -> int:
|
||||
"""Kontextfenster des lokalen Modells laut Cluster (60 s gecacht)."""
|
||||
now = time.time()
|
||||
if now - _ctx_cache["ts"] > 60:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{BRIDGE_URL}/internal/llm-models", timeout=3) as r:
|
||||
data = json.loads(r.read().decode("utf-8", "ignore"))
|
||||
_ctx_cache["models"] = {m.get("model"): int(m.get("ctx") or 0)
|
||||
for m in data.get("models") or []}
|
||||
_ctx_cache["ts"] = now
|
||||
except Exception as exc:
|
||||
logger.debug("[compact] Kontextfenster nicht abfragbar: %s", exc)
|
||||
_ctx_cache["ts"] = now - 50 # in 10 s nochmal versuchen
|
||||
return _ctx_cache["models"].get(model) or DEFAULT_CTX
|
||||
|
||||
|
||||
def fit_window(turns: list, budget_tokens: int) -> list:
|
||||
"""Die neuesten Nachrichten, die ins Budget passen (mind. die letzte)."""
|
||||
out, used = [], 0
|
||||
for t in reversed(turns[-LOCAL_MAX_TURNS:]):
|
||||
need = est_tokens(t.content)
|
||||
if out and used + need > budget_tokens:
|
||||
break
|
||||
out.append(t)
|
||||
used += need
|
||||
return list(reversed(out))
|
||||
|
||||
|
||||
# ── Laufende Zusammenfassung pro Projekt ─────────────────────────────
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
return json.loads(SUMMARY_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_summary(project_id: str) -> dict:
|
||||
"""{text, upto_ts} — upto_ts = Zeitstempel der juengsten verdichteten Nachricht."""
|
||||
s = _load().get(project_id or "", {})
|
||||
return {"text": s.get("text", ""), "upto_ts": s.get("upto_ts", "")}
|
||||
|
||||
|
||||
def _save_summary(project_id: str, text: str, upto_ts: str) -> None:
|
||||
with _lock:
|
||||
data = _load()
|
||||
data[project_id or ""] = {"text": text, "upto_ts": upto_ts,
|
||||
"updated": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
||||
tmp = SUMMARY_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(SUMMARY_FILE)
|
||||
|
||||
|
||||
def reset(project_id=None) -> None:
|
||||
"""Zusammenfassung(en) verwerfen (z.B. beim Zuruecksetzen des Gespraechs)."""
|
||||
with _lock:
|
||||
if project_id is None:
|
||||
try:
|
||||
SUMMARY_FILE.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return
|
||||
data = _load()
|
||||
data.pop(project_id or "", None)
|
||||
SUMMARY_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
|
||||
|
||||
def summary_block(project_id: str) -> str:
|
||||
s = get_summary(project_id).get("text", "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
return ("## BISHERIGER VERLAUF (Zusammenfassung aelterer Nachrichten)\n"
|
||||
"Das ist eine Zusammenfassung dessen, was vor den letzten Nachrichten "
|
||||
"besprochen wurde. Nutze es als Hintergrund, zitiere es nicht.\n" + s)
|
||||
|
||||
|
||||
def maybe_compact(project_id: str, all_turns: list, window: list, model: str,
|
||||
chat_fn) -> None:
|
||||
"""Startet im Hintergrund eine Verdichtung, wenn genug Nachrichten aus dem
|
||||
Fenster gefallen sind, die noch nicht im Notizzettel stehen.
|
||||
chat_fn = local_llm_chat (wird nur im Hintergrund-Thread aufgerufen)."""
|
||||
if not window:
|
||||
return
|
||||
first_kept = window[0].ts
|
||||
upto = get_summary(project_id)["upto_ts"]
|
||||
dropped = [t for t in all_turns if t.ts < first_kept and t.ts > upto]
|
||||
if len(dropped) < COMPACT_MIN_TURNS:
|
||||
return
|
||||
key = project_id or ""
|
||||
with _lock:
|
||||
if key in _running:
|
||||
return
|
||||
_running.add(key)
|
||||
threading.Thread(target=_compact, args=(key, dropped, model, chat_fn),
|
||||
daemon=True, name=f"compact-{key or 'main'}").start()
|
||||
|
||||
|
||||
def _compact(project_id: str, dropped: list, model: str, chat_fn) -> None:
|
||||
try:
|
||||
prev = get_summary(project_id)["text"]
|
||||
ctx = model_ctx(model)
|
||||
# Was in die Verdichtungs-Anfrage passt: ~60 % des Fensters
|
||||
room = int(ctx * 0.6 * CHARS_PER_TOKEN) - len(prev) - 2000
|
||||
lines, used, last_ts = [], 0, ""
|
||||
for t in dropped: # aelteste zuerst
|
||||
who = "Stefan" if t.role == "user" else "ARIA"
|
||||
line = f"{who}: {t.content.strip()}"
|
||||
if used + len(line) > room:
|
||||
line = line[: max(0, room - used)] + " …"
|
||||
lines.append(line)
|
||||
used += len(line)
|
||||
last_ts = t.ts
|
||||
if used >= room:
|
||||
break # Rest beim naechsten Durchlauf
|
||||
system = ("Du fuehrst einen laufenden Notizzettel ueber ein Gespraech zwischen "
|
||||
"Stefan und seiner Assistentin ARIA. Schreibe auf Deutsch eine knappe "
|
||||
"Zusammenfassung (hoechstens ~250 Woerter, Stichpunkte erlaubt): worum "
|
||||
"es ging, was entschieden oder erledigt wurde, offene Punkte, wichtige "
|
||||
"Namen/Zahlen. Kein Smalltalk, keine Einleitung, nur der Notizzettel.")
|
||||
user = ((f"Bisheriger Notizzettel:\n{prev}\n\n" if prev else "")
|
||||
+ "Diese Nachrichten kommen neu dazu:\n" + "\n".join(lines)
|
||||
+ "\n\nSchreibe den aktualisierten Notizzettel.")
|
||||
res = chat_fn([{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
max_tokens=SUMMARY_MAX_TOKENS, temperature=0.2, model=model, timeout=180)
|
||||
text = (res.get("content") or "").strip() if res.get("ok") else ""
|
||||
if not text:
|
||||
logger.warning("[compact] Verdichtung fehlgeschlagen (%s) — Notizzettel bleibt",
|
||||
res.get("error"))
|
||||
return
|
||||
_save_summary(project_id, text, last_ts)
|
||||
logger.info("[compact] %d Nachrichten verdichtet (Projekt %r) → %d Zeichen Notizzettel",
|
||||
len(dropped), project_id, len(text))
|
||||
except Exception as exc:
|
||||
logger.warning("[compact] Fehler: %s", exc)
|
||||
finally:
|
||||
with _lock:
|
||||
_running.discard(project_id or "")
|
||||
@@ -30,7 +30,7 @@ LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC",
|
||||
|
||||
def local_llm_chat(messages: list, *, max_tokens: int = 512,
|
||||
temperature: float = 0.7, stop=None, tools=None,
|
||||
model=None) -> dict:
|
||||
model=None, timeout=None) -> dict:
|
||||
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
|
||||
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
|
||||
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten.
|
||||
@@ -44,13 +44,15 @@ def local_llm_chat(messages: list, *, max_tokens: int = 512,
|
||||
req["tools"] = tools
|
||||
if model:
|
||||
req["model"] = model
|
||||
if timeout:
|
||||
req["timeout"] = timeout # Bridge-seitige Frist (sonst 30 s)
|
||||
try:
|
||||
body = json.dumps(req).encode("utf-8")
|
||||
http_req = urllib.request.Request(
|
||||
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(http_req, timeout=LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
|
||||
with urllib.request.urlopen(http_req, timeout=(timeout + 5) if timeout else LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8", "ignore"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
|
||||
+20
-1
@@ -1190,6 +1190,11 @@ def conversation_reset():
|
||||
"""Hardes Reset — der Rolling-Window-Verlauf wird komplett geleert.
|
||||
Destillierte facts bleiben in der DB."""
|
||||
conversation().reset()
|
||||
try:
|
||||
import local_compact
|
||||
local_compact.reset() # Notizzettel des lokalen Modus gehoert dazu
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "turns": 0}
|
||||
|
||||
|
||||
@@ -1338,11 +1343,25 @@ def _context_model_rows() -> dict:
|
||||
"reason": reason,
|
||||
"blocked": sorted(blocked),
|
||||
})
|
||||
choices = _model_choices()
|
||||
choices = _model_choices() + _local_model_choices()
|
||||
names = {c["id"]: c["name"] for c in choices}
|
||||
return {"default": default, "rows": rows, "choices": choices, "names": names}
|
||||
|
||||
|
||||
def _local_model_choices() -> list:
|
||||
"""Lokale Modelle, die gerade auf einer Karte liegen (ARIA-Cluster ueber
|
||||
die Bridge) — als "local:<modell>" waehlbar pro Chat. Fail-soft: []."""
|
||||
try:
|
||||
import httpx as _httpx
|
||||
base = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090").rstrip("/")
|
||||
data = _httpx.get(f"{base}/internal/llm-models", timeout=4).json()
|
||||
return [{"id": "local:" + m["model"], "name": "🖥 lokal: " + m["model"], "section": "local"}
|
||||
for m in (data.get("models") or []) if m.get("model")]
|
||||
except Exception as exc:
|
||||
logger.info("[context-model] lokale Modelle nicht abrufbar: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/context-model/list")
|
||||
def context_model_list():
|
||||
return _context_model_rows()
|
||||
|
||||
+71
-5
@@ -292,6 +292,70 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
MEMORY_CARE = """## DEIN GEDAECHTNIS — so pflegst du es
|
||||
|
||||
Zwischen zwei Nachrichten behaeltst du NICHTS. Was du spaeter wissen musst, steht
|
||||
nur dann wieder vor dir, wenn es im Gedaechtnis liegt — angepinnt (immer im
|
||||
Prompt), im Verzeichnis unten (Titel + Merkzeile) oder als Treffer der Suche.
|
||||
Darum pflegst du es selbst, ohne dass Stefan "merk dir das" sagen muss.
|
||||
|
||||
### Wann speichern (von dir aus)
|
||||
- Du lernst etwas, das man sonst muehsam neu herausfinden muesste: wo etwas
|
||||
laeuft, welches System Test und welches Produktion ist, wie man hinkommt, wie
|
||||
ein Ablauf funktioniert, was Stefan entschieden hat und WARUM.
|
||||
- Stefan korrigiert dich oder bestaetigt einen nicht offensichtlichen Weg.
|
||||
- Sofort, nicht "spaeter" — am Ende des Gespraechs bist du es nicht mehr.
|
||||
|
||||
### Was NICHT ins Gedaechtnis gehoert
|
||||
- Was man jederzeit live nachsehen kann und sich laufend aendert: Ticket-Staende,
|
||||
Projekt-/Ticket-Nummern im Fluss, Zeilenzahlen, "wer arbeitet gerade woran".
|
||||
Das wird NACHGESEHEN, nicht erinnert.
|
||||
- Deine eigenen Berichte oder Vermutungen, die du nicht in diesem Zug geprueft hast.
|
||||
- Was ohnehin im Code, in Git oder in der Doku steht.
|
||||
|
||||
### Welche Schublade
|
||||
- `tool` (angepinnt): Infrastruktur und Umgebungen — Hosts, Zugaenge, welche
|
||||
Instanz Test und welche Produktion ist. Das muss IMMER vor dir liegen.
|
||||
- `rule` / `preference` (angepinnt): harte Regeln, Stefans Arbeitsweise.
|
||||
- `fact`: alles andere (nur per Suche sichtbar). Der Titel ist die Merkzeile im
|
||||
Verzeichnis — schreib ihn so, dass du beim Lesen weisst, wann du nachschlagen
|
||||
musst (z.B. "Schmiede: Prod 172.0.2.46, Test aria-wohnung" statt "Software Schmiede").
|
||||
|
||||
### Pflegen statt anhaeufen
|
||||
- VOR dem Speichern mit `memory_search` pruefen, ob es schon einen Eintrag gibt.
|
||||
Gibt es einen: `memory_update` statt eines zweiten Eintrags.
|
||||
- Hat sich etwas geaendert (z.B. eine "alte" Kopie ist jetzt deine Testumgebung):
|
||||
den Eintrag SOFORT berichtigen.
|
||||
- Ist ein Eintrag falsch oder ueberholt: `memory_delete`.
|
||||
|
||||
### Erinnern ist nicht Wissen
|
||||
- Ein Eintrag gibt den Stand von damals wieder (Datum steht dabei). Bevor du
|
||||
Stefan einen Systemzustand als Tatsache nennst, pruefe ihn live.
|
||||
- Jeder Stand-Bericht nennt seine Quelle: WO und WOMIT du nachgesehen hast und
|
||||
wann ("Prod-Board 172.0.2.46, gerade eben"). Hast du in diesem Zug nicht
|
||||
nachgesehen, sag das — und schau nach, statt aus dem Verlauf weiterzuschreiben.
|
||||
- Widerspricht ein neuer Befund einem frueheren aus dem Verlauf: erst klaeren,
|
||||
WO du jeweils nachgesehen hast, bevor du einen davon fuer falsch erklaerst."""
|
||||
|
||||
|
||||
def build_memory_care_section() -> str:
|
||||
return MEMORY_CARE
|
||||
|
||||
|
||||
def _mem_date(p: MemoryPoint) -> str:
|
||||
d = (p.updated_at or p.created_at or "")[:10]
|
||||
return f" (Stand {d})" if d else ""
|
||||
|
||||
|
||||
def _mem_hook(p: MemoryPoint, n: int = 110) -> str:
|
||||
"""Erste Zeile/Satz des Inhalts als Merkzeile fuers Verzeichnis."""
|
||||
c = " ".join((p.content or "").split())
|
||||
if len(c) <= n:
|
||||
return c
|
||||
cut = c[:n]
|
||||
return cut[: cut.rfind(" ")] + " …" if " " in cut else cut + " …"
|
||||
|
||||
|
||||
def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
||||
"""Baue 'Moeglicherweise relevant'-Block aus Search-Treffern."""
|
||||
if not matches:
|
||||
@@ -299,7 +363,7 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
||||
lines = ["## Moeglicherweise relevant (aus Gedaechtnis)"]
|
||||
for p in matches:
|
||||
score = f" [score={p.score:.2f}]" if p.score is not None else ""
|
||||
lines.append(f"- **{p.title}**{score}")
|
||||
lines.append(f"- **{p.title}**{_mem_date(p)}{score}")
|
||||
lines.append(f" {p.content.strip()}")
|
||||
att_line = _attachments_line(p)
|
||||
if att_line:
|
||||
@@ -322,8 +386,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
|
||||
|
||||
lines = [
|
||||
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
|
||||
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
|
||||
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
|
||||
"Diese Eintraege hast DU gespeichert — hier Titel und Merkzeile, nicht der "
|
||||
"ganze Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
|
||||
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
|
||||
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
|
||||
"",
|
||||
@@ -332,7 +396,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
|
||||
items = grouped[cat]
|
||||
lines.append(f"### {cat}")
|
||||
for p in items:
|
||||
lines.append(f"- {p.title}")
|
||||
hook = _mem_hook(p)
|
||||
lines.append(f"- {p.title}{_mem_date(p)}" + (f" — {hook}" if hook and hook != p.title else ""))
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@@ -555,7 +620,8 @@ def build_system_prompt(
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
|
||||
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_memory_care_section(),
|
||||
"", build_time_section(),
|
||||
"", build_voice_flow_section()]
|
||||
if skills:
|
||||
parts.append("")
|
||||
|
||||
+29
-3
@@ -3449,6 +3449,16 @@ class ARIABridge:
|
||||
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
|
||||
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
|
||||
req_id = payload.get("requestId", "") or ""
|
||||
# Verworfen (Muelleimer in der App)? Dann darf ein trotzdem noch
|
||||
# kommender Text NICHT an ARIA — auch wenn eine alte STT-Bridge ihn
|
||||
# noch transkribiert.
|
||||
if req_id and str(payload.get("reason", "")).startswith("cancel"):
|
||||
if not hasattr(self, "_stt_cancelled"):
|
||||
self._stt_cancelled = {}
|
||||
self._stt_cancelled[req_id] = time.time()
|
||||
for rid, t in list(self._stt_cancelled.items()):
|
||||
if time.time() - t > 120:
|
||||
self._stt_cancelled.pop(rid, None)
|
||||
if req_id:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -3473,6 +3483,9 @@ class ARIABridge:
|
||||
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
|
||||
payload.get("reason", ""))
|
||||
return
|
||||
if (payload.get("requestId") or "") in getattr(self, "_stt_cancelled", {}):
|
||||
logger.info("[rvs] stt_endpoint einer verworfenen Aufnahme — ignoriert")
|
||||
return
|
||||
audio_request_id = payload.get("audioRequestId", "") or ""
|
||||
voice = payload.get("voice", "") or ""
|
||||
speed_raw = payload.get("speed")
|
||||
@@ -3732,6 +3745,8 @@ class ARIABridge:
|
||||
"location": payload.get("location") or sid,
|
||||
"caps": payload.get("caps") or [],
|
||||
"control": bool(payload.get("control")),
|
||||
# "vpn" = VPN-Satellit (Kundennetz hinter WireGuard, laeuft auf ARIA)
|
||||
"kind": payload.get("kind") or "lan",
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
cur = self._satellites[sid]
|
||||
@@ -4271,7 +4286,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}."""
|
||||
@@ -4286,7 +4301,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":
|
||||
@@ -4773,6 +4788,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()})
|
||||
@@ -4934,10 +4953,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)
|
||||
@@ -5206,6 +5231,7 @@ class ARIABridge:
|
||||
"id": s["id"], "location": alias or own,
|
||||
"agentName": own, "alias": alias,
|
||||
"caps": s.get("caps") or [], "control": bool(s.get("control")),
|
||||
"kind": s.get("kind") or "lan",
|
||||
"online": (now - s.get("last_seen", 0)) < 300,
|
||||
})
|
||||
return out
|
||||
|
||||
+2031
-225
File diff suppressed because it is too large
Load Diff
+566
-24
@@ -17,6 +17,7 @@ const { WebSocket, WebSocketServer } = require("ws");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vpn = require("./vpn");
|
||||
|
||||
// ── Konfiguration ───────────────────────────────────────
|
||||
const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10);
|
||||
@@ -517,22 +518,46 @@ async function hfRepoQuant(repo, wanted = "Q4_K_M") {
|
||||
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
|
||||
// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
|
||||
// eingetragen — auch bei schon vorhandenen HF-Eintraegen.
|
||||
// "Von HuggingFace aktualisieren": mehrere Blickwinkel statt nur der
|
||||
// meistgeladenen aller Zeiten (da gewinnen alte Modelle). Neu dazu:
|
||||
// was gerade im Trend ist, Coder-Modelle und unzensierte Fassungen.
|
||||
// Nur was eine Box laden kann (GGUF) und hoechstens LLM_CATALOG_MAX_GB.
|
||||
const LLM_CATALOG_MAX_GB = Number(process.env.LLM_CATALOG_MAX_GB || 30);
|
||||
const NON_LLM_RE = /image|audio|tts|speech|music|yue|whisper|diffusion|flux|sdxl|wan2|video|embed|rerank|ocr/i;
|
||||
const HF_REFRESH_QUERIES = [
|
||||
["beliebt", "search=GGUF&sort=downloads&direction=-1&limit=30"],
|
||||
["im Trend", "filter=gguf&sort=trendingScore&direction=-1&limit=30"],
|
||||
["Code", "search=coder&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
["unzensiert", "search=uncensored&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
["unzensiert", "search=heretic&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
["unzensiert", "search=abliterated&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
|
||||
];
|
||||
async function refreshLlmCatalogFromHF() {
|
||||
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
|
||||
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) throw new Error(`HF API ${r.status}`);
|
||||
const list = await r.json();
|
||||
const existing = loadLlmCatalog();
|
||||
const byId = new Map(existing.map(m => [m.id, m]));
|
||||
let added = 0;
|
||||
for (const m of (Array.isArray(list) ? list : [])) {
|
||||
const repo = m.id || m.modelId;
|
||||
if (!repo || !/gguf/i.test(repo)) continue;
|
||||
const fresh = new Map(); // repo → {cat, downloads}
|
||||
for (const [cat, q] of HF_REFRESH_QUERIES) {
|
||||
const list = await hfJson(`https://huggingface.co/api/models?${q}`);
|
||||
for (const m of Array.isArray(list) ? list : []) {
|
||||
const repo = m.id || m.modelId;
|
||||
if (!repo || fresh.has(repo)) continue;
|
||||
// Nur Sprachmodelle: Bild-, Audio-, Musik- und Sprach-Modelle kann die
|
||||
// LLM-Box nicht als Chat-Modell laden.
|
||||
const pt = String(m.pipeline_tag || "");
|
||||
if (pt && !["text-generation", "image-text-to-text", "conversational"].includes(pt)) continue;
|
||||
if (NON_LLM_RE.test(repo)) continue;
|
||||
fresh.set(repo, { cat, downloads: m.downloads || 0 });
|
||||
}
|
||||
}
|
||||
if (!fresh.size) throw new Error("HuggingFace hat nichts geliefert (erreichbar?)");
|
||||
const known = new Set(existing.map(m => m.hfRepo));
|
||||
for (const [repo, f] of fresh) {
|
||||
if (known.has(repo)) continue;
|
||||
const id = slugModelId(repo);
|
||||
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
||||
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
|
||||
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true };
|
||||
byId.set(id, entry);
|
||||
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
||||
byId.set(id, { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
|
||||
description: `HuggingFace · ${f.cat} · ${(f.downloads).toLocaleString("de")} Downloads`, source: "hf",
|
||||
uncensored: UNCENSORED_RE.test(repo), added_at: new Date().toISOString().slice(0, 10), _new: true });
|
||||
}
|
||||
// Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
|
||||
// gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
|
||||
@@ -540,18 +565,354 @@ async function refreshLlmCatalogFromHF() {
|
||||
for (let i = 0; i < toCheck.length; i += 6) {
|
||||
await Promise.all(toCheck.slice(i, i + 6).map(async e => {
|
||||
const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
|
||||
if (!q) {
|
||||
// Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren.
|
||||
if (e._new) byId.delete(e.id); else e.unusable = true;
|
||||
if (!q || (e._new && q.sizeGB > LLM_CATALOG_MAX_GB)) {
|
||||
// Nicht ladbar oder fuer unsere Karten viel zu gross: neue nicht
|
||||
// aufnehmen, alte markieren.
|
||||
if (e._new) byId.delete(e.id); else if (!q) e.unusable = true;
|
||||
return;
|
||||
}
|
||||
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
|
||||
}));
|
||||
}
|
||||
// Werkzeug-Faehigkeit + Sicherheits-Scan nachtragen (wo noch keine Angabe),
|
||||
// Beliebtheit bei jedem Abgleich frisch (aendert sich taeglich)
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const toEnrich = [...byId.values()].filter(e => e.hfRepo && (e.tools === undefined || e.safety === undefined
|
||||
|| !e.popularity || e.popularity.at !== today));
|
||||
for (let i = 0; i < toEnrich.length; i += 6) {
|
||||
await Promise.all(toEnrich.slice(i, i + 6).map(async e => {
|
||||
if (e.tools === undefined) e.tools = await hfToolSupport(e.hfRepo);
|
||||
if (e.safety === undefined) e.safety = await hfRepoSafety(e.hfRepo);
|
||||
if (!e.popularity || e.popularity.at !== today) e.popularity = await hfPopularity(e.hfRepo) || e.popularity;
|
||||
}));
|
||||
}
|
||||
let added = 0;
|
||||
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
|
||||
// Aufraeumen: von HF uebernommen, vor > LLM_CATALOG_KEEP_DAYS Tagen, auf
|
||||
// keiner Box (Platte/Karte), heute nicht mehr gelistet → raus. Kuratierte
|
||||
// und von Hand hinzugefuegte bleiben immer.
|
||||
const keepDays = Number(process.env.LLM_CATALOG_KEEP_DAYS || 30);
|
||||
const onBox = new Set();
|
||||
for (const w of workers.values()) {
|
||||
for (const d of ((w.disk && w.disk.models) || [])) onBox.add(d.repo);
|
||||
for (const sl of (w.slots || [])) if (sl.model) onBox.add(sl.model);
|
||||
}
|
||||
let removed = 0;
|
||||
for (const [id, e] of [...byId]) {
|
||||
const age = e.added_at ? (Date.now() - new Date(e.added_at).getTime()) / 864e5 : 0;
|
||||
if (e.source === "hf" && !/Hand/.test(e.description || "") && age > keepDays
|
||||
&& !fresh.has(e.hfRepo) && !onBox.has(e.hfRepo) && !onBox.has(e.id)) {
|
||||
byId.delete(id); removed++;
|
||||
}
|
||||
}
|
||||
const merged = Array.from(byId.values());
|
||||
saveLlmCatalog(merged);
|
||||
return { models: merged, added };
|
||||
return { models: merged, added, removed };
|
||||
}
|
||||
// Einmal am Tag von selbst (LLM_CATALOG_REFRESH_HOURS, 0 = aus)
|
||||
const LLM_CATALOG_REFRESH_HOURS = Number(process.env.LLM_CATALOG_REFRESH_HOURS ?? 24);
|
||||
if (LLM_CATALOG_REFRESH_HOURS > 0) {
|
||||
setInterval(() => {
|
||||
refreshLlmCatalogFromHF()
|
||||
.then(r => log("info", "llm", `LLM-Katalog automatisch aktualisiert: +${r.added}, -${r.removed}`))
|
||||
.catch(err => log("warn", "llm", `LLM-Katalog automatisch: ${err.message}`));
|
||||
}, LLM_CATALOG_REFRESH_HOURS * 3600 * 1000);
|
||||
}
|
||||
|
||||
// ── Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort ─────────────────
|
||||
const ARIA_TOOL_LOG_DIR = "/shared/logs/aria-tools";
|
||||
function ariaProof(toTs, project) {
|
||||
// Beginn = Stefans letzte Nachricht davor im selben Kontext (sonst 30 min)
|
||||
let fromTs = toTs - 30 * 60 * 1000;
|
||||
try {
|
||||
const lines = fs.readFileSync("/shared/config/chat_backup.jsonl", "utf-8").split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (!lines[i]) continue;
|
||||
let e; try { e = JSON.parse(lines[i]); } catch { continue; }
|
||||
if (!e || !(e.ts < toTs) || e.role !== "user") continue;
|
||||
if ((e.project_id || "") !== project) continue;
|
||||
fromTs = Math.max(fromTs, e.ts - 2000);
|
||||
break;
|
||||
}
|
||||
} catch (_) { }
|
||||
const days = new Set([new Date(fromTs).toISOString().slice(0, 10), new Date(toTs).toISOString().slice(0, 10)]);
|
||||
const reqs = new Map();
|
||||
for (const d of days) {
|
||||
let txt = "";
|
||||
try { txt = fs.readFileSync(`${ARIA_TOOL_LOG_DIR}/${d}.jsonl`, "utf-8"); } catch { continue; }
|
||||
for (const line of txt.split("\n")) {
|
||||
if (!line) continue;
|
||||
let ev; try { ev = JSON.parse(line); } catch { continue; }
|
||||
if (!ev.requestId) continue;
|
||||
const r = reqs.get(ev.requestId) || { requestId: ev.requestId, project: null, start: 0, end: 0, tools: [], byId: {} };
|
||||
if (ev.kind === "start") { r.start = ev.ts; r.project = ev.projectId || ""; r.model = ev.model || ""; }
|
||||
else if (ev.kind === "end") r.end = ev.ts;
|
||||
else if (ev.kind === "tool_use") {
|
||||
const t = { id: ev.id, name: ev.name, input: ev.input, inputTruncatedBytes: ev.inputTruncatedBytes || 0, ts: ev.ts };
|
||||
r.tools.push(t); if (ev.id) r.byId[ev.id] = t;
|
||||
} else if (ev.kind === "tool_result") {
|
||||
const t = ev.id && r.byId[ev.id];
|
||||
if (t) { t.result = ev.content; t.isError = !!ev.isError; t.resultTruncatedBytes = ev.truncatedBytes || 0; t.doneTs = ev.ts; }
|
||||
}
|
||||
reqs.set(ev.requestId, r);
|
||||
}
|
||||
}
|
||||
const hit = [...reqs.values()]
|
||||
.filter(r => r.start && r.project === project && r.start >= fromTs && r.start <= toTs + 5000)
|
||||
.sort((a, b) => a.start - b.start)
|
||||
.map(({ byId, ...r }) => r);
|
||||
const logSince = (() => { try { return fs.readdirSync(ARIA_TOOL_LOG_DIR).filter(f => /\.jsonl$/.test(f)).sort()[0]?.slice(0, 10) || ""; } catch { return ""; } })();
|
||||
return { from: fromTs, to: toTs, requests: hit, toolCount: hit.reduce((n, r) => n + r.tools.length, 0), logSince };
|
||||
}
|
||||
|
||||
// ── Gerade im Trend: ladbare GGUF-Modelle von HuggingFace ───────────
|
||||
// Fuer das Fenster "Bestenlisten" im Modell-Katalog. "Trend" ist nicht
|
||||
// "das Beste" — viele Community-Mischungen. Darum standardmaessig nur
|
||||
// bekannte Anbieter. Ergebnis 30 min gecacht (HF nicht dauernd fragen).
|
||||
const TRUSTED_GGUF_AUTHORS = new Set(["unsloth", "bartowski", "lmstudio-community", "ggml-org", "qwen",
|
||||
"google", "mistralai", "microsoft", "meta-llama", "deepseek-ai", "ibm-granite", "nvidia", "openai",
|
||||
"allenai", "huggingfacetb", "thudm", "zai-org", "moonshotai", "minimaxai", "tencent", "baidu"]);
|
||||
const DISCOVER_CATS = {
|
||||
chat: { label: "Allgemein / Chat", q: "pipeline_tag=text-generation" },
|
||||
code: { label: "Programmieren", q: "search=coder" },
|
||||
vision: { label: "Bilder verstehen", q: "pipeline_tag=image-text-to-text" },
|
||||
};
|
||||
const _discoverCache = new Map(); // key → {ts, items}
|
||||
async function discoverGguf(cat, trusted) {
|
||||
const c = DISCOVER_CATS[cat] || DISCOVER_CATS.chat;
|
||||
const key = `${cat}|${trusted ? 1 : 0}`;
|
||||
const hit = _discoverCache.get(key);
|
||||
if (hit && Date.now() - hit.ts < 30 * 60 * 1000) return hit.items;
|
||||
const r = await fetch(`https://huggingface.co/api/models?library=gguf&sort=trendingScore&direction=-1&limit=100&${c.q}`,
|
||||
{ headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) throw new Error(`HF API ${r.status}`);
|
||||
let list = (await r.json()).filter(m => m && m.id);
|
||||
if (trusted) list = list.filter(m => TRUSTED_GGUF_AUTHORS.has(String(m.id).split("/")[0].toLowerCase()));
|
||||
list = list.slice(0, 18);
|
||||
const items = [];
|
||||
for (let i = 0; i < list.length; i += 6) {
|
||||
await Promise.all(list.slice(i, i + 6).map(async m => {
|
||||
const q = await hfRepoQuant(m.id, "Q4_K_M");
|
||||
if (!q) return; // kein ladbares GGUF im Repo
|
||||
items.push({ repo: m.id, author: String(m.id).split("/")[0], downloads: m.downloads || 0,
|
||||
likes: m.likes || 0, trending: m.trendingScore || 0,
|
||||
lastModified: m.lastModified || m.createdAt || "", quant: q.quant, sizeGB: q.sizeGB });
|
||||
}));
|
||||
}
|
||||
items.sort((a, b) => b.trending - a.trending);
|
||||
_discoverCache.set(key, { ts: Date.now(), items });
|
||||
return items;
|
||||
}
|
||||
|
||||
// Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden")
|
||||
// Link oder "org/name" → "org/name" (huggingface.co/org/name/tree/main … geht auch)
|
||||
function hfRepoFromInput(input) {
|
||||
const t = String(input || "").trim().replace(/^https?:\/\/(www\.)?huggingface\.co\//i, "").replace(/[?#].*$/, "");
|
||||
const m = t.match(/^([\w.-]+)\/([\w.-]+)/);
|
||||
return m ? `${m[1]}/${m[2]}` : "";
|
||||
}
|
||||
// Repo ohne GGUF (z.B. nur safetensors): GGUF-Fassungen desselben Modells
|
||||
// auf HuggingFace suchen — meist von mradermacher, bartowski, unsloth & Co.
|
||||
async function hfGgufAlternatives(repo) {
|
||||
const name = String(repo).split("/")[1] || "";
|
||||
if (!name) return [];
|
||||
try {
|
||||
const r = await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(name)}&filter=gguf&sort=downloads&direction=-1&limit=12`,
|
||||
{ headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) return [];
|
||||
return (await r.json()).filter(m => m.id && m.id !== repo)
|
||||
.map(m => ({ repo: m.id, downloads: m.downloads || 0, likes: m.likes || 0 })).slice(0, 8);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── ℹ im Modell-Katalog: was kann das Modell, ist es zensiert? ──────
|
||||
// HuggingFace liefert Grundmodell, Lizenz und Schlagworte, aber keine
|
||||
// verstaendliche Beschreibung. Darum: kurze deutsche Texte je Modellfamilie
|
||||
// (erkannt am Namen) + die Fakten von HF + Anfang der Modellkarte.
|
||||
const MODEL_FAMILIES = [
|
||||
["qwen3-coder", "Qwen3-Coder (Alibaba)", "Aufs Programmieren spezialisiert: Code schreiben, verstehen, umbauen, mit Werkzeugen arbeiten (Agenten). Die 30B-A3B-Fassung ist ein MoE-Modell: pro Wort rechnen nur ~3 Mrd. Parameter — schnell für seine Größe."],
|
||||
["foundation-sec", "Foundation-Sec (Cisco Foundation AI)", "Auf IT-Sicherheit trainiert: Schwachstellen, Angriffstechniken (MITRE ATT&CK), Logs und Warnmeldungen auswerten. Die reine „Foundation-Sec-8B“ ist ein Basismodell (setzt nur Text fort) — zum Chatten die Instruct- oder Reasoning-Fassung nehmen."],
|
||||
["vulnllm", "VulnLLM-R", "Sucht Sicherheitslücken in Quellcode und begründet Schritt für Schritt (Reasoning), warum eine Stelle verwundbar ist."],
|
||||
["dolphin", "Dolphin (Cognitive Computations)", "Bewusst ohne Ablehnungen trainiert: macht, was man verlangt, und lässt sich stark über den Systemprompt steuern. Gut für Aufgaben, die große Anbieter verweigern — man ist aber selbst verantwortlich, was man damit tut."],
|
||||
["hermes", "Hermes (Nous Research)", "Sehr gutes Befolgen von Anweisungen, Rollenspiel und Werkzeug-Aufrufe (Function Calling), wenige Ablehnungen. Hermes 4 kann zusätzlich „denken“ (Reasoning)."],
|
||||
["lexi", "Lexi (Orenguteng)", "Unzensierter Llama-Ableger für allgemeinen Chat, beantwortet auch Fragen, die das Original ablehnt."],
|
||||
["gemma", "Gemma (Google)", "Allround-Modell mit gutem Deutsch; neuere Fassungen verstehen auch Bilder. Eher vorsichtig, lehnt heikle Themen ab."],
|
||||
["qwen", "Qwen (Alibaba)", "Starke Allround-Familie: Wissen, Logik, Mathe, Code, viele Sprachen inkl. Deutsch; neuere Fassungen mit Denkmodus. Standard-Fassungen haben Schutzfilter."],
|
||||
["llama", "Llama (Meta)", "Bewährtes Allround-Modell und Basis für sehr viele Ableger; ordentliches Deutsch, solide Werkzeug-Aufrufe."],
|
||||
["mistral", "Mistral (Mistral AI, Frankreich)", "Gut in europäischen Sprachen, effizient; Mistral Small 24B ist für seine Größe sehr stark."],
|
||||
["deepseek", "DeepSeek", "Stark in Logik, Mathe und Code, oft mit ausführlichem Denkweg (Reasoning)."],
|
||||
["phi", "Phi (Microsoft)", "Klein und schnell, erstaunlich gut in Logik für die Größe; eher Englisch."],
|
||||
];
|
||||
const UNCENSORED_RE = /uncensor|abliterat|heretic|obliterat|dolphin|lexi|unfiltered|nsfw|no.?refus/i;
|
||||
async function hfJson(url) {
|
||||
try {
|
||||
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
return r.ok ? await r.json() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function hfReadmeIntro(repo) {
|
||||
try {
|
||||
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) return "";
|
||||
let t = await r.text();
|
||||
t = t.replace(/^---[\s\S]*?\n---\n/, ""); // YAML-Kopf
|
||||
const paras = t.split(/\n\s*\n/).map(p => p.trim())
|
||||
.filter(p => p && !/^(#|!\[|<|\||```|\[!\[|>|-{3,})/.test(p) && p.replace(/[^A-Za-z]/g, "").length > 60)
|
||||
// Absaetze, die fast nur Links sind (Website:, Twitter: …), ueberspringen
|
||||
.filter(p => p.replace(/https?:\/\/\S+/g, "").replace(/[^A-Za-z]/g, "").length > 80);
|
||||
return paras.slice(0, 2).join("\n\n").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").slice(0, 900);
|
||||
} catch { return ""; }
|
||||
}
|
||||
// Ganze Modellkarte als Text (ohne YAML/Bilder/HTML), fuer die Zusammenfassung
|
||||
async function hfReadmeText(repo, max = 7000) {
|
||||
try {
|
||||
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||
if (!r.ok) return "";
|
||||
return (await r.text()).replace(/^---[\s\S]*?\n---\n/, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").slice(0, max);
|
||||
} catch { return ""; }
|
||||
}
|
||||
// Deutsche Kurzbeschreibung vom LOKALEN Modell aus der Modellkarte — einmal
|
||||
// erzeugt, dann im Katalog gespeichert (summary_de). So hat jedes neue Modell
|
||||
// eine, ohne dass jemand Texte pflegt.
|
||||
async function llmModelSummary(repo) {
|
||||
repo = hfRepoFromInput(repo);
|
||||
const cat = loadLlmCatalog();
|
||||
const entry = cat.find(m => m.hfRepo === repo);
|
||||
if (entry && entry.summary_de) return { summary: entry.summary_de, by: entry.summary_by || "", cached: true };
|
||||
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
|
||||
let base = (meta.cardData || {}).base_model;
|
||||
base = Array.isArray(base) ? base[0] : base || "";
|
||||
const card = [await hfReadmeText(base) , await hfReadmeText(repo, 3000)].filter(Boolean).join("\n\n---\n\n");
|
||||
if (!card.trim()) return { summary: "", error: "Keine Modellkarte auf HuggingFace." };
|
||||
const tags = (meta.tags || []).filter(t => !/^(base_model|license|region|endpoints)/.test(t)).slice(0, 25).join(", ");
|
||||
const res = await rvsRequest("llm_request", {
|
||||
requestId: "modelinfo_" + Date.now(),
|
||||
messages: [
|
||||
{ role: "system", content: "Du fasst Modellkarten von KI-Sprachmodellen fuer einen Laien auf Deutsch zusammen. "
|
||||
+ "Nur was in der Karte steht, nichts erfinden. 3 bis 5 kurze Saetze: wofuer das Modell gedacht ist, worin es "
|
||||
+ "besonders gut ist, ob es Ablehnungen/Schutzfilter entfernt hat (uncensored/abliterated), und Besonderheiten "
|
||||
+ "(Denkmodus, Werkzeuge, Sprachen, Groesse). Keine Ueberschrift, kein Markdown." },
|
||||
{ role: "user", content: `Modell: ${repo}\nGrundmodell: ${base || "-"}\nSchlagworte: ${tags}\n\nModellkarte:\n${card}` },
|
||||
],
|
||||
max_tokens: 400, temperature: 0.2, model: "", targetInstance: "",
|
||||
}, "llm_response", 180000);
|
||||
const text = String(res.content || res.text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
||||
if (!res.ok || !text) return { summary: "", error: res.error || "Das lokale Modell hat nicht geantwortet." };
|
||||
const by = res.model || "lokales Modell";
|
||||
if (entry) {
|
||||
entry.summary_de = text; entry.summary_by = by; entry.summary_at = new Date().toISOString();
|
||||
saveLlmCatalog(cat);
|
||||
}
|
||||
return { summary: text, by, cached: false };
|
||||
}
|
||||
|
||||
async function llmModelInfo(repo) {
|
||||
repo = hfRepoFromInput(repo);
|
||||
if (!repo) throw new Error("ungueltiges Repo");
|
||||
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
|
||||
const card = meta.cardData || {};
|
||||
let base = card.base_model;
|
||||
base = Array.isArray(base) ? base[0] : base || "";
|
||||
const baseMeta = base ? (await hfJson(`https://huggingface.co/api/models/${base}`) || {}) : {};
|
||||
const tags = [...new Set([...(meta.tags || []), ...((card.tags) || [])])]
|
||||
.filter(t => !/^(base_model|license|region|endpoints|autotrain|text-generation-inference|safetensors|transformers|gguf|conversational|imatrix)/.test(t));
|
||||
const hay = [repo, base, ...tags].join(" ").toLowerCase();
|
||||
const fams = [];
|
||||
for (const [key, name, text] of MODEL_FAMILIES) {
|
||||
// Ganzes Wort am Anfang ("phi" nicht in "dolphin" finden)
|
||||
const re = new RegExp("(^|[^a-z0-9])" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
if (name && re.test(hay) && !fams.some(f => f.name === name)) fams.push({ name, text });
|
||||
}
|
||||
// Allgemeine Familien (Qwen/Llama …) nur, wenn keine speziellere passt
|
||||
const special = fams.filter(f => !/^(Qwen|Llama|Mistral|Gemma) \(/.test(f.name));
|
||||
const families = special.length ? [...special, ...fams.filter(f => !special.includes(f)).slice(0, 1)] : fams.slice(0, 1);
|
||||
const unc = UNCENSORED_RE.exec(hay);
|
||||
return {
|
||||
repo, base, author: meta.author || repo.split("/")[0], license: card.license || "",
|
||||
downloads: meta.downloads || 0, likes: meta.likes || 0,
|
||||
created: meta.createdAt || "", updated: meta.lastModified || "",
|
||||
tags: tags.slice(0, 20), families,
|
||||
uncensored: !!unc,
|
||||
tools: await hfToolSupport(repo),
|
||||
safety: await hfRepoSafety(repo),
|
||||
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
|
||||
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
|
||||
introFrom: base || repo,
|
||||
};
|
||||
}
|
||||
|
||||
// Kann das Modell Werkzeuge? Kennzeichen: die Chat-Vorlage im GGUF (HF liefert
|
||||
// sie unter expand[]=gguf) kennt ein Werkzeug-Format, oder Schlagworte wie
|
||||
// function-calling/tool-use. Zeigt nur, OB das Format da ist, nicht wie gut.
|
||||
const TOOLS_TEMPLATE_RE = /\btools\b|tool_call|<tool|function_call/;
|
||||
const TOOLS_TAG_RE = /function.?call|tool.?use|tool.?calling|agent/i;
|
||||
async function hfToolSupport(repo) {
|
||||
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=gguf&expand[]=tags`);
|
||||
if (!d) return null;
|
||||
const tpl = String(((d.gguf || {}).chat_template) || "");
|
||||
if (TOOLS_TEMPLATE_RE.test(tpl)) return true;
|
||||
if ((d.tags || []).some(t => TOOLS_TAG_RE.test(t))) return true;
|
||||
return tpl ? false : null; // keine Vorlage = unbekannt
|
||||
}
|
||||
|
||||
// ── Sicherheit eines Repos ─────────────────────────────────────────
|
||||
// Die Box laedt NUR die GGUF-Datei (llama.cpp -hf / paralleler Download per
|
||||
// sha256) — Skripte, Pickle o.ae. daneben werden nie geholt oder ausgefuehrt.
|
||||
// Liegen sie trotzdem im Repo, ist das ein Warnsignal fuer den Anbieter.
|
||||
// HuggingFace scannt jede Datei (securityFileStatus) — das Ergebnis zeigen wir.
|
||||
const EXTRA_KNOWN_GGUF_AUTHORS = new Set(["mradermacher", "thebloke", "maziyarpanahi", "nousresearch",
|
||||
"dphn", "cognitivecomputations", "fdtn-ai", "quantfactory", "lmstudio-ai"]);
|
||||
function knownGgufAuthor(a) { // Funktion: TRUSTED_GGUF_AUTHORS steht weiter unten in der Datei
|
||||
return TRUSTED_GGUF_AUTHORS.has(a) || EXTRA_KNOWN_GGUF_AUTHORS.has(a);
|
||||
}
|
||||
const RISKY_FILE_RE = /\.(py|sh|bash|bat|cmd|ps1|exe|dll|so|dylib|pkl|pickle|pt|pth|ckpt|joblib|npy|zip|tar|gz|7z|jar|js|vbs)$|(^|\/)pytorch_model[^/]*\.bin$/i;
|
||||
// Beliebtheit: HF hat keine Sterne — nur Downloads (30 Tage / gesamt) und Likes.
|
||||
async function hfPopularity(repo) {
|
||||
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=downloads&expand[]=likes&expand[]=downloadsAllTime`);
|
||||
if (!d) return null;
|
||||
return { downloads: d.downloads || 0, downloadsAll: d.downloadsAllTime || 0, likes: d.likes || 0,
|
||||
at: new Date().toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
async function hfRepoSafety(repo) {
|
||||
const author = String(repo).split("/")[0].toLowerCase();
|
||||
const out = { known: knownGgufAuthor(author), flagged: [], risky: [], scanned: 0 };
|
||||
const tree = await hfJson(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1&expand=true`);
|
||||
for (const f of Array.isArray(tree) ? tree : []) {
|
||||
if (f.type !== "file") continue;
|
||||
const st = f.securityFileStatus || {};
|
||||
const subs = Object.values(st).filter(v => v && typeof v === "object").map(v => String(v.status || ""));
|
||||
const all = [String(st.status || ""), ...subs].map(x => x.toLowerCase());
|
||||
if (all.some(x => /unsafe|suspicious|malicious|caution|infected/.test(x))) out.flagged.push({ path: f.path, status: all.filter(Boolean).join(", ") });
|
||||
else if (all.includes("safe")) out.scanned++;
|
||||
if (RISKY_FILE_RE.test(f.path)) out.risky.push(f.path);
|
||||
}
|
||||
out.risky = out.risky.slice(0, 20);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function addHfRepoToCatalog(repo, description = "") {
|
||||
repo = hfRepoFromInput(repo);
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
|
||||
const q = await hfRepoQuant(repo, "Q4_K_M");
|
||||
if (!q) {
|
||||
const err = new Error("In diesem Repo gibt es kein GGUF — das braucht die Box (llama.cpp).");
|
||||
err.alternatives = await hfGgufAlternatives(repo);
|
||||
throw err;
|
||||
}
|
||||
const cat = loadLlmCatalog();
|
||||
let id = slugModelId(repo);
|
||||
const same = cat.find(m => m.hfRepo === repo);
|
||||
if (same) return { models: cat, id: same.id, existed: true };
|
||||
while (cat.some(m => m.id === id)) id += "-2";
|
||||
cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants,
|
||||
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf",
|
||||
tools: await hfToolSupport(repo), uncensored: UNCENSORED_RE.test(repo),
|
||||
safety: await hfRepoSafety(repo), popularity: await hfPopularity(repo),
|
||||
added_at: new Date().toISOString().slice(0, 10) });
|
||||
saveLlmCatalog(cat);
|
||||
return { models: cat, id, existed: false };
|
||||
}
|
||||
|
||||
// ── File-Project-Manifest ───────────────────────────────────────────
|
||||
@@ -730,11 +1091,70 @@ function satelliteList() {
|
||||
return Array.from(satellites.values()).map(s => ({
|
||||
id: s.id, location: alias[s.id] || s.location,
|
||||
agentName: s.location, alias: alias[s.id] || "",
|
||||
caps: s.caps, control: s.control, net: s.net || null,
|
||||
caps: s.caps, control: s.control, net: s.net || null, kind: s.kind || "lan",
|
||||
online: (now - (s.last_seen || 0)) < 300000,
|
||||
}));
|
||||
}
|
||||
|
||||
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
|
||||
const credsRevealWaiters = new Map();
|
||||
|
||||
function vpnRvs() {
|
||||
return { host: RVS_HOST, port: RVS_PORT, tls: RVS_TLS, tlsFallback: RVS_TLS_FALLBACK,
|
||||
token: RVS_TOKEN, sni: process.env.RVS_SNI || "",
|
||||
publicPort: process.env.VPN_PUBLIC_PORT || "443" };
|
||||
}
|
||||
|
||||
async function broadcastVpn() {
|
||||
try { broadcast({ type: "vpn_list", ...(await vpn.list()) }); }
|
||||
catch (e) { log("warn", "vpn", "Liste: " + e.message); }
|
||||
}
|
||||
|
||||
async function handleVpn(ws, msg) {
|
||||
const reply = (body) => { try { ws.send(JSON.stringify({ type: "vpn_result", requestId: msg.requestId || "", action: msg.action, ...body })); } catch { /* weg */ } };
|
||||
try {
|
||||
switch (msg.action) {
|
||||
case "vpn_list":
|
||||
ws.send(JSON.stringify({ type: "vpn_list", ...(await vpn.list()) }));
|
||||
return;
|
||||
case "vpn_check":
|
||||
reply({ ok: true, check: vpn.checkConf(msg.conf) });
|
||||
return;
|
||||
case "vpn_get":
|
||||
reply({ ok: true, connection: vpn.getConf(String(msg.id || "")) });
|
||||
return;
|
||||
case "vpn_profile":
|
||||
reply({ ok: true, profile: vpn.profile(String(msg.id || ""), msg.type === "fritz" ? "fritz" : "standard", vpnRvs()) });
|
||||
return;
|
||||
case "vpn_info":
|
||||
reply({ ok: true, info: await vpn.info(String(msg.id || "")) });
|
||||
return;
|
||||
case "vpn_save":
|
||||
reply({ ok: true, ...(await vpn.save(msg, vpnRvs())) });
|
||||
log("info", "vpn", `Verbindung gespeichert: ${String(msg.name || "").slice(0, 60)}`);
|
||||
break;
|
||||
case "vpn_toggle":
|
||||
reply({ ok: true, ...(await vpn.toggle(String(msg.id || ""), !!msg.enabled, vpnRvs())) });
|
||||
log("info", "vpn", `Verbindung ${msg.id} ${msg.enabled ? "an" : "aus"}`);
|
||||
break;
|
||||
case "vpn_delete":
|
||||
reply({ ok: true, ...(await vpn.remove(String(msg.id || ""))) });
|
||||
log("info", "vpn", `Verbindung ${msg.id} geloescht`);
|
||||
break;
|
||||
case "vpn_settings":
|
||||
reply({ ok: true, settings: vpn.setSettings(msg.settings || {}) });
|
||||
break;
|
||||
default:
|
||||
reply({ ok: false, error: "unbekannte Aktion" });
|
||||
return;
|
||||
}
|
||||
broadcastVpn();
|
||||
} catch (e) {
|
||||
reply({ ok: false, error: e.message });
|
||||
broadcastVpn();
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSatellites() {
|
||||
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||
}
|
||||
@@ -773,6 +1193,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,
|
||||
}));
|
||||
}
|
||||
@@ -1280,6 +1701,7 @@ function connectRVS(forcePlain) {
|
||||
satellites.set(p.id, {
|
||||
id: p.id, location: p.location || p.id,
|
||||
caps: p.caps || [], control: !!p.control, net: p.net || null,
|
||||
kind: p.kind || "lan",
|
||||
last_seen: Date.now(),
|
||||
});
|
||||
broadcastSatellites();
|
||||
@@ -1308,9 +1730,19 @@ function connectRVS(forcePlain) {
|
||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||
} else if (msg.type === "llm_openai_config_result") {
|
||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "llm_runtime_result" || msg.type === "llm_runtime_progress") {
|
||||
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_result") {
|
||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||
broadcast({ type: "sat_result", payload: msg.payload || {} });
|
||||
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
|
||||
const p = msg.payload || {};
|
||||
const asker = credsRevealWaiters.get(p.requestId);
|
||||
if (asker) {
|
||||
credsRevealWaiters.delete(p.requestId);
|
||||
if (asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: "sat_result", payload: p }));
|
||||
} else if (!(p.result && typeof p.result === "object" && "kennwort" in p.result)) { // Kennwort ohne Fragenden: verwerfen
|
||||
broadcast({ type: "sat_result", payload: p });
|
||||
}
|
||||
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
|
||||
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
|
||||
// der Bridge → an den Browser (Agent-Historie).
|
||||
@@ -1323,7 +1755,16 @@ function connectRVS(forcePlain) {
|
||||
location: p.location || "", devices: p.devices || [] });
|
||||
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
|
||||
// Credential-Store-Antworten eines Satelliten → an den Browser.
|
||||
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||
// Mit Klartext-Passwoertern (Auge im Formular) NUR an den Browser, der
|
||||
// gefragt hat — nie an alle offenen Diagnostic-Tabs.
|
||||
const p = msg.payload || {};
|
||||
if (p.revealed) {
|
||||
const asker = credsRevealWaiters.get(p.requestId);
|
||||
credsRevealWaiters.delete(p.requestId);
|
||||
if (asker && asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: msg.type, payload: p }));
|
||||
} else {
|
||||
broadcast({ type: msg.type, payload: p });
|
||||
}
|
||||
} else if (msg.type === "worker_hello") {
|
||||
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
|
||||
const p = msg.payload || {};
|
||||
@@ -1342,6 +1783,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).
|
||||
@@ -1541,6 +1983,26 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, tim
|
||||
freshWs.on("error", () => {});
|
||||
}
|
||||
|
||||
// Wie sendToRVS_withResponse, aber als Promise fuer den Server selbst:
|
||||
// eigene Verbindung, wartet auf expectType mit passender requestId.
|
||||
function rvsRequest(sendType, sendPayload, expectType, timeoutMs = 60000) {
|
||||
return new Promise((resolve) => {
|
||||
if (!RVS_HOST || !RVS_TOKEN) return resolve({ ok: false, error: "RVS nicht konfiguriert" });
|
||||
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
||||
const ws2 = new WebSocket(`${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`);
|
||||
const done = (v) => { clearTimeout(t); try { ws2.close(); } catch (_) {} resolve(v); };
|
||||
const t = setTimeout(() => done({ ok: false, error: "Zeitüberschreitung" }), timeoutMs);
|
||||
ws2.on("open", () => ws2.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })));
|
||||
ws2.on("message", (raw) => {
|
||||
try {
|
||||
const r = JSON.parse(raw.toString());
|
||||
if (r.type === expectType && (r.payload || {}).requestId === sendPayload.requestId) done(r.payload || {});
|
||||
} catch {}
|
||||
});
|
||||
ws2.on("error", (e) => done({ ok: false, error: e.message }));
|
||||
});
|
||||
}
|
||||
|
||||
function sendToRVS_raw(msgObj) {
|
||||
if (!RVS_HOST || !RVS_TOKEN) return;
|
||||
const payload = JSON.stringify(msgObj);
|
||||
@@ -2326,13 +2788,63 @@ const server = http.createServer((req, res) => {
|
||||
refreshLlmCatalogFromHF()
|
||||
.then(r => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
|
||||
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added, removed: r.removed }));
|
||||
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
|
||||
})
|
||||
.catch(err => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
|
||||
});
|
||||
} else if (req.url.startsWith("/api/aria-proof") && req.method === "GET") {
|
||||
// Belege zu einer ARIA-Antwort: alle Werkzeug-Aufrufe (Bash/ssh, Read, …),
|
||||
// die ARIA im Proxy zwischen Stefans Frage und dieser Antwort gemacht hat.
|
||||
// Quelle: /shared/logs/aria-tools/<tag>.jsonl (proxy-patches/routes.js).
|
||||
const u = new URL(req.url, "http://x");
|
||||
const to = Number(u.searchParams.get("ts")) || 0;
|
||||
const project = u.searchParams.get("project") || "";
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
if (!to) { res.end(JSON.stringify({ ok: false, error: "ts fehlt" })); return; }
|
||||
try { res.end(JSON.stringify({ ok: true, ...ariaProof(to, project) })); }
|
||||
catch (e) { res.end(JSON.stringify({ ok: false, error: e.message })); }
|
||||
} else if (req.url.startsWith("/api/llm-discover") && req.method === "GET") {
|
||||
const u = new URL(req.url, "http://x");
|
||||
discoverGguf(u.searchParams.get("cat") || "chat", u.searchParams.get("trusted") !== "0")
|
||||
.then(items => {
|
||||
const inCat = new Set(loadLlmCatalog().map(m => m.hfRepo));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, items: items.map(i => ({ ...i, inCatalog: inCat.has(i.repo) })) }));
|
||||
})
|
||||
.catch(err => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message, items: [] }));
|
||||
});
|
||||
} else if (req.url.startsWith("/api/llm-model-info") && req.method === "GET") {
|
||||
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
|
||||
llmModelInfo(repo)
|
||||
.then(info => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...info })); })
|
||||
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
|
||||
} else if (req.url.startsWith("/api/llm-model-summary") && req.method === "GET") {
|
||||
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
|
||||
llmModelSummary(repo)
|
||||
.then(r => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: !!r.summary, ...r })); })
|
||||
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
|
||||
} else if (req.url === "/api/llm-catalog/add" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
let repo = "", manual = false;
|
||||
try { const b = JSON.parse(body || "{}"); repo = b.hfRepo; manual = !!b.manual; } catch {}
|
||||
addHfRepoToCatalog(repo, manual ? "Von Hand hinzugefügt" : "")
|
||||
.then(r => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, ...r }));
|
||||
if (!r.existed) log("info", "llm", `Katalog: ${repo} aus den HF-Trends übernommen (${r.id})`);
|
||||
})
|
||||
.catch(err => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message, alternatives: err.alternatives || [] }));
|
||||
});
|
||||
});
|
||||
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readLocalLlmConfig()));
|
||||
@@ -3215,6 +3727,10 @@ wss.on("connection", (ws) => {
|
||||
params.rvs_tls = RVS_TLS === "true";
|
||||
params.rvs_token = RVS_TOKEN;
|
||||
}
|
||||
if (String(msg.cmd || "") === "fritzbox.backup_password" && msg.requestId) {
|
||||
credsRevealWaiters.set(String(msg.requestId), ws);
|
||||
setTimeout(() => credsRevealWaiters.delete(String(msg.requestId)), 60000);
|
||||
}
|
||||
sendToRVS_raw({
|
||||
type: "sat_command",
|
||||
payload: {
|
||||
@@ -3237,6 +3753,23 @@ wss.on("connection", (ws) => {
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (typeof msg.action === "string" && msg.action.startsWith("vpn_")) {
|
||||
// VPN-Satelliten (Einstellungen → VPN). Antworten nur an DIESEN Browser —
|
||||
// vpn_get enthaelt den privaten Schluessel. Aenderungen → Liste an alle.
|
||||
handleVpn(ws, msg);
|
||||
} else if (msg.action === "llm_runtime") {
|
||||
// llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen.
|
||||
const op = String(msg.op || "status");
|
||||
if (!["status", "check", "update", "rollback"].includes(op)) return;
|
||||
sendToRVS_raw({
|
||||
type: "llm_runtime",
|
||||
payload: {
|
||||
instanceId: String(msg.instanceId || ""),
|
||||
op,
|
||||
requestId: String(msg.requestId || ""),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "agent_history_query") {
|
||||
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
||||
// antwortet mit agent_history_list (auf derselben rvsWs).
|
||||
@@ -3268,8 +3801,13 @@ wss.on("connection", (ws) => {
|
||||
requestId: "dn_" + Date.now() },
|
||||
timestamp: Date.now() });
|
||||
} else if (msg.action === "sat_creds_list") {
|
||||
const requestId = "dc_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
|
||||
if (msg.reveal) {
|
||||
credsRevealWaiters.set(requestId, ws);
|
||||
setTimeout(() => credsRevealWaiters.delete(requestId), 30000);
|
||||
}
|
||||
sendToRVS_raw({ type: "sat_creds_list",
|
||||
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
|
||||
payload: { satellite: msg.satellite || "", ip: msg.ip || "", reveal: !!msg.reveal, requestId },
|
||||
timestamp: Date.now() });
|
||||
} else if (msg.action === "sat_creds_set") {
|
||||
sendToRVS_raw({ type: "sat_creds_set",
|
||||
@@ -3455,12 +3993,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") {
|
||||
@@ -3469,9 +4009,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();
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
// ── VPN-Satelliten (Einstellungen → VPN → WireGuard) ─────────────
|
||||
//
|
||||
// Jede VPN-Verbindung ist ein eigener Container (Image aria-vpn-satellite,
|
||||
// satellite/Dockerfile.vpn): WireGuard-Tunnel + normaler Satellit dahinter,
|
||||
// Kategorie "VPN-Satellit". Eigener Container = eigener Netz-Namensraum —
|
||||
// ueberlappende Kundennetze stoeren sich nicht, und lokale Satelliten sehen
|
||||
// die VPN-Netze nie.
|
||||
//
|
||||
// Ablage (Bind-Mount /data der Diagnostic):
|
||||
// /data/vpn/connections.json Liste {id, kind, name, enabled, scanNets, …}
|
||||
// /data/vpn/settings.json {keepDays}
|
||||
// /data/vpn/<id>/tunnel/wg.conf die WireGuard-Datei (0600)
|
||||
// /data/vpn/<id>/tunnel/status.json schreibt der Container (ℹ-Fenster)
|
||||
// /data/vpn/<id>/tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier)
|
||||
// /data/vpn/<id>/data/ Satelliten-Daten (Zugangsdaten, Namen, …)
|
||||
//
|
||||
// Server-Verbindungen (Kunde waehlt sich ein, z.B. FritzBox): ARIA erzeugt
|
||||
// beide Schluesselpaare + PSK, der Container lauscht auf 51820 mit fester IP
|
||||
// im Netz aria-vpn; der Verteiler (satellite/vpn_relay.py, UDP-Port fuer
|
||||
// alle) ordnet die Pakete per mac1 zu und liest dafuer /data/vpn/relay.json.
|
||||
//
|
||||
// An = Container mit restart unless-stopped (nach Neustart wieder da).
|
||||
// Aus = Container weg, Dateien bleiben — Kundennetze nur bei Bedarf.
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const DIR = "/data/vpn";
|
||||
const IMAGE = process.env.VPN_IMAGE || "aria-vpn-satellite:latest";
|
||||
const NETWORK = "aria-vpn";
|
||||
const SELF = process.env.DIAG_CONTAINER || "aria-diagnostic";
|
||||
const PREFIX = "aria-vpn-";
|
||||
// Netz der VPN-Container: dynamische IPs (Clients) aus der unteren Haelfte,
|
||||
// Server-Verbindungen bekommen feste IPs aus der oberen (.128 + n), damit der
|
||||
// Verteiler sie auch nach einem Neustart findet.
|
||||
// Bewusst ausserhalb von Dockers eigenen Pools (172.17–31.x, 192.168.x),
|
||||
// sonst kollidiert es mit automatisch angelegten Compose-Netzen.
|
||||
const SUBNET = process.env.VPN_SUBNET || "10.213.213.0/24";
|
||||
const SUBNET_BASE = SUBNET.split("/")[0].split(".").slice(0, 3).join(".");
|
||||
const SERVER_PORT = 51820; // WireGuard im Server-Container
|
||||
const TUNNEL_NET = (n) => `10.222.${n}`; // je Server-Verbindung ein /24
|
||||
|
||||
function docker(method, apiPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : "";
|
||||
const req = http.request({
|
||||
socketPath: "/var/run/docker.sock", path: apiPath, method,
|
||||
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
let json = null;
|
||||
try { json = JSON.parse(buf.toString() || "null"); } catch { /* Logs sind kein JSON */ }
|
||||
resolve({ status: res.statusCode, json, buf });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(30000, () => req.destroy(new Error("Docker antwortet nicht")));
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Ablage ──────────────────────────────────────────────────────
|
||||
function readJson(p, fallback) {
|
||||
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; }
|
||||
}
|
||||
function writeJson(p, v, mode = 0o600) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p + ".tmp", JSON.stringify(v, null, 2), { mode });
|
||||
fs.renameSync(p + ".tmp", p);
|
||||
}
|
||||
const connsFile = () => path.join(DIR, "connections.json");
|
||||
const loadConns = () => readJson(connsFile(), []);
|
||||
const saveConns = (list) => writeJson(connsFile(), list);
|
||||
const tunnelDir = (id) => path.join(DIR, id, "tunnel");
|
||||
function settings() {
|
||||
return { keepDays: 30, ...readJson(path.join(DIR, "settings.json"), {}) };
|
||||
}
|
||||
|
||||
// Host-Pfad von /data — die VPN-Container brauchen Bind-Mounts mit HOST-Pfaden.
|
||||
let _hostData = "";
|
||||
async function hostDataDir() {
|
||||
if (_hostData) return _hostData;
|
||||
const r = await docker("GET", `/containers/${SELF}/json`);
|
||||
const m = ((r.json && r.json.Mounts) || []).find((x) => x.Destination === "/data");
|
||||
if (!m) throw new Error("Host-Pfad von /data unbekannt (Diagnostic ohne Bind-Mount?)");
|
||||
_hostData = m.Source;
|
||||
return _hostData;
|
||||
}
|
||||
|
||||
// ── WireGuard-Datei pruefen ─────────────────────────────────────
|
||||
function parseConf(text) {
|
||||
const iface = {}; const peers = []; let cur = null;
|
||||
for (const raw of String(text || "").split(/\r?\n/)) {
|
||||
const line = raw.split("#")[0].trim();
|
||||
if (!line) continue;
|
||||
const sec = line.match(/^\[(\w+)\]$/);
|
||||
if (sec) {
|
||||
const n = sec[1].toLowerCase();
|
||||
cur = n === "interface" ? iface : n === "peer" ? (peers.push({}), peers[peers.length - 1]) : null;
|
||||
continue;
|
||||
}
|
||||
const i = line.indexOf("=");
|
||||
if (!cur || i < 0) continue;
|
||||
const k = line.slice(0, i).trim().toLowerCase(); const v = line.slice(i + 1).trim();
|
||||
cur[k] = cur[k] && ["address", "allowedips", "dns"].includes(k) ? cur[k] + "," + v : v;
|
||||
}
|
||||
return { iface, peers };
|
||||
}
|
||||
function checkConf(text) {
|
||||
const c = parseConf(text);
|
||||
if (!c.iface.privatekey) return { error: "In [Interface] fehlt PrivateKey." };
|
||||
if (!c.peers.length || c.peers.some((p) => !p.publickey)) return { error: "In [Peer] fehlt PublicKey." };
|
||||
if (!c.peers.some((p) => p.endpoint)) return { error: "Kein Endpoint — wohin soll sich der Tunnel verbinden?" };
|
||||
const nets = [];
|
||||
for (const p of c.peers) {
|
||||
for (const n of String(p.allowedips || "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
||||
if (!n.includes(":") && !n.endsWith("/0")) nets.push(n);
|
||||
}
|
||||
}
|
||||
const all = c.peers.some((p) => /(^|,)\s*0\.0\.0\.0\/0/.test(p.allowedips || ""));
|
||||
return { ok: true, nets, all, endpoint: c.peers.map((p) => p.endpoint).filter(Boolean).join(", ") };
|
||||
}
|
||||
|
||||
// ── Container ───────────────────────────────────────────────────
|
||||
async function ensureNetwork() {
|
||||
const r = await docker("GET", `/networks/${NETWORK}`);
|
||||
if (r.status === 200) return;
|
||||
// ICC aus: VPN-Container sehen sich gegenseitig nicht.
|
||||
const c = await docker("POST", "/networks/create", {
|
||||
Name: NETWORK, Driver: "bridge",
|
||||
Options: { "com.docker.network.bridge.enable_icc": "false" },
|
||||
IPAM: { Config: [{ Subnet: SUBNET, IPRange: `${SUBNET_BASE}.0/25`, Gateway: `${SUBNET_BASE}.1` }] },
|
||||
Labels: { "aria.vpn": "1" },
|
||||
});
|
||||
if (c.status >= 300) throw new Error(`Netz ${NETWORK} anlegen: ${JSON.stringify(c.json).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
async function removeContainer(id) {
|
||||
await docker("DELETE", `/containers/${PREFIX}${id}?force=true`);
|
||||
}
|
||||
|
||||
async function startContainer(conn, rvs) {
|
||||
const img = await docker("GET", `/images/${encodeURIComponent(IMAGE)}/json`);
|
||||
if (img.status !== 200) {
|
||||
throw new Error(`Image ${IMAGE} fehlt. Auf der ARIA-VM: docker compose up -d --build vpn-satellite-image`);
|
||||
}
|
||||
await ensureNetwork();
|
||||
const host = await hostDataDir();
|
||||
fs.mkdirSync(path.join(DIR, conn.id, "data"), { recursive: true });
|
||||
const env = {
|
||||
VPN_ID: conn.id, VPN_NAME: conn.name, SATELLITE_ID: `vpn-${conn.id}`,
|
||||
SATELLITE_LOCATION: conn.name, SCAN_NETS: conn.scanNets || "",
|
||||
SCAN_MAX_SEC: "180", CONTROL_ENABLED: "true",
|
||||
RVS_HOST: rvs.host, RVS_PORT: String(rvs.port), RVS_TLS: String(rvs.tls),
|
||||
RVS_TLS_FALLBACK: String(rvs.tlsFallback), RVS_TOKEN: rvs.token,
|
||||
...(rvs.sni ? { RVS_SNI: rvs.sni } : {}),
|
||||
};
|
||||
await removeContainer(conn.id);
|
||||
const c = await docker("POST", `/containers/create?name=${PREFIX}${conn.id}`, {
|
||||
Image: IMAGE,
|
||||
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
||||
Labels: { "aria.vpn": conn.id, "aria.vpn.name": conn.name },
|
||||
HostConfig: {
|
||||
Binds: [`${host}/vpn/${conn.id}/tunnel:/vpn`, `${host}/vpn/${conn.id}/data:/data`],
|
||||
CapAdd: ["NET_ADMIN"],
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
NetworkMode: NETWORK,
|
||||
LogConfig: { Type: "json-file", Config: { "max-size": "5m", "max-file": "2" } },
|
||||
},
|
||||
...(conn.kind === "server" ? { NetworkingConfig: { EndpointsConfig: {
|
||||
[NETWORK]: { IPAMConfig: { IPv4Address: serverIp(conn) } } } } } : {}),
|
||||
});
|
||||
if (c.status >= 300) throw new Error(`Container anlegen: ${JSON.stringify(c.json).slice(0, 300)}`);
|
||||
const s = await docker("POST", `/containers/${PREFIX}${conn.id}/start`);
|
||||
if (s.status >= 300) throw new Error(`Container starten: ${JSON.stringify(s.json).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
async function containerState(id) {
|
||||
const r = await docker("GET", `/containers/${PREFIX}${id}/json`);
|
||||
if (r.status !== 200) return { exists: false };
|
||||
const st = r.json.State || {};
|
||||
return { exists: true, running: !!st.Running, restarting: !!st.Restarting,
|
||||
startedAt: st.StartedAt, exitCode: st.ExitCode };
|
||||
}
|
||||
|
||||
async function containerLogs(id, lines = 60) {
|
||||
const r = await docker("GET", `/containers/${PREFIX}${id}/logs?stdout=true&stderr=true&tail=${lines}×tamps=true`);
|
||||
if (r.status !== 200) return "";
|
||||
// Docker-Multiplex-Format: 8 Byte Kopf je Block
|
||||
const out = []; let i = 0; const b = r.buf;
|
||||
while (i + 8 <= b.length) {
|
||||
const len = b.readUInt32BE(i + 4);
|
||||
out.push(b.slice(i + 8, i + 8 + len).toString());
|
||||
i += 8 + len;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
// ── Server-Verbindungen ─────────────────────────────────────────
|
||||
function wgKeypair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
||||
return {
|
||||
priv: privateKey.export({ format: "der", type: "pkcs8" }).slice(-32).toString("base64"),
|
||||
pub: publicKey.export({ format: "der", type: "spki" }).slice(-32).toString("base64"),
|
||||
};
|
||||
}
|
||||
const serverIp = (conn) => `${SUBNET_BASE}.${128 + conn.n}`;
|
||||
const keysFile = (id) => path.join(tunnelDir(id), "keys.json");
|
||||
|
||||
function parseLan(v) {
|
||||
const nets = String(v || "").split(/[\s,]+/).filter(Boolean);
|
||||
if (!nets.length) throw new Error("Kundennetz fehlt (z.B. 192.168.178.0/24).");
|
||||
for (const n of nets) {
|
||||
const m = n.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
|
||||
if (!m || m.slice(1, 5).some((x) => +x > 255) || +m[5] < 8 || +m[5] > 32) {
|
||||
throw new Error(`Kundennetz „${n}" ist kein Netz wie 192.168.178.0/24.`);
|
||||
}
|
||||
}
|
||||
return nets;
|
||||
}
|
||||
|
||||
// Server-seitige wg.conf (im Container): lauscht, Gegenseite = Kunde
|
||||
function serverConf(conn, k) {
|
||||
const t = TUNNEL_NET(conn.n);
|
||||
return ["[Interface]", `PrivateKey = ${k.srvPriv}`, `ListenPort = ${SERVER_PORT}`, `Address = ${t}.1/24`, "",
|
||||
"[Peer]", `PublicKey = ${k.peerPub}`, `PresharedKey = ${k.psk}`,
|
||||
`AllowedIPs = ${[`${t}.2/32`, ...conn.lan].join(", ")}`, ""].join("\n");
|
||||
}
|
||||
|
||||
// Datei fuer die Gegenseite. FritzBox (Netzwerk-Kopplung): Address = eigene
|
||||
// LAN-Adresse der Box, AllowedIPs = unser Netz (kein 0.0.0.0/0 — lehnt die
|
||||
// Box ab). Standard: eigene Tunnel-Adresse, die Gegenseite muss ihr LAN dann
|
||||
// selbst routen/maskieren.
|
||||
function peerProfile(conn, k, type, rvs) {
|
||||
const t = TUNNEL_NET(conn.n);
|
||||
const lan0 = conn.lan[0];
|
||||
const prefix = lan0.split("/")[1];
|
||||
const addr = type === "fritz" ? `${conn.routerIp}/${prefix}` : `${t}.2/24`;
|
||||
return [`# ARIA VPN-Satellit „${conn.name}" — ${type === "fritz" ? "für FRITZ!Box (Netzwerk-Kopplung)" : "Standard-WireGuard"}`,
|
||||
"[Interface]", `PrivateKey = ${k.peerPriv}`, `Address = ${addr}`, "",
|
||||
"[Peer]", `PublicKey = ${k.srvPub}`, `PresharedKey = ${k.psk}`,
|
||||
`AllowedIPs = ${t}.0/24`, `Endpoint = ${rvs.host}:${rvs.publicPort || 443}`,
|
||||
"PersistentKeepalive = 25", ""].join("\n");
|
||||
}
|
||||
|
||||
function writeRelayMap() {
|
||||
const rows = [];
|
||||
for (const c of loadConns()) {
|
||||
if (c.kind !== "server" || !c.enabled) continue;
|
||||
const k = readJson(keysFile(c.id), null);
|
||||
if (k) rows.push({ id: c.id, ip: serverIp(c), port: SERVER_PORT, pub: k.srvPub });
|
||||
}
|
||||
writeJson(path.join(DIR, "relay.json"), rows, 0o644);
|
||||
}
|
||||
|
||||
async function saveServer(msg, rvs) {
|
||||
const name = String(msg.name || "").trim().slice(0, 60);
|
||||
if (!name) throw new Error("Name fehlt.");
|
||||
const lan = parseLan(msg.lan);
|
||||
const routerIp = String(msg.routerIp || "").trim() ||
|
||||
lan[0].split("/")[0].replace(/\.\d+$/, ".1");
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(routerIp)) throw new Error("Router-IP ist keine IP-Adresse.");
|
||||
const conns = loadConns();
|
||||
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
|
||||
if (!conn) {
|
||||
const used = new Set(conns.filter((c) => c.kind === "server").map((c) => c.n));
|
||||
let n = 1;
|
||||
while (used.has(n)) n++;
|
||||
if (n > 126) throw new Error("Höchstens 126 Server-Verbindungen.");
|
||||
conn = { id: crypto.randomBytes(4).toString("hex"), kind: "server", n, enabled: false, createdAt: Date.now() };
|
||||
conns.push(conn);
|
||||
}
|
||||
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",") || lan.join(",");
|
||||
Object.assign(conn, { name, lan, routerIp, scanNets, updatedAt: Date.now() });
|
||||
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
|
||||
let k = readJson(keysFile(conn.id), null);
|
||||
if (!k) {
|
||||
const s = wgKeypair(); const p = wgKeypair();
|
||||
k = { srvPriv: s.priv, srvPub: s.pub, peerPriv: p.priv, peerPub: p.pub,
|
||||
psk: crypto.randomBytes(32).toString("base64") };
|
||||
writeJson(keysFile(conn.id), k);
|
||||
}
|
||||
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), serverConf(conn, k), { mode: 0o600 });
|
||||
saveConns(conns);
|
||||
if (conn.enabled) await startContainer(conn, rvs);
|
||||
writeRelayMap();
|
||||
return { id: conn.id };
|
||||
}
|
||||
|
||||
function profile(id, type, rvs) {
|
||||
const conn = loadConns().find((c) => c.id === id);
|
||||
if (!conn || conn.kind !== "server") throw new Error("Server-Verbindung unbekannt.");
|
||||
const k = readJson(keysFile(id), null);
|
||||
if (!k) throw new Error("Schlüssel fehlen.");
|
||||
return { name: conn.name, type, conf: peerProfile(conn, k, type, rvs) };
|
||||
}
|
||||
|
||||
// ── API (vom ws-Handler in server.js) ───────────────────────────
|
||||
async function list() {
|
||||
const conns = loadConns();
|
||||
const res = [];
|
||||
for (const c of conns) {
|
||||
const st = readJson(path.join(tunnelDir(c.id), "status.json"), {});
|
||||
res.push({ ...c, status: st, container: await containerState(c.id) });
|
||||
}
|
||||
return { connections: res, settings: settings() };
|
||||
}
|
||||
|
||||
async function save(msg, rvs) {
|
||||
if ((msg.kind || "") === "server" || (msg.id && (loadConns().find((c) => c.id === msg.id) || {}).kind === "server")) {
|
||||
return saveServer(msg, rvs);
|
||||
}
|
||||
const name = String(msg.name || "").trim().slice(0, 60);
|
||||
const conf = String(msg.conf || "").replace(/\r\n/g, "\n").trim() + "\n";
|
||||
if (!name) throw new Error("Name fehlt.");
|
||||
const chk = checkConf(conf);
|
||||
if (chk.error) throw new Error(chk.error);
|
||||
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",");
|
||||
const conns = loadConns();
|
||||
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
|
||||
if (!conn) {
|
||||
conn = { id: crypto.randomBytes(4).toString("hex"), kind: msg.kind || "client",
|
||||
enabled: false, createdAt: Date.now() };
|
||||
conns.push(conn);
|
||||
}
|
||||
Object.assign(conn, { name, scanNets, updatedAt: Date.now() });
|
||||
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
|
||||
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), conf, { mode: 0o600 });
|
||||
saveConns(conns);
|
||||
if (conn.enabled) await startContainer(conn, rvs); // Aenderung greift sofort
|
||||
return { id: conn.id };
|
||||
}
|
||||
|
||||
async function toggle(id, enabled, rvs) {
|
||||
const conns = loadConns();
|
||||
const conn = conns.find((c) => c.id === id);
|
||||
if (!conn) throw new Error("Verbindung unbekannt.");
|
||||
conn.enabled = !!enabled;
|
||||
saveConns(conns);
|
||||
try {
|
||||
if (conn.enabled) await startContainer(conn, rvs);
|
||||
else await removeContainer(id);
|
||||
} finally {
|
||||
writeRelayMap();
|
||||
}
|
||||
return { id, enabled: conn.enabled };
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const conns = loadConns();
|
||||
if (!conns.some((c) => c.id === id)) throw new Error("Verbindung unbekannt.");
|
||||
await removeContainer(id);
|
||||
saveConns(conns.filter((c) => c.id !== id));
|
||||
fs.rmSync(path.join(DIR, id), { recursive: true, force: true });
|
||||
writeRelayMap();
|
||||
return { id };
|
||||
}
|
||||
|
||||
function getConf(id) {
|
||||
const conn = loadConns().find((c) => c.id === id);
|
||||
if (!conn) throw new Error("Verbindung unbekannt.");
|
||||
if (conn.kind === "server") return { ...conn }; // Schluessel bleiben auf ARIA
|
||||
return { ...conn, conf: fs.readFileSync(path.join(tunnelDir(id), "wg.conf"), "utf8") };
|
||||
}
|
||||
|
||||
async function info(id) {
|
||||
const conn = loadConns().find((c) => c.id === id);
|
||||
if (!conn) throw new Error("Verbindung unbekannt.");
|
||||
const dir = tunnelDir(id);
|
||||
let events = [];
|
||||
try {
|
||||
events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8").trim().split("\n")
|
||||
.filter(Boolean).slice(-300).map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
||||
.filter(Boolean);
|
||||
} catch { /* noch keine */ }
|
||||
return { ...conn, status: readJson(path.join(dir, "status.json"), {}), events,
|
||||
container: await containerState(id), logs: await containerLogs(id) };
|
||||
}
|
||||
|
||||
function setSettings(s) {
|
||||
const keepDays = Math.max(1, Math.min(3650, parseInt(s.keepDays, 10) || 30));
|
||||
writeJson(path.join(DIR, "settings.json"), { keepDays });
|
||||
rotate();
|
||||
return { keepDays };
|
||||
}
|
||||
|
||||
// Log-Rotation: Ereignisse aelter als keepDays fliegen raus (stuendlich).
|
||||
function rotate() {
|
||||
const cut = Date.now() / 1000 - settings().keepDays * 86400;
|
||||
for (const c of loadConns()) {
|
||||
const p = path.join(tunnelDir(c.id), "events.jsonl");
|
||||
try {
|
||||
const lines = fs.readFileSync(p, "utf8").split("\n").filter(Boolean);
|
||||
const keep = lines.filter((l) => { try { return JSON.parse(l).ts >= cut; } catch { return false; } });
|
||||
if (keep.length !== lines.length) fs.writeFileSync(p, keep.join("\n") + (keep.length ? "\n" : ""));
|
||||
} catch { /* keine Datei */ }
|
||||
}
|
||||
}
|
||||
setInterval(rotate, 3600 * 1000).unref();
|
||||
|
||||
module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf, profile, writeRelayMap };
|
||||
@@ -164,6 +164,42 @@ services:
|
||||
- RVS_TOKEN=${RVS_TOKEN:-}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── VPN-Satelliten: nur das Image bauen ───────────────
|
||||
# Die eigentlichen Container (einer pro WireGuard-Verbindung, aria-vpn-<id>)
|
||||
# legt die Diagnostic an (Einstellungen → VPN). Dieser Eintrag sorgt nur
|
||||
# dafuer, dass `docker compose up -d --build` das Image mitbaut; der
|
||||
# Container selbst beendet sich sofort wieder.
|
||||
# Voraussetzung auf dem Host: WireGuard-Kernelmodul (modprobe wireguard).
|
||||
vpn-satellite-image:
|
||||
build:
|
||||
context: ./satellite
|
||||
dockerfile: Dockerfile.vpn
|
||||
image: aria-vpn-satellite:latest
|
||||
container_name: aria-vpn-satellite-image
|
||||
entrypoint: ["true"]
|
||||
network_mode: none
|
||||
restart: "no"
|
||||
|
||||
# ─── VPN-Verteiler: EIN UDP-Port fuer alle WireGuard-Server-Verbindungen ─
|
||||
# Kunden waehlen sich auf UDP 443 am RVS-Hostnamen ein; der NPM-Stream (oder
|
||||
# der Router) leitet das hierher (VPN_RELAY_PORT, Standard 51820). Der
|
||||
# Verteiler ordnet jedes Paket per WireGuard-mac1 der richtigen Verbindung
|
||||
# (eigener Container) zu, ohne etwas zu entschluesseln; Unbekanntes wird
|
||||
# still verworfen. Host-Netz, weil er die Container-IPs im Netz aria-vpn
|
||||
# erreichen muss (dort ist Container-zu-Container gesperrt).
|
||||
vpn-relay:
|
||||
image: aria-vpn-satellite:latest
|
||||
container_name: aria-vpn-relay
|
||||
depends_on:
|
||||
- vpn-satellite-image
|
||||
network_mode: host
|
||||
command: ["python", "-u", "vpn_relay.py"]
|
||||
environment:
|
||||
- VPN_RELAY_PORT=${VPN_RELAY_PORT:-51820}
|
||||
volumes:
|
||||
- ./aria-data/config/diag-state/vpn:/vpn:ro
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
aria-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+16
-1
@@ -135,6 +135,9 @@ const ALLOWED_TYPES = new Set([
|
||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
||||
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||
"llm_openai_config", "llm_openai_config_result",
|
||||
// llama-swap/llama.cpp einer Box pruefen/aktualisieren/zuruecksetzen
|
||||
// (Diagnostic → Modell-Katalog → ⚙ llama.cpp; ausgefuehrt vom llm-updater).
|
||||
"llm_runtime", "llm_runtime_result", "llm_runtime_progress",
|
||||
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
|
||||
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
|
||||
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
|
||||
@@ -188,6 +191,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}`;
|
||||
@@ -696,7 +702,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;
|
||||
|
||||
@@ -40,6 +40,14 @@ CONTROL_ENABLED=true
|
||||
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
|
||||
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
|
||||
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
|
||||
# fritzbox.describe FritzBox: was DIESE Box kann (alle TR-064-Aktionen, ohne Login)
|
||||
# fritzbox.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true
|
||||
# fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten
|
||||
# fritzbox.backup / backups / backup_view / restore / backup_delete
|
||||
# FritzBox: Einstellungen sichern (Taste an der Box, 2 min),
|
||||
# auflisten, lesbar ansehen, wieder einspielen, loeschen.
|
||||
# Sicherungen liegen unter FB_BACKUP_DIR (/data/fritzbox-backups).
|
||||
# (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen)
|
||||
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
|
||||
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
|
||||
# adb.connect ADB-Verbindung aufbauen (params: ip)
|
||||
@@ -52,7 +60,7 @@ CONTROL_ENABLED=true
|
||||
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
|
||||
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
|
||||
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.*,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
|
||||
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
|
||||
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# ARIA VPN-Satellit — WireGuard-Tunnel + Satellit in EINEM Container pro
|
||||
# Verbindung (angelegt von der Diagnostic). Laeuft NICHT im Host-Netz, sondern
|
||||
# im Docker-Netz aria-vpn; braucht nur NET_ADMIN (wg0, Routen, nftables).
|
||||
# Das WireGuard-Kernelmodul kommt vom Host.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends wireguard-tools iproute2 nftables snmp adb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY satellite.py vpn_entry.py vpn_relay.py ./
|
||||
|
||||
CMD ["python", "-u", "vpn_entry.py"]
|
||||
@@ -117,3 +117,77 @@ Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar —
|
||||
|
||||
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
|
||||
sonst `false` und nur beobachten.
|
||||
|
||||
## VPN-Satelliten (WireGuard, ohne Installation beim Kunden)
|
||||
|
||||
Ein VPN-Satellit ist ein Satellit, der **nicht im Kundennetz** läuft, sondern auf ARIA —
|
||||
verbunden über einen WireGuard-Tunnel. Angelegt wird er in der Diagnostic unter
|
||||
**Einstellungen → VPN → WireGuard**; im Reiter Satelliten steht er unter „VPN-Satelliten 🔐“.
|
||||
|
||||
**Clients** (ARIA wählt sich beim Kunden ein): die WireGuard-Datei des Kunden hochladen
|
||||
oder einfügen, Namen vergeben, einschalten.
|
||||
- **An** = Container mit Neustart-Automatik (verbindet sich nach einem Neustart wieder).
|
||||
**Aus** = Container weg, Datei und Log bleiben — Kundennetze nur bei Bedarf.
|
||||
- ✏️ bearbeiten · ⬇ Datei wieder herunterladen · ℹ Verlauf (wann verbunden, wie lange,
|
||||
Datenmenge) · 🗑 löschen. Log-Aufbewahrung im Reiter Einstellungen.
|
||||
|
||||
**Wie es gebaut ist** (`Dockerfile.vpn`, `vpn_entry.py`, `diagnostic/vpn.js`):
|
||||
- **Ein Container pro Verbindung** (`aria-vpn-<id>`, Netz `aria-vpn`, nur `NET_ADMIN`).
|
||||
Eigener Netz-Namensraum: überlappende Kundennetze (überall `192.168.178.0/24`) stören
|
||||
sich nicht, und lokale Satelliten im Host-Netz sehen die VPN-Netze nie.
|
||||
- **Firewall im Container:** durch den Tunnel nur ausgehend, zurück kommen nur Antworten —
|
||||
auch wenn die Gegenseite den Tunnel aufgebaut hat, führt keine Verbindung vom Kundennetz
|
||||
zu ARIA. Ins ARIA-Netz/Internet darf der Container nur zum RVS und zum Tunnel-Endpunkt.
|
||||
- `wg0` wird selbst aufgesetzt, nicht per `wg-quick`: `DNS`/`PostUp` aus fremden Dateien
|
||||
werden ignoriert, und bei `AllowedIPs = 0.0.0.0/0` bleibt der Weg zum RVS trotzdem
|
||||
außerhalb des Tunnels. Endpunkte mit Hostnamen (DynDNS) werden neu aufgelöst, solange
|
||||
keine Verbindung steht.
|
||||
- **Scan:** über WireGuard gibt es kein mDNS/SSDP/ARP — der VPN-Satellit klopft die Netze
|
||||
aktiv ab (`SCAN_NETS`, Standard = AllowedIPs ohne `0.0.0.0/0`; typische Ports, auch
|
||||
„Verbindung abgelehnt“ zählt als Gerät). FritzBoxen erkennt er an Port 49000.
|
||||
- Daten pro Verbindung unter `aria-data/config/diag-state/vpn/<id>/` (`tunnel/wg.conf`
|
||||
mit privatem Schlüssel, `status.json`, `events.jsonl`, `data/` = Zugangsdaten usw.).
|
||||
|
||||
**Voraussetzungen auf der ARIA-VM:**
|
||||
- WireGuard-Kernelmodul: `modprobe wireguard` und dauerhaft
|
||||
`echo wireguard > /etc/modules-load.d/wireguard.conf`
|
||||
- Image bauen: `docker compose up -d --build vpn-satellite-image` (baut nur das Image,
|
||||
der Container beendet sich sofort wieder — das ist richtig so).
|
||||
|
||||
**Server** (Kunden wählen sich bei ARIA ein, z.B. per FritzBox): im Reiter Server
|
||||
„+ Hinzufügen“, Namen, Kundennetz (z.B. `192.168.178.0/24`) und LAN-IP der FritzBox eintragen.
|
||||
ARIA erzeugt beide Schlüsselpaare und einen Pre-Shared-Key; ⬇ liefert die Datei für die
|
||||
Gegenseite:
|
||||
- **FritzBox** (Netzwerk-Kopplung): `Address` = LAN-IP der Box, `AllowedIPs` = ARIAs
|
||||
Tunnelnetz `10.222.<n>.0/24` (kein `0.0.0.0/0` — das lehnt die FritzBox ab). Import:
|
||||
Internet → Freigaben → VPN (WireGuard) → Verbindung hinzufügen → Netzwerke koppeln →
|
||||
Einstellungen aus Datei übernehmen. *Noch nicht an einer echten FritzBox getestet.*
|
||||
- **Standard-WireGuard** (Linux, andere Router): eigene Tunnel-Adresse `10.222.<n>.2`; das
|
||||
Kundennetz muss die Gegenseite dann selbst weiterleiten bzw. maskieren.
|
||||
|
||||
Alle Einwahlen kommen über **einen** Port: Der **Verteiler** (`vpn_relay.py`, Container
|
||||
`aria-vpn-relay`, Host-Netz, `VPN_RELAY_PORT`, Standard 51820) ordnet jedes Paket anhand der
|
||||
WireGuard-Prüfsumme `mac1` der richtigen Verbindung zu — jede Einwahl hat ihr eigenes
|
||||
Server-Schlüsselpaar und ihren eigenen Container mit fester IP im Netz `aria-vpn`
|
||||
(`10.213.213.0/24`, änderbar mit `VPN_SUBNET`). Entschlüsselt wird dabei nichts; fremde
|
||||
Schlüssel und alles andere verwirft er still. Auch zwei Kunden mit demselben Netz
|
||||
(`192.168.178.0/24`) funktionieren gleichzeitig.
|
||||
|
||||
**Erreichbar machen — UDP 443 auf dem RVS-Hostnamen** (TCP 443 bleibt beim RVS):
|
||||
- *Mit Nginx Proxy Manager:* Hosts → **Streams** → Add Stream: Incoming Port `443`,
|
||||
Forward Host = ARIA-VM, Forward Port `51820`, **TCP Forwarding aus, UDP Forwarding an**
|
||||
(TCP würde mit den Proxy-Hosts kollidieren). Im docker-compose des NPM `- '443:443/udp'`
|
||||
ergänzen und neu starten. Am Router UDP 443 ebenfalls auf den NPM-Rechner leiten.
|
||||
Hinweis: ARIA sieht dann als Absender den NPM statt der Kunden-IP; die erzeugten Dateien
|
||||
senden alle 25 s ein Lebenszeichen, damit nginx die Zuordnung behält.
|
||||
- *Ohne Nginx:* den RVS-Port am Router zusätzlich als **UDP** weiterleiten — direkt auf die
|
||||
ARIA-VM, Port `51820`.
|
||||
- *Mit ufw als Firewall:*
|
||||
```bash
|
||||
# auf dem NPM-Rechner: UDP 443 aus dem Internet erlauben
|
||||
ufw allow 443/udp comment 'WireGuard -> ARIA'
|
||||
# auf der ARIA-VM: Verteiler-Port NUR vom NPM erlauben
|
||||
ufw allow proto udp from <IP-des-NPM> to any port 51820 comment 'VPN-Verteiler nur vom NPM'
|
||||
# ohne Nginx statt dessen (der Router leitet direkt auf die VM):
|
||||
ufw allow 51820/udp
|
||||
```
|
||||
|
||||
+972
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
ARIA VPN-Satellit — ein WireGuard-Tunnel + ein ganz normaler Satellit dahinter.
|
||||
|
||||
Laeuft als eigener Container pro VPN-Verbindung (angelegt von der Diagnostic,
|
||||
Einstellungen → VPN → WireGuard). Eigener Container = eigener Netz-Namensraum:
|
||||
- ueberlappende Kundennetze (jeder hat 192.168.178.0/24) stoeren sich nicht
|
||||
- lokale Satelliten (network_mode: host) sehen diese Netze nie
|
||||
|
||||
Ablauf:
|
||||
1. /vpn/wg.conf lesen (Standard-WireGuard-Datei, wie man sie bekommt)
|
||||
2. wg0 selbst aufsetzen (kein wg-quick: DNS/PostUp aus fremden Dateien
|
||||
werden ignoriert, und der Weg zum RVS darf NIE durch den Tunnel)
|
||||
3. Firewall (nftables, nur in diesem Container):
|
||||
raus durch den Tunnel: alles; rein aus dem Tunnel: nur Antworten
|
||||
raus ins ARIA-Netz/Internet: nur zum RVS und zum Tunnel-Endpunkt
|
||||
4. satellite.py starten (SAT_KIND=vpn, Scan = SCAN_NETS aktiv abklopfen)
|
||||
5. Tunnel beobachten: status.json + events.jsonl (verbunden/getrennt, Dauer)
|
||||
fuer das ℹ-Fenster; Endpunkt mit Hostnamen (DynDNS) neu aufloesen
|
||||
|
||||
Env: VPN_ID, VPN_NAME, RVS_HOST/RVS_PORT/RVS_SNI (+ alles fuer satellite.py)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn] %(levelname)s %(message)s")
|
||||
log = logging.getLogger("vpn")
|
||||
|
||||
VPN_DIR = os.environ.get("VPN_DIR", "/vpn")
|
||||
CONF = os.path.join(VPN_DIR, "wg.conf")
|
||||
STATUS = os.path.join(VPN_DIR, "status.json")
|
||||
EVENTS = os.path.join(VPN_DIR, "events.jsonl")
|
||||
IFACE = "wg0"
|
||||
HANDSHAKE_OK_S = 180 # juenger = verbunden (WireGuard erneuert alle 2 min)
|
||||
WATCH_S = 15
|
||||
|
||||
_stop = threading.Event()
|
||||
_conn = {"connected": False, "since": 0, "rx": 0, "tx": 0} # fuer "getrennt" beim Stoppen
|
||||
|
||||
|
||||
def sh(*args: str, check: bool = True, inp: str | None = None) -> str:
|
||||
p = subprocess.run(list(args), capture_output=True, text=True, input=inp)
|
||||
if check and p.returncode != 0:
|
||||
raise RuntimeError(f"{' '.join(args[:4])}: {(p.stderr or p.stdout).strip()[:300]}")
|
||||
return p.stdout
|
||||
|
||||
|
||||
def event(kind: str, **extra) -> None:
|
||||
rec = {"ts": int(time.time()), "event": kind, **extra}
|
||||
try:
|
||||
with open(EVENTS, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
except OSError as e:
|
||||
log.warning("events.jsonl nicht schreibbar: %s", e)
|
||||
|
||||
|
||||
# ── WireGuard-Datei lesen ───────────────────────────────────────
|
||||
|
||||
def parse_conf(text: str) -> dict:
|
||||
"""[Interface] + beliebig viele [Peer]. Schluessel ohne Gross/Klein."""
|
||||
iface: dict = {}
|
||||
peers: list = []
|
||||
cur = None
|
||||
for raw in text.splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
sec = re.match(r"^\[(\w+)\]$", line)
|
||||
if sec:
|
||||
name = sec.group(1).lower()
|
||||
if name == "interface":
|
||||
cur = iface
|
||||
elif name == "peer":
|
||||
cur = {}
|
||||
peers.append(cur)
|
||||
else:
|
||||
cur = None
|
||||
continue
|
||||
if cur is None or "=" not in line:
|
||||
continue
|
||||
k, v = (s.strip() for s in line.split("=", 1))
|
||||
k = k.lower()
|
||||
if k in cur and k in ("address", "allowedips", "dns"):
|
||||
cur[k] += "," + v
|
||||
else:
|
||||
cur[k] = v
|
||||
if not iface.get("privatekey"):
|
||||
raise ValueError("[Interface] PrivateKey fehlt")
|
||||
if not peers or not all(p.get("publickey") for p in peers):
|
||||
raise ValueError("[Peer] PublicKey fehlt")
|
||||
return {"iface": iface, "peers": peers}
|
||||
|
||||
|
||||
def _split(v: str) -> list[str]:
|
||||
return [x.strip() for x in (v or "").split(",") if x.strip()]
|
||||
|
||||
|
||||
def setconf_text(c: dict) -> str:
|
||||
"""Nur was `wg setconf` versteht (Address/DNS/MTU/PostUp sind wg-quick)."""
|
||||
out = ["[Interface]", f"PrivateKey = {c['iface']['privatekey']}"]
|
||||
if c["iface"].get("listenport"):
|
||||
out.append(f"ListenPort = {c['iface']['listenport']}")
|
||||
for p in c["peers"]:
|
||||
out += ["", "[Peer]", f"PublicKey = {p['publickey']}"]
|
||||
for k, name in (("presharedkey", "PresharedKey"), ("endpoint", "Endpoint"),
|
||||
("allowedips", "AllowedIPs")):
|
||||
if p.get(k):
|
||||
out.append(f"{name} = {p[k]}")
|
||||
out.append(f"PersistentKeepalive = {p.get('persistentkeepalive') or 25}")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def endpoint_host_port(ep: str) -> tuple[str, int]:
|
||||
m = re.match(r"^\[?([^\]]+?)\]?:(\d+)$", ep.strip())
|
||||
if not m:
|
||||
raise ValueError(f"Endpoint unlesbar: {ep}")
|
||||
return m.group(1), int(m.group(2))
|
||||
|
||||
|
||||
def resolve4(host: str) -> list[str]:
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return [host]
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return sorted({a[4][0] for a in socket.getaddrinfo(host, None, socket.AF_INET)})
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
# ── Netz + Firewall ─────────────────────────────────────────────
|
||||
|
||||
def default_gw() -> str:
|
||||
m = re.search(r"default via (\S+)", sh("ip", "-4", "route", "show", "default"))
|
||||
if not m:
|
||||
raise RuntimeError("Container hat kein Default-Gateway")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str], listen: int = 0) -> None:
|
||||
rvs = ", ".join(rvs_ips) if rvs_ips else "127.0.0.2"
|
||||
eps = ", ".join(ep_ips) if ep_ips else "127.0.0.2"
|
||||
# Server-Verbindung: der Verteiler (vpn_relay.py) reicht die Pakete der
|
||||
# Gegenseite an diesen Port weiter — nur WireGuard, nur ueber eth0.
|
||||
lin = f'iifname "eth0" udp dport {listen} accept' if listen else ""
|
||||
lout = f'oifname "eth0" udp sport {listen} accept' if listen else ""
|
||||
# Nur die eigene Tabelle — "flush ruleset" wuerde Dockers NAT-Regeln fuer
|
||||
# den eingebauten DNS (127.0.0.11) im Container mit loeschen.
|
||||
sh("nft", "delete", "table", "inet", "vpnsat", check=False)
|
||||
rules = f"""
|
||||
table inet vpnsat {{
|
||||
set endpoints {{ type ipv4_addr; elements = {{ {eps} }} }}
|
||||
chain input {{
|
||||
type filter hook input priority 0; policy drop;
|
||||
iif lo accept
|
||||
ct state established,related accept
|
||||
iifname "eth0" ip saddr @endpoints meta l4proto udp accept
|
||||
{lin}
|
||||
}}
|
||||
chain forward {{
|
||||
type filter hook forward priority 0; policy drop;
|
||||
}}
|
||||
chain output {{
|
||||
type filter hook output priority 0; policy drop;
|
||||
oif lo accept
|
||||
ct state established,related accept
|
||||
oifname "{IFACE}" accept
|
||||
oifname "eth0" ip daddr {{ {rvs} }} tcp dport {rvs_port} accept
|
||||
oifname "eth0" ip daddr @endpoints meta l4proto udp accept
|
||||
{lout}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
sh("nft", "-f", "-", inp=rules)
|
||||
log.info("Firewall aktiv: Tunnel nur ausgehend; ins ARIA-Netz nur RVS %s:%d", rvs, rvs_port)
|
||||
|
||||
|
||||
def setup(c: dict) -> dict:
|
||||
rvs_host = os.environ.get("RVS_HOST", "")
|
||||
rvs_port = int(os.environ.get("RVS_PORT", "443") or 443)
|
||||
rvs_ips = resolve4(rvs_host)
|
||||
if not rvs_ips:
|
||||
raise RuntimeError(f"RVS {rvs_host!r} nicht aufloesbar")
|
||||
gw = default_gw()
|
||||
|
||||
ep_ips: list[str] = []
|
||||
for p in c["peers"]:
|
||||
if p.get("endpoint"):
|
||||
h, _ = endpoint_host_port(p["endpoint"])
|
||||
ep_ips += resolve4(h)
|
||||
|
||||
sh("ip", "link", "del", IFACE, check=False)
|
||||
try:
|
||||
sh("ip", "link", "add", IFACE, "type", "wireguard")
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(f"WireGuard im Kernel nicht verfuegbar (modprobe wireguard auf dem Host?): {e}")
|
||||
sh("wg", "setconf", IFACE, "/dev/stdin", inp=setconf_text(c))
|
||||
for a in _split(c["iface"].get("address", "")):
|
||||
if ":" not in a:
|
||||
sh("ip", "-4", "address", "add", a, "dev", IFACE)
|
||||
sh("ip", "link", "set", IFACE, "mtu", c["iface"].get("mtu") or "1420", "up")
|
||||
|
||||
# RVS und Endpunkte immer am Tunnel vorbei
|
||||
for ip in set(rvs_ips + ep_ips):
|
||||
sh("ip", "route", "replace", f"{ip}/32", "via", gw, "dev", "eth0")
|
||||
nets = []
|
||||
for p in c["peers"]:
|
||||
for n in _split(p.get("allowedips", "")):
|
||||
if ":" in n:
|
||||
continue
|
||||
net = ipaddress.ip_network(n, strict=False)
|
||||
if net.prefixlen == 0:
|
||||
for half in ("0.0.0.0/1", "128.0.0.0/1"):
|
||||
sh("ip", "route", "replace", half, "dev", IFACE)
|
||||
else:
|
||||
sh("ip", "route", "replace", str(net), "dev", IFACE)
|
||||
nets.append(str(net))
|
||||
firewall(rvs_ips, rvs_port, ep_ips, int(c["iface"].get("listenport") or 0))
|
||||
log.info("Tunnel %s oben: Netze %s, Endpunkt(e) %s", IFACE, ", ".join(nets) or "-", ", ".join(ep_ips) or "-")
|
||||
return {"nets": nets, "endpoints": ep_ips}
|
||||
|
||||
|
||||
def scan_nets(c: dict) -> str:
|
||||
"""Was der Satellit abklopft: SCAN_NETS (aus der Diagnostic) oder die
|
||||
AllowedIPs ohne 0.0.0.0/0. Zu grosse Netze kuerzt satellite.py selbst."""
|
||||
if os.environ.get("SCAN_NETS", "").strip():
|
||||
return os.environ["SCAN_NETS"].strip()
|
||||
nets = []
|
||||
for p in c["peers"]:
|
||||
for n in _split(p.get("allowedips", "")):
|
||||
if ":" not in n and not n.endswith("/0"):
|
||||
nets.append(n)
|
||||
return ",".join(nets)
|
||||
|
||||
|
||||
# ── Beobachten ──────────────────────────────────────────────────
|
||||
|
||||
def watch(c: dict) -> None:
|
||||
connected = False
|
||||
since = 0
|
||||
last_resolve = 0.0
|
||||
while not _stop.is_set():
|
||||
try:
|
||||
dump = sh("wg", "show", IFACE, "dump").splitlines()[1:]
|
||||
now = int(time.time())
|
||||
hs = max((int(l.split("\t")[4]) for l in dump if l.count("\t") >= 6), default=0)
|
||||
rx = sum(int(l.split("\t")[5]) for l in dump if l.count("\t") >= 6)
|
||||
tx = sum(int(l.split("\t")[6]) for l in dump if l.count("\t") >= 6)
|
||||
ep = ", ".join(l.split("\t")[2] for l in dump if l.count("\t") >= 6)
|
||||
ok = hs > 0 and now - hs < HANDSHAKE_OK_S
|
||||
if ok and not connected:
|
||||
since = now
|
||||
event("verbunden", endpoint=ep)
|
||||
log.info("verbunden (%s)", ep)
|
||||
elif connected and not ok:
|
||||
event("getrennt", dauer_s=now - since, rx=rx, tx=tx)
|
||||
log.info("getrennt nach %d s", now - since)
|
||||
connected = ok
|
||||
_conn.update(connected=ok, since=since, rx=rx, tx=tx)
|
||||
st = {"state": "verbunden" if ok else "getrennt", "since": since if ok else 0,
|
||||
"handshake": hs, "rx": rx, "tx": tx, "endpoint": ep, "updated": now}
|
||||
tmp = STATUS + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(st, f)
|
||||
os.replace(tmp, STATUS)
|
||||
# DynDNS beim Kunden: ohne Verbindung den Namen neu aufloesen
|
||||
if not ok and time.time() - last_resolve > 120:
|
||||
last_resolve = time.time()
|
||||
for p in c["peers"]:
|
||||
if not p.get("endpoint"):
|
||||
continue
|
||||
h, port = endpoint_host_port(p["endpoint"])
|
||||
for ip in resolve4(h):
|
||||
sh("nft", "add", "element", "inet", "vpnsat", "endpoints", f"{{ {ip} }}", check=False)
|
||||
sh("ip", "route", "replace", f"{ip}/32", "via", default_gw(), "dev", "eth0", check=False)
|
||||
sh("wg", "set", IFACE, "peer", p["publickey"], "endpoint", f"{h}:{port}", check=False)
|
||||
except Exception as e:
|
||||
log.warning("Beobachten: %s", e)
|
||||
_stop.wait(WATCH_S)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def _term(*_):
|
||||
_stop.set()
|
||||
signal.signal(signal.SIGTERM, _term)
|
||||
signal.signal(signal.SIGINT, _term)
|
||||
|
||||
try:
|
||||
c = parse_conf(open(CONF, encoding="utf-8").read())
|
||||
info = setup(c)
|
||||
except Exception as e:
|
||||
log.error("Tunnel nicht aufgebaut: %s", e)
|
||||
event("fehler", meldung=str(e)[:300])
|
||||
with open(STATUS, "w") as f:
|
||||
json.dump({"state": "fehler", "error": str(e)[:300], "updated": int(time.time())}, f)
|
||||
time.sleep(60) # nicht im Sekundentakt neu starten
|
||||
sys.exit(1)
|
||||
event("gestartet", netze=info["nets"])
|
||||
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"SAT_KIND": "vpn",
|
||||
"SATELLITE_ID": env.get("SATELLITE_ID") or f"vpn-{env.get('VPN_ID', 'x')}",
|
||||
"SATELLITE_LOCATION": env.get("VPN_NAME") or env.get("SATELLITE_LOCATION") or "VPN",
|
||||
"SCAN_NETS": scan_nets(c),
|
||||
"LIST_SELF": "false",
|
||||
"CONTROL_ENABLED": env.get("CONTROL_ENABLED", "true"),
|
||||
})
|
||||
threading.Thread(target=watch, args=(c,), daemon=True).start()
|
||||
proc = None
|
||||
while not _stop.is_set():
|
||||
proc = subprocess.Popen([sys.executable, "-u", "/app/satellite.py"], env=env)
|
||||
while proc.poll() is None and not _stop.is_set():
|
||||
time.sleep(1)
|
||||
if not _stop.is_set():
|
||||
log.warning("satellite.py beendet (rc=%s) — starte neu", proc.returncode)
|
||||
_stop.wait(5)
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
if _conn["connected"]:
|
||||
event("getrennt", dauer_s=int(time.time()) - _conn["since"], rx=_conn["rx"], tx=_conn["tx"])
|
||||
event("gestoppt")
|
||||
sh("ip", "link", "del", IFACE, check=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
ARIA VPN-Verteiler — EIN UDP-Port fuer alle WireGuard-Server-Verbindungen.
|
||||
|
||||
Kunden waehlen sich alle auf denselben Port ein (UDP 443 am RVS-Hostnamen,
|
||||
per NPM-Stream bzw. Router hierher weitergeleitet). Jede Server-Verbindung
|
||||
laeuft aber in ihrem EIGENEN Container (eigener Netz-Namensraum, wegen
|
||||
ueberlappender Kundennetze). Der Verteiler ordnet die Pakete zu, ohne etwas
|
||||
zu entschluesseln:
|
||||
|
||||
Typ 1 (Handshake-Anfang): mac1 = BLAKE2s(Schluessel aus dem oeffentlichen
|
||||
Schluessel des EMPFAENGERS, Nachricht). Jede Verbindung hat ein eigenes
|
||||
Server-Schluesselpaar → der Verteiler rechnet mac1 fuer jede bekannte
|
||||
Verbindung nach und weiss, wohin das Paket gehoert.
|
||||
Typ 2 (Antwort des Containers): merkt sich dessen Sitzungsnummer.
|
||||
Typ 3/4 (Cookie/Daten): nach Absender-Adresse, sonst nach Sitzungsnummer
|
||||
(Kunde hat die Adresse gewechselt).
|
||||
|
||||
Jeder Absender bekommt einen eigenen Upstream-Socket zum Container, so finden
|
||||
die Antworten zurueck. Unbekanntes wird still verworfen — wie WireGuard selbst
|
||||
(ein Portscan sieht nichts).
|
||||
|
||||
Laeuft mit network_mode: host (erreicht die Container-IPs im Netz aria-vpn,
|
||||
dort ist Container-zu-Container gesperrt). Liest /vpn/relay.json, das die
|
||||
Diagnostic schreibt: [{id, ip, port, pub}].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn-relay] %(levelname)s %(message)s")
|
||||
log = logging.getLogger("vpn-relay")
|
||||
|
||||
LISTEN_PORT = int(os.environ.get("VPN_RELAY_PORT", "51820") or 51820)
|
||||
MAP_FILE = os.environ.get("VPN_RELAY_MAP", "/vpn/relay.json")
|
||||
IDLE_S = 600
|
||||
MAX_CLIENTS = 2048
|
||||
|
||||
_targets: list[dict] = [] # [{id, addr:(ip,port), mac1key}]
|
||||
_map_mtime = 0.0
|
||||
_by_index: dict[int, str] = {} # Sitzungsnummer des Containers -> id
|
||||
_clients: dict = {} # Kunden-Adresse -> Upstream
|
||||
|
||||
|
||||
def _mac1_key(pub_b64: str) -> bytes:
|
||||
pub = base64.b64decode(pub_b64)
|
||||
return hashlib.blake2s(b"mac1----" + pub).digest()
|
||||
|
||||
|
||||
def load_map() -> None:
|
||||
global _targets, _map_mtime
|
||||
try:
|
||||
m = os.path.getmtime(MAP_FILE)
|
||||
except OSError:
|
||||
if _targets:
|
||||
log.info("keine Zuordnung mehr (%s fehlt)", MAP_FILE)
|
||||
_targets, _map_mtime = [], 0.0
|
||||
return
|
||||
if m == _map_mtime:
|
||||
return
|
||||
try:
|
||||
rows = json.load(open(MAP_FILE))
|
||||
_targets = [{"id": r["id"], "addr": (r["ip"], int(r.get("port", 51820))),
|
||||
"mac1key": _mac1_key(r["pub"])} for r in rows if r.get("ip") and r.get("pub")]
|
||||
_map_mtime = m
|
||||
log.info("%d Server-Verbindung(en): %s", len(_targets),
|
||||
", ".join(f"{t['id']}→{t['addr'][0]}" for t in _targets) or "-")
|
||||
except Exception as e:
|
||||
log.warning("relay.json unlesbar: %s", e)
|
||||
|
||||
|
||||
def target_for_init(data: bytes) -> dict | None:
|
||||
if len(data) != 148:
|
||||
return None
|
||||
for t in _targets:
|
||||
mac = hashlib.blake2s(data[:116], key=t["mac1key"], digest_size=16).digest()
|
||||
if mac == data[116:132]:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
class Upstream(asyncio.DatagramProtocol):
|
||||
"""Ein Socket je Kunde → Container; leitet Antworten zurueck."""
|
||||
|
||||
def __init__(self, front, client_addr, target):
|
||||
self.front, self.client, self.target = front, client_addr, target
|
||||
self.transport = None
|
||||
self.last = time.time()
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
self.last = time.time()
|
||||
if len(data) >= 8 and data[0] == 2: # Antwort: Sitzungsnummer merken
|
||||
_by_index[struct.unpack_from("<I", data, 4)[0]] = self.target["id"]
|
||||
self.front.sendto(data, self.client)
|
||||
|
||||
def error_received(self, exc):
|
||||
pass
|
||||
|
||||
|
||||
class Front(asyncio.DatagramProtocol):
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
asyncio.ensure_future(self.handle(data, addr))
|
||||
|
||||
async def handle(self, data: bytes, addr) -> None:
|
||||
if len(data) < 4:
|
||||
return
|
||||
load_map()
|
||||
kind = data[0]
|
||||
up = _clients.get(addr)
|
||||
target = None
|
||||
if kind == 1:
|
||||
target = target_for_init(data)
|
||||
if not target:
|
||||
return # fremder Schluessel → still verwerfen
|
||||
elif up is None and kind == 4 and len(data) >= 8:
|
||||
tid = _by_index.get(struct.unpack_from("<I", data, 4)[0])
|
||||
target = next((t for t in _targets if t["id"] == tid), None)
|
||||
if not target:
|
||||
return
|
||||
elif up is None:
|
||||
return
|
||||
if up and target and up.target["id"] != target["id"]:
|
||||
up.transport.close()
|
||||
up = None
|
||||
if up is None or up.target["addr"] != (target or up.target)["addr"]:
|
||||
if up:
|
||||
up.transport.close()
|
||||
if len(_clients) >= MAX_CLIENTS:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
t = target or up.target
|
||||
_, up = await loop.create_datagram_endpoint(
|
||||
lambda: Upstream(self.transport, addr, t), remote_addr=t["addr"])
|
||||
_clients[addr] = up
|
||||
up.last = time.time()
|
||||
up.transport.sendto(data)
|
||||
|
||||
|
||||
async def janitor() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
now = time.time()
|
||||
for a, up in list(_clients.items()):
|
||||
if now - up.last > IDLE_S:
|
||||
up.transport.close()
|
||||
del _clients[a]
|
||||
if len(_by_index) > 8192:
|
||||
_by_index.clear()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_map()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.create_datagram_endpoint(Front, local_addr=("0.0.0.0", LISTEN_PORT))
|
||||
log.info("lausche auf UDP %d", LISTEN_PORT)
|
||||
await janitor()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -181,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
|
||||
@@ -206,6 +207,23 @@ services:
|
||||
- HF_TOKEN=${HF_TOKEN:-}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── LLM-Updater — llama-swap/llama.cpp aktualisieren ─
|
||||
# Der EINZIGE Dienst mit Docker-Socket (= root auf der Box). Darum kann er nur
|
||||
# drei feste Dinge fuer aria-llama-swap: Version pruefen, aktualisieren (altes
|
||||
# Image sichern, ziehen, neu anlegen, Selbsttest, sonst zurueck) und auf die
|
||||
# gesicherte Version zuruecksetzen. Kein Port nach aussen; der llm-adapter
|
||||
# spricht ihn im Compose-Netz mit einem Token an (/models/.updater_token).
|
||||
# Ausgeloest wird das aus der Diagnostic (Modell-Katalog → ⚙ llama.cpp).
|
||||
llm-updater:
|
||||
build: ./llm-updater
|
||||
container_name: aria-llm-updater
|
||||
profiles: ["llm"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- .:/stack:ro # Compose-Datei + .env zum Neu-Anlegen
|
||||
- ./models:/models:ro # Token + welche Modelle vorgeladen sind
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── OpenHands (optional, Standalone) ────────────────
|
||||
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
|
||||
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
|
||||
|
||||
@@ -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:
|
||||
|
||||
+823
-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", "llm_runtime_result"}
|
||||
_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).
|
||||
@@ -705,6 +1002,83 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
||||
})
|
||||
|
||||
|
||||
# ── llama-swap/llama.cpp aktualisieren (ueber den llm-updater) ──
|
||||
# Der Adapter hat bewusst KEINEN Docker-Zugriff. Pruefen, Aktualisieren und
|
||||
# Zuruecksetzen macht der llm-updater (eigener Container, nur im Compose-Netz);
|
||||
# der Adapter reicht die Diagnostic-Anfrage weiter und meldet den Fortschritt.
|
||||
UPDATER_URL = os.getenv("UPDATER_URL", "http://llm-updater:8090").rstrip("/")
|
||||
_UPDATER_TOKEN_FILE = "/models/.updater_token"
|
||||
|
||||
|
||||
def _updater_token() -> str:
|
||||
"""Gemeinsamer Token mit dem llm-updater. Liegt in /models (der Updater
|
||||
haengt es nur lesend ein); fehlt er, legt der Adapter ihn an."""
|
||||
try:
|
||||
tok = open(_UPDATER_TOKEN_FILE).read().strip()
|
||||
if tok:
|
||||
return tok
|
||||
except OSError:
|
||||
pass
|
||||
import secrets
|
||||
tok = secrets.token_hex(24)
|
||||
try:
|
||||
fd = os.open(_UPDATER_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(tok)
|
||||
except OSError as e:
|
||||
logger.warning("Updater-Token nicht schreibbar: %s", e)
|
||||
return tok
|
||||
|
||||
|
||||
async def _updater(method: str, path: str, timeout: float = 60) -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as c:
|
||||
r = await c.request(method, UPDATER_URL + path,
|
||||
headers={"X-Updater-Token": _updater_token()})
|
||||
return r.json()
|
||||
except httpx.ConnectError:
|
||||
return {"ok": False, "error": "Der Update-Dienst (llm-updater) laeuft auf dieser Box "
|
||||
"nicht. Mit `docker compose up -d llm-updater` starten."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Update-Dienst: {str(e)[:150]}"}
|
||||
|
||||
|
||||
async def _handle_runtime(ws, payload: dict) -> None:
|
||||
"""op = status | check | update | rollback (aus der Diagnostic)."""
|
||||
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
|
||||
return
|
||||
op = payload.get("op", "status")
|
||||
base = {"requestId": payload.get("requestId", ""), "instanceId": INSTANCE_ID,
|
||||
"node": NODE_NAME, "op": op}
|
||||
if op in ("status", "check"):
|
||||
res = await _updater("GET", "/" + op, timeout=60)
|
||||
await _send(ws, "llm_runtime_result", {**base, **res})
|
||||
return
|
||||
if op not in ("update", "rollback"):
|
||||
await _send(ws, "llm_runtime_result", {**base, "ok": False, "error": "unbekannte Aktion"})
|
||||
return
|
||||
start = await _updater("POST", "/" + op)
|
||||
if not start.get("ok"):
|
||||
await _send(ws, "llm_runtime_result", {**base, **start})
|
||||
return
|
||||
logger.info("llama-swap %s gestartet (%s)", op, start.get("job"))
|
||||
seen = 0
|
||||
while True:
|
||||
await asyncio.sleep(3)
|
||||
job = await _updater("GET", "/job", timeout=20)
|
||||
if job.get("error") and "state" not in job:
|
||||
continue # Updater kurz nicht erreichbar — weiter warten
|
||||
lines = job.get("log") or []
|
||||
if len(lines) > seen:
|
||||
await _send(ws, "llm_runtime_progress", {**base, "lines": lines[seen:]})
|
||||
seen = len(lines)
|
||||
if job.get("state") == "done":
|
||||
await _send(ws, "llm_runtime_result", {**base, **(job.get("result") or {})})
|
||||
logger.info("llama-swap %s fertig: %s", op, (job.get("result") or {}).get("ok"))
|
||||
await _announce(ws)
|
||||
return
|
||||
|
||||
|
||||
async def _handle_openai_config(ws, payload: dict) -> None:
|
||||
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
|
||||
|
||||
@@ -841,9 +1215,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 +1233,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 +1508,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 +1574,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 +1590,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 +1685,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 +1739,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 +1789,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 +1822,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 +1852,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 +1896,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 +1934,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))
|
||||
@@ -1162,6 +1963,9 @@ async def _run() -> None:
|
||||
if mtype == "llm_openai_config":
|
||||
asyncio.create_task(_handle_openai_config(ws, payload))
|
||||
continue
|
||||
if mtype == "llm_runtime":
|
||||
asyncio.create_task(_handle_runtime(ws, payload))
|
||||
continue
|
||||
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model",
|
||||
"llm_unload_model", "llm_models_request"):
|
||||
continue
|
||||
|
||||
@@ -96,6 +96,31 @@ class NodeStats:
|
||||
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||
|
||||
async def _query_cards(self) -> list:
|
||||
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
|
||||
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
|
||||
Dienste einer Box sie melden."""
|
||||
out = await _run_cmd(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits")
|
||||
cards = []
|
||||
for line in out.strip().splitlines():
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 9:
|
||||
continue
|
||||
|
||||
def num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
|
||||
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
|
||||
"power": num(p[7]), "powerLimit": num(p[8])})
|
||||
return cards
|
||||
|
||||
async def _nvidia_smi_text(self) -> str:
|
||||
txt = await _run_cmd("nvidia-smi")
|
||||
return txt or "nvidia-smi nicht verfuegbar"
|
||||
@@ -128,9 +153,10 @@ class NodeStats:
|
||||
while time.time() - t0 < STREAM_MAX_SEC:
|
||||
g = await self._query_gpu()
|
||||
smi = await self._nvidia_smi_text()
|
||||
cards = await self._query_cards()
|
||||
await send(ws, "node_stats", {
|
||||
"instanceId": self.instance_id, "node": self.node_name,
|
||||
"nvidiaSmi": smi, **g,
|
||||
"nvidiaSmi": smi, "cards": cards, **g,
|
||||
})
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Docker-CLI mit Compose-Plugin + Python (nur Standardbibliothek + PyYAML)
|
||||
FROM docker:29-cli
|
||||
|
||||
RUN apk add --no-cache python3 py3-yaml
|
||||
WORKDIR /app
|
||||
COPY updater.py .
|
||||
|
||||
CMD ["python3", "-u", "updater.py"]
|
||||
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
ARIA LLM-Updater (AI-Box) — aktualisiert das llama-swap-Image (mit llama.cpp).
|
||||
|
||||
Der einzige Dienst mit Docker-Socket. Darum kann er bewusst NUR drei feste
|
||||
Dinge, alle fuer genau einen Container (aria-llama-swap):
|
||||
|
||||
check installierte Version + Image-Pruefsumme, dazu die neueste aus der
|
||||
Registry (Pruefsumme des Tags) und das letzte llama-swap-Release
|
||||
update altes Image sichern (Tag vor-update-<Zeit>), neues ziehen,
|
||||
Container neu anlegen, Selbsttest — scheitert der, automatisch zurueck
|
||||
rollback das gesicherte Image wieder einsetzen, Container neu anlegen, Selbsttest
|
||||
|
||||
Kein freier Befehl, keine anderen Container. Erreichbar nur im Compose-Netz
|
||||
(kein Port nach aussen), jede Anfrage braucht den Token, den der llm-adapter
|
||||
in /models/.updater_token ablegt.
|
||||
|
||||
Neu angelegt wird ueber `docker compose … up --no-deps --force-recreate
|
||||
llama-swap` mit der Compose-Datei des Stacks — so bleibt alles, was die
|
||||
Compose-Datei festlegt (GPU-Runtime, Volumes, LD_LIBRARY_PATH …), gleich.
|
||||
Relative Pfade loest Compose gegen --project-directory auf; das ist der
|
||||
Host-Pfad aus dem Label des laufenden Containers, er muss im Container hier
|
||||
nicht existieren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("llm-updater")
|
||||
|
||||
CONTAINER = os.getenv("LLAMA_CONTAINER", "aria-llama-swap")
|
||||
SERVICE = os.getenv("LLAMA_SERVICE", "llama-swap")
|
||||
STACK_DIR = os.getenv("STACK_DIR", "/stack")
|
||||
TOKEN_FILE = os.getenv("TOKEN_FILE", "/models/.updater_token")
|
||||
GEN_CONFIG = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama-swap:8080").rstrip("/")
|
||||
RELEASES_URL = os.getenv("RELEASES_URL", "https://api.github.com/repos/mostlygeek/llama-swap/releases/latest")
|
||||
BACKUP_REPO = "llama-swap" # gesicherte Images: llama-swap:vor-update-<Zeit>
|
||||
KEEP_BACKUPS = 1
|
||||
READY_TIMEOUT_S = int(os.getenv("READY_TIMEOUT_S", "900")) # Modell(e) wieder geladen
|
||||
PORT = int(os.getenv("PORT", "8090"))
|
||||
|
||||
_job = {"id": "", "op": "", "state": "idle", "log": [], "result": None}
|
||||
_job_lock = threading.Lock()
|
||||
|
||||
|
||||
# ── kleine Helfer ───────────────────────────────────────────────
|
||||
|
||||
def _run(args: list[str], timeout: int = 120) -> tuple[int, str]:
|
||||
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
return p.returncode, (p.stdout + p.stderr).strip()
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
logger.info(msg)
|
||||
with _job_lock:
|
||||
_job["log"].append(f"{time.strftime('%H:%M:%S')} {msg}")
|
||||
_job["log"] = _job["log"][-60:]
|
||||
|
||||
|
||||
def _inspect(ref: str, kind: str = "container") -> dict:
|
||||
rc, out = _run(["docker", kind, "inspect", ref])
|
||||
if rc != 0:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(out)[0]
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _image_ref() -> str:
|
||||
return (_inspect(CONTAINER).get("Config") or {}).get("Image", "")
|
||||
|
||||
|
||||
def _local_digest(ref: str) -> str:
|
||||
"""Pruefsumme, unter der das Image gezogen wurde (Index/Manifest des Tags)."""
|
||||
repo = ref.rsplit(":", 1)[0] if ":" in ref.split("/")[-1] else ref
|
||||
for d in _inspect(ref, "image").get("RepoDigests") or []:
|
||||
if d.startswith(repo + "@"):
|
||||
return d.split("@", 1)[1]
|
||||
return ""
|
||||
|
||||
|
||||
def _versions() -> dict:
|
||||
v = {}
|
||||
rc, out = _run(["docker", "exec", CONTAINER, "llama-swap", "-version"], 30)
|
||||
m = re.search(r"version:\s*(\S+)", out)
|
||||
if rc == 0 and m:
|
||||
v["llamaSwap"] = m.group(1)
|
||||
b = re.search(r"built at\s*(\S+)", out)
|
||||
if b:
|
||||
v["llamaSwapBuilt"] = b.group(1)
|
||||
rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--version"], 30)
|
||||
m = re.search(r"version:\s*(.+)", out)
|
||||
if rc == 0 and m:
|
||||
v["llamaCpp"] = m.group(1).strip()
|
||||
return v
|
||||
|
||||
|
||||
def _backups() -> list[dict]:
|
||||
rc, out = _run(["docker", "images", BACKUP_REPO, "--format", "{{.Tag}}\t{{.ID}}\t{{.CreatedAt}}"])
|
||||
res = []
|
||||
for line in out.splitlines() if rc == 0 else []:
|
||||
tag, iid, created = (line.split("\t") + ["", "", ""])[:3]
|
||||
if tag.startswith("vor-update-"):
|
||||
res.append({"tag": f"{BACKUP_REPO}:{tag}", "id": iid, "created": created})
|
||||
return sorted(res, key=lambda b: b["tag"], reverse=True)
|
||||
|
||||
|
||||
def _http(url: str, method: str = "GET", headers: dict | None = None, timeout: int = 20):
|
||||
req = urllib.request.Request(url, method=method, headers=headers or {})
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def _remote_digest(ref: str) -> str:
|
||||
"""Pruefsumme des Tags in der Registry (ohne Docker, anonymer Token)."""
|
||||
name, tag = ref.rsplit(":", 1) if ":" in ref.split("/")[-1] else (ref, "latest")
|
||||
parts = name.split("/")
|
||||
if "." in parts[0] or ":" in parts[0]:
|
||||
host, path = parts[0], "/".join(parts[1:])
|
||||
else:
|
||||
host, path = "registry-1.docker.io", name if "/" in name else f"library/{name}"
|
||||
url = f"https://{host}/v2/{path}/manifests/{tag}"
|
||||
accept = ", ".join([
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
])
|
||||
try:
|
||||
r = _http(url, "HEAD", {"Accept": accept})
|
||||
return r.headers.get("Docker-Content-Digest", "")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 401:
|
||||
raise
|
||||
chal = e.headers.get("WWW-Authenticate", "")
|
||||
p = dict(re.findall(r'(\w+)="([^"]*)"', chal))
|
||||
tok_url = f"{p['realm']}?service={p.get('service', '')}&scope={p.get('scope', f'repository:{path}:pull')}"
|
||||
tok = json.load(_http(tok_url))
|
||||
token = tok.get("token") or tok.get("access_token") or ""
|
||||
r = _http(url, "HEAD", {"Accept": accept, "Authorization": f"Bearer {token}"})
|
||||
return r.headers.get("Docker-Content-Digest", "")
|
||||
|
||||
|
||||
def _latest_release() -> dict:
|
||||
try:
|
||||
d = json.load(_http(RELEASES_URL, headers={"Accept": "application/vnd.github+json"}))
|
||||
return {"tag": d.get("tag_name", ""), "published": d.get("published_at", ""),
|
||||
"url": d.get("html_url", "")}
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:120]}
|
||||
|
||||
|
||||
def _status(remote: bool) -> dict:
|
||||
ref = _image_ref()
|
||||
if not ref:
|
||||
return {"ok": False, "error": f"Container {CONTAINER} nicht gefunden"}
|
||||
img = _inspect(ref, "image")
|
||||
st = {
|
||||
"ok": True, "image": ref,
|
||||
"installed": {**_versions(), "digest": _local_digest(ref),
|
||||
"created": img.get("Created", "")},
|
||||
"backups": _backups(),
|
||||
"job": {k: _job[k] for k in ("id", "op", "state")},
|
||||
}
|
||||
if remote:
|
||||
try:
|
||||
st["latestDigest"] = _remote_digest(ref)
|
||||
except Exception as e:
|
||||
st["latestError"] = f"Registry nicht erreichbar: {str(e)[:120]}"
|
||||
st["release"] = _latest_release()
|
||||
if st.get("latestDigest") and st["installed"]["digest"]:
|
||||
st["updateAvailable"] = st["latestDigest"] != st["installed"]["digest"]
|
||||
return st
|
||||
|
||||
|
||||
# ── Neu anlegen + Selbsttest ────────────────────────────────────
|
||||
|
||||
def _compose_args() -> list[str]:
|
||||
labels = (_inspect(CONTAINER).get("Config") or {}).get("Labels") or {}
|
||||
proj = labels.get("com.docker.compose.project", "")
|
||||
wdir = labels.get("com.docker.compose.project.working_dir", "")
|
||||
if not proj or not wdir:
|
||||
raise RuntimeError("Container wurde nicht mit Compose angelegt — Labels fehlen")
|
||||
args = ["docker", "compose", "--project-directory", wdir, "-p", proj,
|
||||
"-f", os.path.join(STACK_DIR, "docker-compose.yml"), "--profile", "llm"]
|
||||
env = os.path.join(STACK_DIR, ".env")
|
||||
if os.path.exists(env):
|
||||
args += ["--env-file", env]
|
||||
return args
|
||||
|
||||
|
||||
def _recreate() -> None:
|
||||
rc, out = _run(_compose_args() + ["up", "-d", "--no-deps", "--force-recreate",
|
||||
"--pull", "never", SERVICE], 300)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Container neu anlegen fehlgeschlagen: {out[-400:]}")
|
||||
_log("Container neu angelegt")
|
||||
|
||||
|
||||
def _preloaded() -> list[str]:
|
||||
try:
|
||||
import yaml
|
||||
cfg = yaml.safe_load(open(GEN_CONFIG)) or {}
|
||||
return list(((cfg.get("hooks") or {}).get("on_startup") or {}).get("preload") or [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _selftest() -> tuple[bool, str]:
|
||||
"""Karten sichtbar (sonst rechnet llama.cpp still auf der CPU) und die
|
||||
vorher geladenen Modelle wieder bereit."""
|
||||
for _ in range(30):
|
||||
if (_inspect(CONTAINER).get("State") or {}).get("Running"):
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
return False, "Container laeuft nicht"
|
||||
rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--list-devices"], 60)
|
||||
cards = [l.strip() for l in out.splitlines() if re.match(r"\s*CUDA\d+", l)]
|
||||
if not cards:
|
||||
return False, f"llama.cpp sieht keine Grafikkarte (wuerde auf der CPU rechnen): {out[-300:]}"
|
||||
_log(f"Karten sichtbar: {len(cards)} ({'; '.join(c[:60] for c in cards)})")
|
||||
want = _preloaded()
|
||||
if not want:
|
||||
return True, f"{len(cards)} Karte(n) sichtbar, kein Modell vorgeladen"
|
||||
_log(f"warte, bis {', '.join(want)} geladen ist …")
|
||||
deadline = time.time() + READY_TIMEOUT_S
|
||||
last = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
run = json.load(_http(f"{LLAMA_URL}/running", timeout=10)).get("running") or []
|
||||
states = {r.get("model"): r.get("state") for r in run}
|
||||
last = ", ".join(f"{m}={states.get(m, '-')}" for m in want)
|
||||
if all(states.get(m) == "ready" for m in want):
|
||||
return True, f"{len(cards)} Karte(n), Modell(e) bereit: {', '.join(want)}"
|
||||
except Exception as e:
|
||||
last = str(e)[:80]
|
||||
time.sleep(5)
|
||||
return False, f"Modell nach {READY_TIMEOUT_S // 60} min nicht bereit ({last})"
|
||||
|
||||
|
||||
def _drop_untagged(image_id: str) -> None:
|
||||
"""Ein Image, das nach dem Zuruecksetzen keinen Namen mehr hat, belegt nur
|
||||
noch Platz (das llama-swap-Image hat ~14 GB)."""
|
||||
if image_id and not (_inspect(image_id, "image").get("RepoTags") or []):
|
||||
rc, _ = _run(["docker", "rmi", image_id])
|
||||
if rc == 0:
|
||||
_log("verworfene Version geloescht")
|
||||
|
||||
|
||||
def _prune_backups() -> None:
|
||||
for b in _backups()[KEEP_BACKUPS:]:
|
||||
_run(["docker", "rmi", b["tag"]])
|
||||
_log(f"alte Sicherung entfernt: {b['tag']}")
|
||||
|
||||
|
||||
# ── Jobs ────────────────────────────────────────────────────────
|
||||
|
||||
def _do_update() -> dict:
|
||||
ref = _image_ref()
|
||||
before = _status(remote=False)
|
||||
old_id = _inspect(ref, "image").get("Id", "")
|
||||
backup = f"{BACKUP_REPO}:vor-update-{time.strftime('%Y%m%d-%H%M%S')}"
|
||||
rc, out = _run(["docker", "tag", old_id, backup])
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Sicherung fehlgeschlagen: {out}")
|
||||
_log(f"altes Image gesichert als {backup}")
|
||||
_log(f"ziehe {ref} … (mehrere GB, dauert)")
|
||||
rc, out = _run(["docker", "pull", ref], 3600)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Ziehen fehlgeschlagen: {out[-400:]}")
|
||||
new_id = _inspect(ref, "image").get("Id", "")
|
||||
if new_id == old_id:
|
||||
_run(["docker", "rmi", backup])
|
||||
return {"ok": True, "changed": False, "message": "Schon aktuell — nichts geaendert."}
|
||||
_recreate()
|
||||
ok, why = _selftest()
|
||||
if ok:
|
||||
_prune_backups()
|
||||
after = _status(remote=False)
|
||||
return {"ok": True, "changed": True, "before": before.get("installed"),
|
||||
"after": after.get("installed"), "selftest": why, "backup": backup}
|
||||
_log(f"Selbsttest fehlgeschlagen: {why} — setze zurueck")
|
||||
_run(["docker", "tag", backup, ref])
|
||||
_recreate()
|
||||
_drop_untagged(new_id)
|
||||
ok2, why2 = _selftest()
|
||||
return {"ok": False, "rolledBack": True, "error": f"Selbsttest fehlgeschlagen: {why}",
|
||||
"rollbackSelftest": why2 if ok2 else f"AUCH die alte Version laeuft nicht: {why2}"}
|
||||
|
||||
|
||||
def _do_rollback() -> dict:
|
||||
bks = _backups()
|
||||
if not bks:
|
||||
raise RuntimeError("Keine gesicherte Version vorhanden")
|
||||
ref = _image_ref()
|
||||
cur_id = _inspect(ref, "image").get("Id", "")
|
||||
b = bks[0]
|
||||
rc, out = _run(["docker", "tag", b["tag"], ref])
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Zuruecksetzen fehlgeschlagen: {out}")
|
||||
_log(f"{b['tag']} wieder als {ref} gesetzt")
|
||||
_recreate()
|
||||
_drop_untagged(cur_id)
|
||||
ok, why = _selftest()
|
||||
return {"ok": ok, "selftest": why, "restored": b["tag"],
|
||||
"after": _status(remote=False).get("installed"),
|
||||
**({} if ok else {"error": f"Selbsttest fehlgeschlagen: {why}"})}
|
||||
|
||||
|
||||
def _start_job(op: str) -> dict:
|
||||
with _job_lock:
|
||||
if _job["state"] == "running":
|
||||
return {"ok": False, "error": f"Es laeuft schon: {_job['op']}", "job": _job["id"]}
|
||||
_job.update(id=f"{op}-{int(time.time())}", op=op, state="running", log=[], result=None)
|
||||
jid = _job["id"]
|
||||
|
||||
def work():
|
||||
try:
|
||||
res = _do_update() if op == "update" else _do_rollback()
|
||||
except Exception as e:
|
||||
logger.exception("%s fehlgeschlagen", op)
|
||||
res = {"ok": False, "error": str(e)[:500]}
|
||||
with _job_lock:
|
||||
_job["result"] = res
|
||||
_job["state"] = "done"
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
return {"ok": True, "job": jid}
|
||||
|
||||
|
||||
# ── HTTP ────────────────────────────────────────────────────────
|
||||
|
||||
def _token() -> str:
|
||||
try:
|
||||
return open(TOKEN_FILE).read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *a):
|
||||
pass
|
||||
|
||||
def _reply(self, code: int, body: dict) -> None:
|
||||
data = json.dumps(body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
tok = _token()
|
||||
if not tok or self.headers.get("X-Updater-Token", "") != tok:
|
||||
self._reply(403, {"ok": False, "error": "Token fehlt oder falsch"})
|
||||
return False
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
if not self._authorized():
|
||||
return
|
||||
try:
|
||||
if self.path == "/status":
|
||||
self._reply(200, _status(remote=False))
|
||||
elif self.path == "/check":
|
||||
self._reply(200, _status(remote=True))
|
||||
elif self.path == "/job":
|
||||
with _job_lock:
|
||||
self._reply(200, dict(_job))
|
||||
else:
|
||||
self._reply(404, {"ok": False, "error": "unbekannt"})
|
||||
except Exception as e:
|
||||
self._reply(500, {"ok": False, "error": str(e)[:300]})
|
||||
|
||||
def do_POST(self):
|
||||
if not self._authorized():
|
||||
return
|
||||
if self.path in ("/update", "/rollback"):
|
||||
self._reply(200, _start_job(self.path[1:]))
|
||||
else:
|
||||
self._reply(404, {"ok": False, "error": "unbekannt"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("LLM-Updater auf :%d fuer Container %s", PORT, CONTAINER)
|
||||
http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -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,
|
||||
|
||||
@@ -327,6 +327,7 @@ class StreamSession:
|
||||
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
|
||||
noise_floor: float = 0.0
|
||||
closed: bool = False
|
||||
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
|
||||
endpoint_sent: bool = False
|
||||
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
||||
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
||||
@@ -396,10 +397,12 @@ class SessionManager:
|
||||
sess.last_chunk_at = time.time()
|
||||
return True
|
||||
|
||||
def end_session(self, request_id: str) -> None:
|
||||
def end_session(self, request_id: str, reason: str = "") -> None:
|
||||
sess = self._sessions.get(request_id)
|
||||
if sess is not None:
|
||||
sess.closed = True
|
||||
if str(reason).startswith("cancel"):
|
||||
sess.discard = True # Muelleimer in der App: verwerfen
|
||||
|
||||
def drop(self, request_id: str) -> None:
|
||||
self._sessions.pop(request_id, None)
|
||||
@@ -620,6 +623,9 @@ class SessionManager:
|
||||
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
||||
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
||||
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
||||
if sess.discard:
|
||||
reason = "cancelled"
|
||||
_min_voiced = 10 ** 9 # verworfen: nie transkribieren, leerer Text
|
||||
if sess.voiced_frames < _min_voiced:
|
||||
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
||||
@@ -810,7 +816,7 @@ async def run_loop(sessions: SessionManager) -> None:
|
||||
elif mtype == "stt_audio_chunk":
|
||||
sessions.feed_chunk(payload)
|
||||
elif mtype == "stt_stream_end":
|
||||
sessions.end_session(payload.get("requestId", ""))
|
||||
sessions.end_session(payload.get("requestId", ""), payload.get("reason", ""))
|
||||
elif mtype == "stt_transcribe_blob":
|
||||
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
|
||||
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
|
||||
|
||||
@@ -96,6 +96,31 @@ class NodeStats:
|
||||
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||
|
||||
async def _query_cards(self) -> list:
|
||||
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
|
||||
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
|
||||
Dienste einer Box sie melden."""
|
||||
out = await _run_cmd(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits")
|
||||
cards = []
|
||||
for line in out.strip().splitlines():
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 9:
|
||||
continue
|
||||
|
||||
def num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
|
||||
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
|
||||
"power": num(p[7]), "powerLimit": num(p[8])})
|
||||
return cards
|
||||
|
||||
async def _nvidia_smi_text(self) -> str:
|
||||
txt = await _run_cmd("nvidia-smi")
|
||||
return txt or "nvidia-smi nicht verfuegbar"
|
||||
@@ -128,9 +153,10 @@ class NodeStats:
|
||||
while time.time() - t0 < STREAM_MAX_SEC:
|
||||
g = await self._query_gpu()
|
||||
smi = await self._nvidia_smi_text()
|
||||
cards = await self._query_cards()
|
||||
await send(ws, "node_stats", {
|
||||
"instanceId": self.instance_id, "node": self.node_name,
|
||||
"nvidiaSmi": smi, **g,
|
||||
"nvidiaSmi": smi, "cards": cards, **g,
|
||||
})
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
+11
-3
@@ -390,6 +390,7 @@ class StreamSession:
|
||||
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
||||
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
||||
closed: bool = False # nach stream_end gesetzt
|
||||
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
|
||||
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
|
||||
@@ -483,13 +484,16 @@ class SessionManager:
|
||||
session.last_chunk_at = time.time()
|
||||
return True
|
||||
|
||||
def end_session(self, request_id: str) -> Optional[StreamSession]:
|
||||
def end_session(self, request_id: str, reason: str = "") -> Optional[StreamSession]:
|
||||
"""Markiert Session als geschlossen. Der Endpointer-Loop macht das
|
||||
Final-Transcribe + Cleanup."""
|
||||
Final-Transcribe + Cleanup. reason "cancel:*" (z.B. Muelleimer in der
|
||||
App) = verwerfen: kein Transcribe, leerer Text — nichts geht an ARIA."""
|
||||
session = self._sessions.get(request_id)
|
||||
if session is None:
|
||||
return None
|
||||
session.closed = True
|
||||
if str(reason).startswith("cancel"):
|
||||
session.discard = True
|
||||
return session
|
||||
|
||||
def drop(self, request_id: str) -> None:
|
||||
@@ -807,6 +811,10 @@ class SessionManager:
|
||||
if sess.partial_task is not None and not sess.partial_task.done():
|
||||
sess.partial_task.cancel()
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
if sess.discard:
|
||||
logger.info("Stream %s: verworfen (cancel) — kein Transcribe", sess.request_id[:8])
|
||||
audio = audio[:0]
|
||||
reason = "cancelled"
|
||||
if audio.size == 0:
|
||||
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
|
||||
final_text = ""
|
||||
@@ -1080,7 +1088,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
req_id[:8], payload.get("reason", ""))
|
||||
await _debug_log(ws, "stream.end",
|
||||
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
|
||||
sessions.end_session(req_id)
|
||||
sessions.end_session(req_id, payload.get("reason", ""))
|
||||
|
||||
elif mtype == "voice_id_status_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
|
||||
@@ -96,6 +96,31 @@ class NodeStats:
|
||||
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||
|
||||
async def _query_cards(self) -> list:
|
||||
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
|
||||
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
|
||||
Dienste einer Box sie melden."""
|
||||
out = await _run_cmd(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits")
|
||||
cards = []
|
||||
for line in out.strip().splitlines():
|
||||
p = [x.strip() for x in line.split(",")]
|
||||
if len(p) < 9:
|
||||
continue
|
||||
|
||||
def num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
|
||||
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
|
||||
"power": num(p[7]), "powerLimit": num(p[8])})
|
||||
return cards
|
||||
|
||||
async def _nvidia_smi_text(self) -> str:
|
||||
txt = await _run_cmd("nvidia-smi")
|
||||
return txt or "nvidia-smi nicht verfuegbar"
|
||||
@@ -128,9 +153,10 @@ class NodeStats:
|
||||
while time.time() - t0 < STREAM_MAX_SEC:
|
||||
g = await self._query_gpu()
|
||||
smi = await self._nvidia_smi_text()
|
||||
cards = await self._query_cards()
|
||||
await send(ws, "node_stats", {
|
||||
"instanceId": self.instance_id, "node": self.node_name,
|
||||
"nvidiaSmi": smi, **g,
|
||||
"nvidiaSmi": smi, "cards": cards, **g,
|
||||
})
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
Reference in New Issue
Block a user