Compare commits

..
25 Commits
Author SHA1 Message Date
duffyduck 3773178726 release: bump version to 0.2.6.2 2026-11-01 00:17:38 +01:00
duffyduckandClaude Opus 5.5 3fbe2dd090 feat(diagnostic): Beliebtheit im Modell-Katalog — Downloads und Likes von HuggingFace
HF hat keine Sternebewertung; es gibt Downloads (30 Tage, gesamt) und
Likes. Beide sagen 'viel benutzt', nicht 'gut' (viele Downloads sind
Automatik, Likes oft beim Entdecken). Angezeigt unter dem Modellnamen mit
Hinweis im Tooltip; beim Hinzufuegen sofort, beim taeglichen Abgleich
aufgefrischt. ℹ: Zeile Beliebtheit mit Einordnung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:19:24 +01:00
duffyduckandClaude Opus 5.5 237a216945 feat(diagnostic): Sicherheit im Modell-Katalog — Anbieter, HF-Scan, Skripte im Repo
Die Box laedt nur die GGUF-Datei (llama.cpp -hf / paralleler Download per
sha256); Skripte, Pickle u.ae. im selben Repo kommen nie auf die Box. Liegen
sie dort, ist das trotzdem ein Warnsignal fuer den Anbieter.

- hfRepoSafety: HF-Dateiliste mit securityFileStatus (ProtectAI, AV,
  Pickle-Import, VirusTotal) → Befunde (unsafe/suspicious/caution …),
  Anzahl geprueft-sicher, riskante Dateien (.py/.sh/.exe/.pkl/.pt …),
  bekannter Anbieter (Hersteller + bekannte GGUF-Umwandler).
- Katalog: "⚠ Befund" / "⚠ Skripte" / "✓" je Eintrag; beim Hinzufuegen
  sofort, Bestand beim naechsten Aktualisieren.
- ℹ: Abschnitt "Sicherheit".
- Laden/Nur downloaden: bei Befunden erst Rueckfrage.

Getestet gegen HuggingFace: unsloth/mradermacher bekannt und ohne Befund;
Virtue-AI-HUB/VulnLLM-R-7B (Original) mit Befund "caution" an
training_args.bin (Pickle mit Imports) — das GGUF von mradermacher ist sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:15:48 +01:00
duffyduckandClaude Opus 5.5 9529bb0b16 feat(app): Aufnahme verwerfen — Muelleimer neben dem Aufnahmeknopf
Bisher hiess "Stopp" immer "senden"; wer es sich anders ueberlegt hatte,
konnte die Aufnahme nicht zuruecknehmen.

- App: waehrend der Aufnahme 🗑 links neben dem roten Knopf. Verwirft ueber
  ariaStopRecording('user-discard') → cancelStreamingRecording; das leere
  Endpoint raeumt die Sprechblase ab, zurueck aufs Wake-Word.
- Fund dabei: "cancel" schickte der STT-Bridge nur stt_stream_end — die
  transkribierte trotzdem und der Text ging an ARIA (fiel nur nicht auf,
  weil cancel bisher bei Stille kam). Jetzt:
  - Whisper- und Voxtral-Bridge: stream_end mit reason "cancel:*" →
    discard, kein Transcribe, leerer Text (reason "cancelled").
  - aria-bridge merkt sich verworfene requestIds (2 min) und wirft einen
    trotzdem ankommenden Text weg — Schutz auch bei alter STT-Bridge.

Getestet: Whisper-Bridge mit nachgebautem Runner (Stopp → Text, Muelleimer
→ kein Transcribe, leer). App: TypeScript ohne Fehler; auf dem Handy noch
nicht ausprobiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:05:35 +01:00
duffyduckandClaude Opus 5.5 91619d9307 feat(diagnostic): Katalog zeigt, ob ein Modell Werkzeuge kann (🔧) + Filter
Erkennung: Chat-Vorlage des GGUF (HF expand[]=gguf) kennt ein Werkzeug-
Format, oder Schlagworte wie function-calling/tool-use. Zeigt, OB das
Format da ist — nicht, wie gut das Modell damit umgeht.
- Neue und hinzugefuegte Eintraege bekommen tools: true/false/null,
  bestehende beim naechsten "Von HuggingFace aktualisieren".
- Liste: 🔧-Abzeichen; Haken "nur Modelle mit Werkzeugen" (Standard an)
  blendet Modelle ohne Werkzeug-Format aus (unbekannt bleibt sichtbar).
- ℹ: Zeile "Werkzeuge: ja / nein / unbekannt".

Getestet gegen HuggingFace (7 Modelle, u.a. Hermes 3 ueber das Schlagwort,
weil dessen offizielle Vorlage minimal ist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:41:24 +01:00
duffyduckandClaude Opus 5.5 c8911316d7 feat(brain): lokale Modelle bewusst waehlen — pro Chat (🧠) oder "@lokal …"
Bisher gab es nur den Schalter "leichte Fragen lokal / alles lokal". Jetzt
waehlt Stefan selbst, wo ein lokales Modell antwortet:
- Pro Chat: der Modell-Picker (🧠, context-model) bietet zusaetzlich die
  gerade geladenen lokalen Modelle als "local:<modell>" (Gruppe "Lokal").
  Steht ein Chat darauf, laeuft JEDER Turn dort lokal — z.B. ein Pentest-
  Projekt auf einem unzensierten Modell. Claude ist in dem Chat raus.
- Eine Frage: "@lokal …" (geladenes Modell) oder "@lokal:<modell> …".
- Lehnt Claude ab, nennt ARIA beide Wege; sie leitet NICHT selbst weiter.

Lokaler Turn: Identitaet + Hinweis "keine Werkzeuge, nichts behaupten",
Notizzettel + Fenster nach Kontext (local_compact), <think> entfernt,
Metrik. Fehler (Modell nicht geladen …) werden angezeigt, aber nicht als
ARIAs Antwort in den Verlauf geschrieben. Antwort-Quelle "local" mit
Modellname und Grund ("pro-chat" / "einmalig").

Getestet: Erkennung von @lokal (auch mit Modell, nach GPS-Hinweis, nicht
bei "@lokalisierung"). Durchlauf mit echtem Modell noch nicht (Karte im
Gegenlauf belegt, Brain nicht neu gebaut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:33:23 +01:00
duffyduckandClaude Opus 5.5 d289d9404a feat(diagnostic): Katalog von HuggingFace — Trends, Code, unzensiert; taeglich; aufraeumen
"Von HuggingFace aktualisieren" holte nur die 40 meistgeladenen GGUF
aller Zeiten — neue und unzensierte Modelle kamen so kaum vor. Jetzt:
- Blickwinkel: beliebt, im Trend (trendingScore), Code, unzensiert
  (uncensored/heretic/abliterated).
- Nur Sprachmodelle (pipeline_tag + Namensfilter gegen Bild/Audio/Musik/
  Video/Embedding) und hoechstens LLM_CATALOG_MAX_GB (30).
- Markierungen in der Liste: "unzensiert", "neu" (letzte 7 Tage).
- Einmal am Tag automatisch (LLM_CATALOG_REFRESH_HOURS, 0 = aus).
- Aufraeumen: HF-Eintraege aelter als LLM_CATALOG_KEEP_DAYS (30), auf
  keiner Box und nicht mehr gelistet, fliegen raus; kuratierte und von
  Hand hinzugefuegte bleiben.

Getestet gegen HuggingFace mit Test-Katalog: 43 neue in 2 s, keine
Bild-/Audio-Modelle mehr, groesstes 29,2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:26:11 +01:00
duffyduckandClaude Opus 5.5 faa4868ee2 feat(diagnostic): ℹ im Modell-Katalog — was kann das Modell, ist es zensiert?
Neben jedem Katalog-Eintrag ℹ → Fenster mit:
- Kurzbeschreibung auf Deutsch, beim ersten Oeffnen vom LOKALEN Modell aus
  der Modellkarte (Grundmodell + GGUF-Repo) erzeugt und im Katalog
  gespeichert (summary_de) — so hat jedes neue Modell eine, ohne Pflege.
  Hinweis "kann Fehler enthalten". Laeuft kein Modell: Meldung.
- Feste deutsche Texte je Modellfamilie (Qwen3-Coder, Foundation-Sec,
  VulnLLM, Dolphin, Hermes, Lexi, Gemma, Qwen, Llama, Mistral, DeepSeek,
  Phi), erkannt als ganzes Wort in Name/Grundmodell/Schlagworten.
- "unzensiert" / "mit Schutzfiltern" (uncensored/abliterated/heretic/
  dolphin/lexi … in Name oder Schlagworten).
- Fakten von HF: Grundmodell, Autor, Lizenz, Beliebtheit, Stand,
  Schlagworte; Auszug der Modellkarte (Link-Absaetze uebersprungen).

Server: /api/llm-model-info, /api/llm-model-summary, rvsRequest() als
Promise-Variante fuer eigene RVS-Anfragen.

Getestet: Info-Teil gegen HuggingFace (4 Modelle). Die Zusammenfassung
durch das lokale Modell ungetestet (Karte gerade im Gegenlauf belegt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:24:33 +01:00
duffyduckandClaude Opus 5.5 848eee5d1c feat(diagnostic): Modell per HuggingFace-Link in den Katalog — mit GGUF-Vorschlaegen
Bisher kamen Modelle nur ueber "Von HuggingFace aktualisieren" und die
Trends in den Katalog. Jetzt: Feld "HuggingFace-Link oder org/name".
Hat das Repo kein GGUF (z.B. nur safetensors wie Virtue-AI-HUB/VulnLLM-R-7B
oder fdtn-ai/Foundation-Sec-8B), sucht der Server GGUF-Fassungen desselben
Modells (HF-Suche, filter=gguf, nach Downloads) und bietet sie mit
"Diese nehmen" an. Hinzugefuegte stehen danach gefiltert in der Liste.

Getestet gegen HuggingFace: beide Beispiele ohne GGUF erkannt, je 5
ladbare GGUF-Fassungen gefunden (Q4_K_M ~4,7–4,9 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:14:24 +01:00
duffyduckandClaude Opus 5.5 2cd8f1a0de feat: Kennwort je FritzBox-Sicherung — gespeichert, per 👁 anzeigen, kopieren
Bisher merkte sich der Satellit nur die QUELLE des Kennworts. Wurde das
Sicherungs-Kennwort spaeter geaendert, passte es nicht mehr zu aelteren
Sicherungen — auch "Wiederherstellen" haette das falsche genommen.

- Satellit: das benutzte Kennwort liegt verschluesselt (Fernet, wie der
  Zugangsdaten-Speicher) in der Begleitdatei; Liste/Ansicht geben es nie
  heraus. Wiederherstellen nimmt genau dieses. fritzbox.backup_password
  liefert es (aeltere Sicherungen: aktuelles der Quelle + Hinweis).
  Feld "kennwort" im Sicherungs-Ergebnis heisst jetzt "verschluesselt_mit".
- Diagnostic-Server: Ergebnisse mit Kennwort gehen nur an den Browser,
  der gefragt hat; ohne Fragenden werden sie verworfen, nie an alle.
- Brain: fritzbox.backup_password ist fuer ARIA gesperrt (Kennwoerter
  gehen nicht ans Sprachmodell).
- Diagnostic: 👁 in der Kennwort-Spalte → Zeile mit dem Kennwort und
  "Kopieren" — klappt auch ohne https (Fallback ueber execCommand).

Getestet: Satellit (Kennwort nicht im Klartext gespeichert, nach
Kennwort-Aenderung zeigt 👁 und nimmt Wiederherstellen das alte), Fenster
in headless Chromium ohne Clipboard-API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:26:37 +01:00
duffyduckandClaude Opus 5.5 e44e2c2a70 fix(diagnostic): Sicherungsliste — Kennwort-Spalte mit 🔒 und Erklaerung
Die Spalte zeigt, mit WELCHEM Kennwort die Sicherung verschluesselt ist
(Quelle, nie der Wert) — ohne Erklaerung war das unverstaendlich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:50 +01:00
duffyduckandClaude Opus 5.5 c77526e92a feat: FritzBox-Sicherung herunterladen + maximale Anzahl je Box einstellbar
- Satellit: fritzbox.backup_get liefert eine vorhandene Sicherung (nur aus
  dem Sicherungsordner, bis 20 MB). Bei ARIA landet sie unter
  /shared/uploads/ und kann in den Chat.
- Zugangsdaten FritzBox: "Maximale Sicherungen" (1–100, leer = 10). Bei
  jeder neuen Sicherung fliegt die aelteste dieser Box raus; die Liste
  meldet die Zahl mit (Kopfzeile im 💾-Fenster).
- Diagnostic: ⬇ je Sicherung laedt die .export-Datei herunter.

Getestet: Satellit (Limit 2 → die zwei neuesten bleiben, Download, Pfad-
schutz), Diagnostic in headless Chromium (Download-Inhalt, Feld laden,
0 abgelehnt, 20 gespeichert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:12 +01:00
duffyduckandClaude Opus 5.5 0a1e334169 fix(satellite): FritzBox-Tastenbestaetigung — Token im SOAP-Header mitschicken
An der Kellerbox kam nach dem Tastendruck "stopped" statt "authenticated".
Laut AVM "TR-064 Support – Authentication" (Kap. 6.4) muss der Token aus
X_AVM-DE_Auth SetConfig(start) bei GetState UND beim Wiederholen der
Aktion als <avm:token> im SOAP-Header stehen — ohne ihn ordnet die Box die
Bestaetigung nicht unserem Vorgang zu.

- _tr064(token=…) setzt den Header.
- GetState und die wiederholte Aktion laufen mit dem Token; Stop ebenso.
- Zustaende werden geloggt und erklaert (stopped/blocked/
  anotherauthprocess/failure); 867 (gesperrt, bis 60 min) und 868
  (beschaeftigt, ~2 min) mit verstaendlicher Meldung.

Getestet gegen eine nachgebaute Box, die sich wie in AVMs Doku verhaelt
(ohne Token "stopped", mit Token "authenticated").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:06:11 +01:00
duffyduckandClaude Opus 5.5 236e94da4a feat: alle FritzBoxen sichern (ueberspringt statt abzubrechen) + Tastenbestaetigung anzeigen/schalten
Satellit:
- fritzbox.backup_all: alle Boxen mit hinterlegtem Login nacheinander;
  nicht erreichbar (Port 49000), Fehler oder Taste noetig → notieren und
  weiter. Ergebnis: x von y gesichert + Grund je Box.
- fritzbox.second_factor: ist die Zusatzbestaetigung an, gibt es sie, und
  laesst sie sich per TR-064 schalten (nur wenn die Firmware eine Set-
  Aktion mit Enable-Argument im Auth-Dienst anbietet).
- fritzbox.second_factor_set: schaltet sie (confirmed, Taste per
  button_wait); sonst not_switchable + Weg ueber die Oberflaeche.

Diagnostic (💾-Fenster): Zeile "Tastenbestaetigung: an/aus" mit Aus-/
Einschalten (Rueckfrage, Countdown; nicht schaltbar → Hinweisfenster),
Knopf "Alle FritzBoxen sichern" mit Ergebnis je Box. Ausschalten/
Deaktivieren gilt jetzt als gefaehrliche Rueckfrage (roter Knopf).

Brain: Beschreibung fuer backup_all (je Satellit, weiter bei Fehlern) und
second_factor; Timeout 15 min fuer backup_all.

Getestet gegen nachgebaute Box (eine Box unerreichbar → uebersprungen,
Taste noetig ohne/mit Warten, Status, Schalten mit und ohne Setter) und das
Fenster in headless Chromium. Ob echte FRITZ!OS-Versionen das Schalten
per TR-064 anbieten, ist ungeprueft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:55:06 +01:00
duffyduckandClaude Opus 5.5 2bfa064ef6 feat: FritzBox-Sicherungen verwalten — Liste, Ansicht, Wiederherstellen, Loeschen
Satellit:
- sat_command laeuft als eigener Task (eine Aktion, die 2 min auf die
  Taste wartet, blockiert den Satelliten nicht mehr).
- Sicherung bekommt eine Begleitdatei (Box, Modell, Firmware, Seriennr.,
  Datum, QUELLE des Kennworts — nie der Wert).
- fritzbox.backups: Sicherungen je Box, neueste zuerst.
- fritzbox.backup_view: lesbar — Kopfdaten, wichtigste Einstellungen
  (Name, LAN/DHCP, WLAN, Kanal, DNS, SIP …), mit full alle Bereiche;
  verschluesselte Werte als «verschluesselt»; nur Dateien aus dem
  Sicherungsordner (kein Pfad von aussen).
- fritzbox.restore: X_AVM-DE_SetConfigFile — der Satellit stellt die Datei
  kurz per HTTP bereit (Zufallspfad, endet nach Abruf/10 min), die Box holt
  sie ab und startet neu. Nur mit confirmed, Taste per button_wait, mit dem
  Kennwort aus der Quelle der Sicherung; fremde Box nur mit other_box.
- fritzbox.backup_delete (mit confirmed). no_file fuer das Diagnostic.

Diagnostic: 💾 bei FritzBox-Geraeten → Sicherungen: Jetzt sichern,
ℹ (lesbar + Suche), Wiederherstellen, Loeschen; Sichern/Wiederherstellen
mit 2-Minuten-Countdown fuer die Taste, Ergebnis im Fenster (✓ / Taste
nicht gedrueckt / Grund), Erfolg blendet sich aus.

Brain: Beschreibung fuer Liste/Ansicht/Wiederherstellen/Loeschen.

Getestet: Satellit gegen nachgebaute Box (Sichern, Liste, Ansicht ohne
Geheimnisse, Pfadschutz, Wiederherstellen inkl. Abholen der Datei,
Loeschen), Diagnostic-Fenster in headless Chromium mit simuliertem
Satelliten. Gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:52:04 +01:00
duffyduckandClaude Opus 5.5 5af03eedb9 fix(satellite): Geheimnisse aus FritzBox-Antworten erreichen ARIA nicht
Lesende TR-064-Aktionen geben teils Geheimnisse zurueck (GetSecurityKeys →
WLAN-Schluessel, Kennwoerter, PINs). Die gingen bisher im Ergebnis an ARIA
und damit ans Sprachmodell. Felder mit Passphrase/Password/PreSharedKey/
WEPKey/Secret/PIN werden jetzt als '•••• (verborgen)' geliefert. Schickt
ARIA den Platzhalter in einer Set-Aktion zurueck, lehnt der Satellit ab —
sonst setzte die Box ihn als neuen Schluessel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:43:04 +01:00
duffyduckandClaude Opus 5.5 c545a1cd77 feat(satellite): Sicherungs-Kennwort je FritzBox — hinterlegt, stark erzwungen, ARIA kennt es nicht
- Zugangsdaten FritzBox: neues Feld "Kennwort fuer Sicherungen"
  (backupPass, verschluesselt auf dem Satelliten, mit 👁 wie die anderen).
- fritzbox.backup nimmt: ausdruecklich angegebenes > hinterlegtes
  Sicherungs-Kennwort > FritzBox-Login. Das Ergebnis nennt nur die Quelle,
  nie das Kennwort. ARIAs Beschreibung: kein 'password' uebergeben.
- Kennwort-Regel (Formular UND Satellit): mind. 12 Zeichen, Gross- und
  Kleinbuchstabe, Ziffer, Sonderzeichen. Schwaches Kennwort wird beim
  Speichern abgelehnt (Meldung im Formular), und eine Sicherung mit
  schwachem Kennwort (auch ueber das Login) bricht mit Hinweis ab.
- Formular: Eingabefelder mit Auge gleich hoch wie die anderen.

Getestet im Satelliten: Regel, Speichern schwach/stark (Login bleibt),
Sicherung ohne/mit hinterlegtem Kennwort; das Kennwort taucht im Ergebnis
nicht auf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:42:24 +01:00
duffyduckandClaude Opus 5.5 e91435a9f6 feat(satellite): FritzBox-Bestaetigung per Taste (2 min) + Sicherung in den Chat
Der Export der Einstellungen scheiterte mit "HTTP 500" — dahinter steckt
UPnP-Fehler 866: die Box will die Aktion zusaetzlich am Geraet bestaetigt.

- _tr064 meldet jetzt den UPnP-Fehlercode statt nur "HTTP 500".
- 866 → ohne button_wait sofort zurueck mit needs_button (ARIA fragt Stefan,
  ob er an der Box steht). Mit button_wait=true: X_AVM-DE_Auth SetConfig
  (start), GetState alle 2 s bis FB_BUTTON_WAIT_SEC (120 s); "authenticated"
  → Aktion erneut, sonst Timeout "Taste nicht gedrueckt, nichts ausgefuehrt"
  (Bestaetigung wird gestoppt).
- fritzbox.backup: X_AVM-DE_GetConfigFile mit Kennwort (Standard: Login-
  Kennwort), Download mit Digest-Auth, Kopie auf dem Satelliten (die
  letzten 10 je Box) und Datei zurueck an ARIA.
- Brain: Datei aus dem Ergebnis landet in /shared/uploads/, ARIA legt sie
  mit [FILE: …] in den Chat; Timeout 200 s bei button_wait; Beschreibung
  erklaert den Tasten-Ablauf.

Getestet gegen nachgebaute Box: ohne Bestaetigung → needs_button, Taste
nach 3 s → Sicherung geladen, keine Taste → Timeout. Gegen eine echte Box
(X_AVM-DE_Auth-Ablauf) noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:40:42 +01:00
duffyduckandClaude Opus 5.5 a58756ddfd fix(satellite): Router als router erkennen, Docker-Netze nicht als Geraete
Auf ARIAs VM fand der neue Satellit 58 Geraete, darunter die Container der
Docker-Netze (docker0/br-*/veth) — die sind keine Geraete und fallen jetzt
aus der ARP-Liste. Die FRITZ!Box 5590 kam per SSDP als 'media-renderer';
InternetGatewayDevice / FRITZ!Box heisst jetzt type 'router'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:27:09 +01:00
duffyduckandClaude Opus 5.5 116a947a4a feat(satellite): FritzBox komplett steuern — describe/call/wlan ueber TR-064
Statt einzelner fest programmierter Befehle nutzt der Satellit die
Selbstbeschreibung der Box (tr64desc.xml + SCPD je Dienst, ohne Anmeldung
lesbar): was DIESE Firmware kann, steht dort — keine Tabellen pro Modell.

- fritzbox.describe: alle TR-064-Aktionen der Box, je mit [liest]/
  [aendert]/[GEFAEHRLICH]; filter grenzt ein (WLAN, Dect, Hosts …).
- fritzbox.call: beliebige Aktion (service kurz "WLANConfiguration:3",
  args ohne "New"-Praefix moeglich). Alles ausser Get… braucht
  confirmed=true, sonst needs_confirmation — ARIA muss vorher fragen.
- fritzbox.wlan: alle WLANs inkl. Gast (index/ssid/an), schalten mit
  index + enable (+ confirmed).
- FritzBox per Geraetename ("Fritzbox Keller") statt IP ansprechbar.
- Allowlist kennt Platzhalter "fritzbox.*" (Default + .env.example).
- Brain: Tool-Beschreibung mit Ablauf (describe → call) und Freigabe-Regel,
  fritzbox.* mit 60 s Timeout.

Getestet gegen eine nachgebaute TR-064-Box: describe/filter, lesen,
schreiben ohne/mit Freigabe (ohne kommt nichts an der Box an), WLAN
schalten, Namensaufloesung, Allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:11:58 +01:00
duffyduckandClaude Opus 5.5 0bf00f9fc4 feat(satellite): fritzbox.hosts mit LAN-Port, Verbindungsart und Tempo
ARIA konnte "an welchem Port der FritzBox Keller haengt X" nicht
beantworten: fritzbox.hosts warf alles ausser Name/IP/MAC/aktiv weg. Die
Host-Liste der Box (TR-064 X_AVM-DE_GetHostListPath) enthaelt je nach
FRITZ!OS auch InterfaceType, X_AVM-DE_Port, X_AVM-DE_Speed und
X_AVM-DE_Guest — die gehen jetzt als interface, lan_port, speed_mbit,
guest mit. params.find filtert nach Name/IP/MAC. Hinweis im Ergebnis:
lan_port ist die Buchse DIESER Box (hinter Switch/Repeater dessen Port).
Tool-Beschreibung im Brain nennt die neuen Felder.

Getestet mit nachgebauter AVM-Host-Liste; gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:06:21 +01:00
duffyduckandClaude Opus 5.5 c934866bba feat(satellite): gespeicherte Zugangsdaten anzeigen, Passwort per Auge, FritzBox-Benutzer ermitteln
Das Zugangsdaten-Formular war beim erneuten Oeffnen leer, obwohl der
Satellit alles gespeichert hatte.

Satellit:
- sat_creds_list mit ip liefert je Typ die Felder ohne Geheimnisse
  (Benutzer, Port, SNMP-Version/v3-Stufe) und has_<feld> fuer Passwoerter;
  mit reveal (nur zusammen mit ip) auch die Geheimnisse dieses Geraets.
- sat_creds_set behaelt ein gespeichertes Geheimnis, wenn das Feld nicht
  mitgeschickt wird — Benutzer aendern, ohne das Passwort neu zu tippen.

Diagnostic-Server: Antworten mit Klartext (revealed) gehen nur an den
Browser, der gefragt hat, nicht an alle offenen Tabs.

Formular: Benutzer/Port/Version stehen beim Oeffnen drin, Passwoerter als
"•••••• gespeichert"; 👁 holt sie im Klartext bzw. schaltet um. Leeres
Passwortfeld = gespeichertes behalten. FritzBox: "Benutzer ermitteln"
(nur bei als FritzBox erkannten Geraeten) liest login_sid.lua?version=2 —
die Box nennt ihre Benutzer ohne Anmeldung; eingetragen wird der zuletzt
angemeldete.

Getestet: Satelliten-Logik per Unit-Test, Formular in headless Chromium
(Anzeige, Speichern ohne Passwort, Auge, Benutzer ermitteln).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:48:44 +01:00
duffyduckandClaude Opus 5.5 3a3ef81835 fix(satellite): Scan-Auftrag waehrend laufendem Scan wartet bis zum Ende
Kam sat_discover, waehrend schon ein Scan lief (z.B. der erste nach dem
Start, ~25 s), wartete der Satellit nur 6 s und lieferte die bisherige
Liste — nach einem Neustart leer → "Keine Geraete gefunden". Jetzt wartet
er, bis der laufende Scan fertig ist (hoechstens SCAN_MAX_SEC).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:14:40 +01:00
duffyduckandClaude Opus 5.5 2a0c8b8192 fix(satellite): Thread-Leck im mDNS-Scan — ServiceBrowser werden beendet
Jeder ServiceBrowser ist ein eigener Thread; zc.close() beendet ihn nicht
(zeroconf 0.151.5, lokal nachgestellt: +1 Thread pro Browser und Scan,
mit cancel() keiner). Bei 14 Diensttypen und einem Scan alle 5 Minuten
hatte der Satellit HomeOffice nach ~5 Tagen 18.841 Threads = Thread-Limit
des Containers. Seit 30.10. 12:19 scheiterte jeder Scan mit "[Errno 11]
Resource temporarily unavailable", auf sat_discover kam keine Antwort mehr
(auch die RVS-Verbindung brach zeitweise mit Errno 11 ab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:10:45 +01:00
duffyduckandClaude Opus 5.5 aeabba10de fix(satellite): Scan haengt nicht mehr ewig und blockiert keine anderen Auftraege
Der Satellit HomeOffice war verbunden (Heartbeat lief), beantwortete
sat_discover aber nicht mehr — auch ein zweiter Scan blieb ohne Antwort.
Ursache: Nachrichten werden nacheinander abgearbeitet (async for … await
_handle), und der Scan lief im Loop. Haengt ein Schritt (mDNS/SSDP/ARP/SNMP/
ADB), steht der ganze Satellit — Tunnel und Befehle eingeschlossen.

- sat_discover laeuft als eigener Task, der Loop bleibt frei.
- Ein Scan ist auf SCAN_MAX_SEC (90 s) begrenzt; danach kommt die letzte
  bekannte Liste zurueck, und das Log nennt den haengenden Schritt.
- Diagnostic: "Scanne Netz …" endet nach 2 Minuten mit einem Hinweis statt
  fuer immer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:05:32 +01:00
13 changed files with 2056 additions and 138 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+39
View File
@@ -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,
+10
View File
@@ -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}
/>
+147 -3
View File
@@ -48,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
@@ -1292,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 "
@@ -2469,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()
@@ -2766,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
@@ -2838,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
@@ -3225,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,
@@ -3239,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}"
+15 -1
View File
@@ -1343,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()
+13
View File
@@ -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")
+553 -33
View File
@@ -286,7 +286,7 @@
<h3 style="margin:0;color:#fff;">🔑 Zugangsdaten: <span id="dev-creds-title">—</span></h3>
<button onclick="closeDeviceCreds()" style="background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">&times;</button>
</div>
<p style="color:#8888AA;font-size:11px;margin:0;">Wird verschluesselt auf dem Satelliten gespeichert. Leere Felder speichern nichts / loeschen den Typ. Der Satellit nutzt sie automatisch bei SNMP / HTTP / FritzBox.</p>
<p style="color:#8888AA;font-size:11px;margin:0;">Wird verschlüsselt auf dem Satelliten gespeichert. Gespeicherte Passwörter stehen als •••••• da — 👁 zeigt sie an. Ein leeres Passwortfeld lässt das gespeicherte Passwort unverändert. Der Satellit nutzt die Daten automatisch bei SNMP / HTTP / FritzBox / SSH.</p>
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
<div style="color:#E0E0F0;font-weight:600;margin-bottom:6px;">SNMP</div>
@@ -299,7 +299,7 @@
</select>
<div id="dc-snmp-community-row">
<label style="font-size:11px;color:#8888AA;">Community</label>
<input id="dc-snmp-community" placeholder="public" style="width:100%;margin-bottom:4px;">
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-snmp-community" type="password" placeholder="public" style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-snmp-community')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
</div>
<div id="dc-snmp-v3" style="display:none;">
<input id="dc-v3-user" placeholder="v3 User" style="width:100%;margin-bottom:4px;">
@@ -310,11 +310,11 @@
</select>
<div style="display:flex;gap:4px;margin-bottom:4px;">
<select id="dc-v3-authproto" style="flex:1;"><option value="SHA">SHA</option><option value="MD5">MD5</option><option value="SHA-256">SHA-256</option></select>
<input id="dc-v3-authkey" placeholder="Auth-Passwort" style="flex:2;">
<input id="dc-v3-authkey" type="password" placeholder="Auth-Passwort" style="flex:2;"><button type="button" class="btn secondary" onclick="dcEye('dc-v3-authkey')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button>
</div>
<div style="display:flex;gap:4px;">
<select id="dc-v3-privproto" style="flex:1;"><option value="AES">AES</option><option value="DES">DES</option><option value="AES-256">AES-256</option></select>
<input id="dc-v3-privkey" placeholder="Priv-Passwort" style="flex:2;">
<input id="dc-v3-privkey" type="password" placeholder="Priv-Passwort" style="flex:2;"><button type="button" class="btn secondary" onclick="dcEye('dc-v3-privkey')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button>
</div>
</div>
</div>
@@ -322,13 +322,17 @@
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
<div style="color:#E0E0F0;font-weight:600;margin-bottom:6px;">HTTP-Basic</div>
<input id="dc-http-user" placeholder="Benutzer (optional)" style="width:100%;margin-bottom:4px;">
<input id="dc-http-pass" type="password" placeholder="Passwort" style="width:100%;">
<div style="display:flex;gap:4px;align-items:center;"><input id="dc-http-pass" type="password" placeholder="Passwort" style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-http-pass')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
</div>
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
<div style="color:#E0E0F0;font-weight:600;margin-bottom:6px;">FritzBox (TR-064)</div>
<input id="dc-fb-user" placeholder="FritzBox-Benutzer (optional, leer wenn keiner)" style="width:100%;margin-bottom:4px;">
<input id="dc-fb-pass" type="password" placeholder="FritzBox-Passwort" style="width:100%;">
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-fb-user" placeholder="FritzBox-Benutzer (z.B. fritz1234)" style="flex:1;">
<button type="button" id="dc-fb-detect" class="btn secondary" onclick="dcDetectFritzUser()" title="Fragt die FritzBox, welche Benutzer es gibt (geht ohne Anmeldung)" style="padding:2px 8px;font-size:11px;white-space:nowrap;">Benutzer ermitteln</button></div>
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-fb-pass" type="password" placeholder="FritzBox-Passwort" style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-fb-pass')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-fb-backuppass" type="password" placeholder="Kennwort für Sicherungen (mind. 12 Zeichen, Aa, 1, !)" title="Damit verschlüsselt ARIA Sicherungen der Box. ARIA nutzt es, kennt es aber nicht — zum Wiederherstellen brauchst du es." style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-fb-backuppass')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
<div style="display:flex;gap:6px;align-items:center;"><label for="dc-fb-keep" style="font-size:11px;color:#8888AA;">Maximale Sicherungen</label>
<input id="dc-fb-keep" type="number" min="1" max="100" placeholder="10" title="So viele Sicherungen dieser Box bleiben auf dem Satelliten — bei jeder neuen fliegt die älteste raus" style="width:70px;"></div>
</div>
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
@@ -337,8 +341,8 @@
<input id="dc-ssh-user" placeholder="Benutzer" style="flex:2;">
<input id="dc-ssh-port" placeholder="Port 22" style="flex:1;">
</div>
<input id="dc-ssh-pass" type="password" placeholder="Passwort (oder Key unten)" style="width:100%;margin-bottom:4px;">
<textarea id="dc-ssh-key" placeholder="Privater SSH-Key (optional, PEM/OpenSSH)" style="width:100%;height:60px;font-family:monospace;font-size:10px;"></textarea>
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;"><input id="dc-ssh-pass" type="password" placeholder="Passwort (oder Key unten)" style="flex:1;"><button type="button" class="btn secondary" onclick="dcEye('dc-ssh-pass')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
<div style="display:flex;gap:4px;"><textarea id="dc-ssh-key" placeholder="Privater SSH-Key (optional, PEM/OpenSSH)" style="flex:1;height:60px;font-family:monospace;font-size:10px;"></textarea><button type="button" class="btn secondary" onclick="dcEye('dc-ssh-key')" title="Anzeigen / verbergen" style="padding:2px 8px;font-size:12px;">👁</button></div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
@@ -956,6 +960,14 @@
den Fortschritt. Danach ist es im Modell-Dropdown oben waehlbar.
<span style="color:#FFD60A;">Achte auf den VRAM der Box (Spalte VRAM).</span>
</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin:0 0 8px;">
<input id="llm-catalog-add" type="text" placeholder="HuggingFace-Link oder org/name hinzufügen …" onkeydown="if(event.key==='Enter')addCatalogByLink()"
style="flex:1;min-width:240px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;">
<button class="btn secondary" onclick="addCatalogByLink()" style="padding:4px 10px;font-size:11px;">Hinzufügen</button>
</div>
<div id="llm-catalog-add-result" style="font-size:12px;margin:0 0 8px;"></div>
<label style="display:inline-flex;gap:5px;align-items:center;font-size:11px;color:#9a9ab5;margin:0 0 6px;cursor:pointer;" title="Modelle ohne Werkzeug-Format ausblenden (z.B. für Übersetzen/Zusammenfassen reichen auch die)">
<input type="checkbox" id="llm-catalog-tools" checked onchange="filterLlmCatalog()"> nur Modelle mit Werkzeugen 🔧</label>
<input id="llm-catalog-search" type="search" placeholder="🔍 Modell suchen … (z.B. coder, qwen3, 30b)" oninput="filterLlmCatalog()"
style="display:none;width:100%;max-width:360px;box-sizing:border-box;margin:0 0 8px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;">
<div id="llm-catalog-list" style="font-size:12px;color:#8888AA;overflow-x:auto;">(lade Katalog…)</div>
@@ -2695,16 +2707,27 @@
if (msg.type === 'sat_creds_list_result') {
const p = msg.payload || msg;
const sat = p.satellite || '';
const map = {};
let map = {};
if (p.ip) {
// Antwort fuer EIN Geraet (Formular) — Liste der anderen behalten
map = { ...(satCreds[sat] || {}) };
delete map[p.ip];
}
(p.items || []).forEach(it => { map[it.ip] = it.types || []; });
satCreds[sat] = map;
const modalOpen = _dcEl('dev-creds-modal').style.display === 'flex';
const it = (p.items || []).find(x => x.ip === credsModalIp);
if (modalOpen && sat === credsModalSat && it && it.detail && (p.revealed || !dcFilled)) {
dcFill(it.detail, !!p.revealed);
_dcEl('dc-status').textContent = 'Hinterlegt: ' + (it.types || []).join(', ') + (p.revealed ? ' — im Klartext angezeigt.' : '');
}
renderSatellites();
return;
}
if (msg.type === 'sat_creds_result') {
const p = msg.payload || msg;
const el = document.getElementById('dc-status');
if (el) el.textContent = p.ok ? '✓ Gespeichert.' : '⚠ Fehlgeschlagen.';
if (el) el.textContent = p.ok ? '✓ Gespeichert.' : ('⚠ ' + (p.error || 'Fehlgeschlagen.'));
if (p.ok && p.satellite) requestSatCreds(p.satellite);
if (p.ok) setTimeout(closeDeviceCreds, 700);
return;
@@ -2763,7 +2786,7 @@
return;
}
if (msg.type === 'sat_devices') {
if (msg.satellite) { satDevices[msg.satellite] = { devices: msg.devices || [], location: msg.location, ts: Date.now() }; }
if (msg.satellite) { satDevices[msg.satellite] = { devices: msg.devices || [], location: msg.location, ts: Date.now() }; delete satScanError[msg.satellite]; }
satScanning = null;
renderSatellites();
return;
@@ -3584,7 +3607,8 @@
const row = ctxModelRow(focusedContextId);
if (!row) { bar.innerHTML = ''; return; }
const def = prettyModel(diagCtxModels.default) || diagCtxModels.default || '?';
const groups = { tier: 'Automatisch (neueste Version)', main: 'Aktuelle Versionen', overflow: 'Ältere Versionen' };
const groups = { tier: 'Automatisch (neueste Version)', main: 'Aktuelle Versionen', overflow: 'Ältere Versionen',
local: 'Lokal (eigene Hardware, ohne Claude)' };
let opts = `<option value="">Einstellung (${escapeHtml(def)})</option>`;
for (const [sec, label] of Object.entries(groups)) {
const inSec = (diagCtxModels.choices || []).filter(c => (c.section || 'tier') === sec);
@@ -4474,7 +4498,7 @@
// uiConfirm → true/false, uiPrompt → Text oder null, uiAlert → undefined.
// Erster Absatz der Meldung = Ueberschrift, der Rest = Erklaerung.
// Esc / Klick daneben = Abbrechen, Enter = OK.
const _UI_DANGER = /l[öo]e?schen|entfernen|zur[üu]cksetzen|NOT-AUS|AGGRESSIV|WIRKLICH|endg[üu]ltig|archivieren|beenden|verloren|[üu]berschrieben|leeren/i;
const _UI_DANGER = /l[öo]e?schen|entfernen|zur[üu]cksetzen|NOT-AUS|AGGRESSIV|WIRKLICH|endg[üu]ltig|archivieren|beenden|verloren|[üu]berschrieben|leeren|ausschalten|deaktivier|zur[üu]cksetzen/i;
function _uiDialog({ message, input, defaultValue, buttons }) {
return new Promise(resolve => {
const text = String(message ?? '');
@@ -5634,6 +5658,7 @@
let satellites = []; // [{id, location, caps, control, online}]
let satDevices = {}; // id → {devices, location, ts}
let satScanning = null; // id des gerade scannenden Satelliten
const satScanError = {}; // id → Meldung, wenn ein Scan nicht beantwortet wurde
let satCreds = {}; // satId → { ip → [typen] } (welche Creds gesetzt sind, OHNE Secrets)
let credsModalSat = ''; // aktuell im Modal bearbeiteter Satellit
let credsModalIp = '';
@@ -6059,6 +6084,8 @@
requestId: apkRequestId, params: { ip: apkTarget.ip } });
}
function onSatResult(p) {
if (p && p.requestId && _fbPending[p.requestId]) { const r = _fbPending[p.requestId]; delete _fbPending[p.requestId]; r(p); return; }
if (p && dcFbReq && p.requestId === dcFbReq) { dcOnFritzUsers(p); return; }
if (!apkRequestId || !p || p.requestId !== apkRequestId) return;
const status = document.getElementById('apk-status');
const log = document.getElementById('apk-log');
@@ -6161,10 +6188,20 @@
}).join('');
}
// Antwortet der Satellit nicht, haengt "Scanne Netz …" sonst fuer immer.
// Der Satellit selbst bricht einen Scan nach 90 s ab (SCAN_MAX_SEC).
function scanSatellite(id) {
satScanning = id;
delete satScanError[id];
renderSatellites();
send({ action: 'sat_discover', satellite: id });
setTimeout(() => {
if (satScanning !== id) return;
satScanning = null;
satScanError[id] = 'Keine Antwort nach 2 Minuten. Der Satellit ist verbunden, beantwortet den Scan aber nicht — '
+ 'vermutlich hängt er. Satellit neu starten (bzw. aktualisieren) hilft.';
renderSatellites();
}, 120000);
}
/** Anzeigenamen fuer einen Satelliten oder Host-Agenten vergeben.
@@ -6218,6 +6255,8 @@
let devHtml;
if (scanning) {
devHtml = '<div style="color:#FFD60A;margin-top:6px;">Scanne Netz …</div>';
} else if (satScanError[s.id]) {
devHtml = '<div style="color:#FF9F0A;margin-top:6px;">⚠ ' + escapeHtml(satScanError[s.id]) + '</div>';
} else if (dev) {
if (!dev.devices.length) {
devHtml = '<div style="color:#8888AA;margin-top:6px;">Keine Geraete gefunden.</div>';
@@ -6239,10 +6278,16 @@
'style="padding:1px 6px;font-size:11px;margin-left:6px;">✎</button>' : '';
const hasCreds = d.ip && (satCreds[s.id] || {})[d.ip] && (satCreds[s.id][d.ip] || []).length;
const keyBtn = d.ip ? '<button class="btn secondary" title="Zugangsdaten (SNMP / HTTP / FritzBox)" ' +
'onclick="openDeviceCreds(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'onclick="openDeviceCreds(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\',' +
(/fritz/i.test([d.name, d.model, d.type, d.service].join(' ')) ? 'true' : 'false') + ')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:8px;">' + (hasCreds ? '🔑 ✓' : '🔑') + '</button>' : '';
// APK ausrollen: bei ADB-faehigen Geraeten (Fire TV & Co.).
const canAdb = d.ip && (d.adbCapable || ['firetv', 'android', 'android-adb', 'androidtv', 'dial'].includes(d.type));
// FritzBox: Sicherungen (erstellen, ansehen, wiederherstellen)
const isFritzDev = d.ip && /fritz/i.test([d.name, d.model, d.type, d.service].join(' '));
const fbBtn = isFritzDev ? '<button class="btn secondary" title="Sicherungen der FritzBox" ' +
'onclick="openFbBackups(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">💾</button>' : '';
const apkBtn = canAdb ? '<button class="btn secondary" title="ARIA-Agent (APK) auf dieses Gerät ausrollen" ' +
'onclick="openApkDeploy(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">📦</button>' : '';
@@ -6261,7 +6306,7 @@
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.hostname || d.id || '?') + '</span> ' +
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
'</div>' + nameBtn + keyBtn + apkBtn +
'</div>' + nameBtn + keyBtn + fbBtn + apkBtn +
'</div>';
}).join('') + '</div>';
}
@@ -6287,22 +6332,345 @@
}).join('');
}
// ── FritzBox-Sicherungen ───────────────────────────────
// Liste je Box, ℹ = Inhalt lesbar, Wiederherstellen, Loeschen, Jetzt sichern.
// Sichern/Wiederherstellen verlangt die Box oft per Taste bestaetigt: der
// Satellit wartet dann bis 2 Minuten, hier laeuft ein Countdown.
const _fbPending = {};
let fbB = null;
function fbCmd(cmd, params, timeoutMs = 30000) {
const requestId = 'fbb_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
return new Promise(resolve => {
const t = setTimeout(() => { delete _fbPending[requestId]; resolve({ ok: false, error: 'Keine Antwort vom Satelliten.' }); }, timeoutMs);
_fbPending[requestId] = p => { clearTimeout(t); resolve(p); };
send({ action: 'sat_command', satellite: fbB.sat, cmd, requestId, params: { ip: fbB.ip, name: fbB.name, ...params } });
});
}
function _fbModal(html) {
let m = document.getElementById('fb-backups-modal');
if (!m) {
m = document.createElement('div');
m.id = 'fb-backups-modal';
m.style.cssText = 'position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;';
m.addEventListener('click', e => { if (e.target === m) closeFbBackups(); });
document.body.appendChild(m);
}
m.innerHTML = `<div style="background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:760px;width:100%;max-height:88vh;overflow-y:auto;color:#E0E0F0;font-size:13px;">${html}</div>`;
return m;
}
function closeFbBackups() { const m = document.getElementById('fb-backups-modal'); if (m) m.remove(); fbB = null; } // vergisst auch angezeigte Kennwoerter
const _fbFmtDate = iso => { try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); } catch (_) { return iso; } };
const _fbPwSrc = { backupPass: 'Sicherungs-Kennwort', login: 'FritzBox-Kennwort', angegeben: 'eigenes Kennwort' };
async function openFbBackups(sat, ip, name) {
fbB = { sat, ip, name, list: [], busy: '', sf: null, keep: 0 };
_fbRender('Lade Sicherungen …');
const [r, sf] = await Promise.all([fbCmd('fritzbox.backups', {}), fbCmd('fritzbox.second_factor', {})]);
if (!fbB) return;
fbB.list = (r.ok && r.result && r.result.sicherungen) || [];
fbB.keep = (r.ok && r.result && r.result.behalten) || 0;
fbB.sf = sf.ok ? sf.result : { fehler: sf.error };
_fbRender(r.ok ? '' : '⚠ ' + (r.error || 'Fehler'));
}
function _fbRender(status) {
if (!fbB) return;
const rows = fbB.list.map((b, i) => `<tr style="border-bottom:1px solid #1E1E2E;">
<td style="padding:6px;">${escapeHtml(_fbFmtDate(b.erstellt) || b.datei)}</td>
<td style="padding:6px;color:#9a9ab5;">${escapeHtml(b.modell || '')}${b.firmware ? ' · FW ' + escapeHtml(b.firmware) : ''}</td>
<td style="padding:6px;color:#9a9ab5;">${_fmtBytes(b.bytes)}</td>
<td style="padding:6px;color:#9a9ab5;" title="Verschlüsselt mit diesem Kennwort — zum Wiederherstellen nötig. „Wiederherstellen“ nimmt es automatisch; beim Einspielen von Hand in der FritzBox-Oberfläche musst du genau dieses eingeben.">🔒 ${escapeHtml(_fbPwSrc[b.kennwort] || b.kennwort || '')}
<button class="btn secondary" onclick="fbShowPw(${i})" title="Kennwort dieser Sicherung anzeigen" style="padding:1px 6px;font-size:11px;margin-left:4px;">👁</button></td>
<td style="padding:6px;white-space:nowrap;">
<button class="btn secondary" onclick="fbViewBackup(${i})" title="Inhalt ansehen" style="padding:2px 8px;font-size:11px;">ℹ</button>
<button class="btn secondary" onclick="fbDownloadBackup(${i})" title="Herunterladen (.export, verschlüsselt mit dem Sicherungs-Kennwort)" style="padding:2px 8px;font-size:11px;">⬇</button>
<button class="btn secondary" onclick="fbRestoreBackup(${i})" title="Diese Sicherung einspielen" style="padding:2px 8px;font-size:11px;">Wiederherstellen</button>
<button class="btn secondary" onclick="fbDeleteBackup(${i})" title="Löschen" style="padding:2px 8px;font-size:11px;color:#FF6E6E;">🗑</button></td></tr>`
+ (fbB.pw && fbB.pw[b.datei] ? `<tr><td colspan="5" style="padding:4px 6px 10px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span style="color:#8888AA;font-size:11px;">Kennwort dieser Sicherung:</span>
<code style="background:#000;border:1px solid #333;border-radius:4px;padding:3px 8px;font-size:13px;color:#E0E0F0;user-select:all;">${escapeHtml(fbB.pw[b.datei].kennwort)}</code>
<button class="btn secondary" onclick="fbCopyPw('${escapeHtmlAttr(b.datei)}', this)" style="padding:2px 8px;font-size:11px;">Kopieren</button></div>
${fbB.pw[b.datei].hinweis ? `<div style="color:#FF9F0A;font-size:11px;margin-top:4px;">${escapeHtml(fbB.pw[b.datei].hinweis)}</div>` : ''}</td></tr>` : '')).join('');
_fbModal(`<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px;">
<h3 style="margin:0;">💾 Sicherungen: ${escapeHtml(fbB.name)} <span style="color:#8888AA;font-size:12px;">(${escapeHtml(fbB.ip)})</span></h3>
<button onclick="closeFbBackups()" style="background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">&times;</button></div>
<p style="color:#8888AA;font-size:11px;margin:0 0 10px;">Liegen auf dem Satelliten, die letzten ${fbB.keep || 10} je Box (einstellbar unter 🔑). Verschlüsselt mit dem hinterlegten Sicherungs-Kennwort (🔑).</p>
${_fbSfLine()}
<div style="display:flex;gap:8px;margin-bottom:10px;">
<button class="btn" onclick="fbCreateBackup()" ${fbB.busy ? 'disabled' : ''}>Jetzt sichern</button>
<button class="btn secondary" onclick="fbBackupAll()" ${fbB.busy ? 'disabled' : ''} title="Alle FritzBoxen mit hinterlegtem Login an diesem Satelliten — nicht erreichbare werden übersprungen">Alle FritzBoxen sichern</button></div>
<div id="fb-run" style="${fbB.busy ? '' : 'display:none;'}margin-bottom:10px;padding:10px;border:1px solid #444466;border-radius:6px;"></div>
${fbB.list.length ? `<table style="width:100%;border-collapse:collapse;font-size:12px;"><tbody>${rows}</tbody></table>`
: '<div style="color:#6a6a88;">Noch keine Sicherung.</div>'}
<div style="color:#8888AA;font-size:11px;margin-top:8px;min-height:14px;">${escapeHtml(status || '')}</div>`);
}
// Lauf mit Countdown — falls die Box eine Bestaetigung per Taste will
async function _fbRun(label, cmd, params) {
fbB.busy = label;
_fbRender('');
const box = document.getElementById('fb-run');
const end = Date.now() + 120000;
const tick = () => {
const el = document.getElementById('fb-run');
if (!el || !fbB || !fbB.busy) return;
const left = Math.max(0, Math.round((end - Date.now()) / 1000));
el.innerHTML = `<b>${escapeHtml(label)} …</b><div style="color:#FFD60A;margin-top:4px;">Falls die Box eine Bestätigung verlangt: jetzt eine Taste an der FritzBox drücken — noch ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}</div>`;
};
tick();
const iv = setInterval(tick, 1000);
// "alle sichern": pro Box bis 2 min Taste — insgesamt grosszuegig warten
const r = await fbCmd(cmd, { ...params, button_wait: true, no_file: true }, cmd === 'fritzbox.backup_all' ? 15 * 60000 : 170000);
clearInterval(iv);
if (!fbB) return r;
fbB.busy = '';
return r;
}
function _fbShowResult(r, okText) {
_fbRender('');
const el = document.getElementById('fb-run');
el.style.display = '';
if (r.ok) {
el.innerHTML = `<b style="color:#34C759;">✓ ${escapeHtml(okText)}</b>` + (r.result && r.result.hinweis ? `<div style="color:#9a9ab5;margin-top:4px;">${escapeHtml(r.result.hinweis)}</div>` : '');
setTimeout(() => { const e = document.getElementById('fb-run'); if (e && fbB && !fbB.busy) e.style.display = 'none'; }, 6000);
} else {
el.innerHTML = `<b style="color:#FF9F0A;">⚠ ${r.timeout ? 'Taste nicht gedrückt' : 'Nicht geklappt'}</b><div style="margin-top:4px;">${escapeHtml(r.error || 'unbekannter Fehler')}</div>`;
}
}
// Tastenbestaetigung der Box: Status + Umschalten (wenn die Firmware es kann)
function _fbSfLine() {
const sf = fbB.sf;
if (!sf) return '<div style="color:#6a6a88;font-size:12px;margin-bottom:8px;">Tastenbestätigung: wird geprüft …</div>';
if (sf.fehler) return `<div style="color:#6a6a88;font-size:12px;margin-bottom:8px;">Tastenbestätigung: unbekannt (${escapeHtml(sf.fehler)})</div>`;
if (!sf.vorhanden) return '<div style="color:#6a6a88;font-size:12px;margin-bottom:8px;">Tastenbestätigung: gibt es bei dieser Box nicht — alles läuft ohne Taste.</div>';
const st = sf.an === true ? '<b style="color:#FFD60A;">an</b>' : sf.an === false ? '<b style="color:#34C759;">aus</b>' : '<b>unbekannt</b>';
const btn = sf.an === null ? '' : `<button class="btn secondary" onclick="fbToggleSf(${sf.an ? 'false' : 'true'})" ${fbB.busy ? 'disabled' : ''} style="padding:2px 8px;font-size:11px;margin-left:8px;">${sf.an ? 'Ausschalten' : 'Einschalten'}</button>`;
return `<div style="font-size:12px;margin-bottom:8px;">Tastenbestätigung: ${st}${btn}</div>`;
}
async function fbToggleSf(on) {
if (fbB.sf && !fbB.sf.schaltbar) { uiAlert('Lässt sich nicht umschalten\n\n' + (fbB.sf.hinweis || '')); return; }
if (!await uiConfirm(`Tastenbestätigung an ${fbB.name} ${on ? 'einschalten' : 'ausschalten'}?\n\n`
+ (on ? 'Danach verlangt die Box für Sicherungen und heikle Einstellungen wieder einen Tastendruck.'
: 'Danach laufen Sicherungen ohne Tastendruck — auch „alle FritzBoxen sichern“. Wer das Login kennt, kann dann aber auch heikle Einstellungen ohne Bestätigung am Gerät ändern.')
+ '\n\nDas Umschalten selbst muss die Box meist per Taste bestätigen (2 Minuten).')) return;
const r = await _fbRun(on ? 'Einschalten läuft' : 'Ausschalten läuft', 'fritzbox.second_factor_set', { enable: on, confirmed: true });
if (!fbB) return;
const sf = await fbCmd('fritzbox.second_factor', {});
if (fbB && sf.ok) fbB.sf = sf.result;
if (!r.ok && r.not_switchable) { _fbRender(''); uiAlert('Lässt sich nicht umschalten\n\n' + r.error); return; }
_fbShowResult(r, on ? 'Tastenbestätigung ist an' : 'Tastenbestätigung ist aus');
}
async function fbBackupAll() {
if (!await uiConfirm('Alle FritzBoxen sichern?\n\nGilt für alle Boxen mit hinterlegtem Login an diesem Satelliten. Nicht erreichbare werden übersprungen. Verlangt eine Box eine Taste, wartet der Satellit bis 2 Minuten — sonst geht es mit der nächsten weiter.')) return;
const r = await _fbRun('Sichere alle FritzBoxen', 'fritzbox.backup_all', {});
if (!fbB) return;
const l = await fbCmd('fritzbox.backups', {});
if (fbB && l.ok) fbB.list = l.result.sicherungen || [];
_fbRender('');
const el = document.getElementById('fb-run');
el.style.display = '';
if (!r.ok) { el.innerHTML = `<b style="color:#FF9F0A;">⚠ Nicht geklappt</b><div>${escapeHtml(r.error || '')}</div>`; return; }
const res = r.result || {};
el.innerHTML = `<b>${res.gesichert} von ${res.von} gesichert</b>` + (res.boxen || []).map(b =>
`<div style="margin-top:3px;">${b.ok ? '<span style="color:#34C759;">✓</span>' : '<span style="color:#FF9F0A;">✗</span>'} ${escapeHtml(b.box)}`
+ (b.ok ? '' : ` — <span style="color:#9a9ab5;">${escapeHtml(b.grund || '')}</span>`) + '</div>').join('');
}
async function fbCreateBackup() {
if (!await uiConfirm(`Sicherung von ${fbB.name} erstellen?\n\nFalls die Box eine Bestätigung verlangt, hast du nach dem Start 2 Minuten, um eine Taste an der FritzBox zu drücken.`)) return;
const r = await _fbRun('Sicherung läuft', 'fritzbox.backup', {});
if (!fbB) return;
const l = await fbCmd('fritzbox.backups', {});
if (fbB && l.ok) fbB.list = l.result.sicherungen || [];
_fbShowResult(r, 'Sicherung erstellt');
}
async function fbRestoreBackup(i) {
const b = fbB.list[i];
if (!b) return;
if (!await uiConfirm(`${fbB.name} auf die Sicherung vom ${_fbFmtDate(b.erstellt)} zurücksetzen?\n\nDie Box übernimmt ALLE Einstellungen dieser Sicherung und startet neu — 1–3 Minuten kein Netz und kein Internet. Falls die Box eine Bestätigung verlangt, hast du nach dem Start 2 Minuten für den Tastendruck.`)) return;
const r = await _fbRun('Wiederherstellen läuft', 'fritzbox.restore', { file: b.datei, confirmed: true });
if (fbB) _fbShowResult(r, 'Sicherung eingespielt');
}
// Kopieren auch ohne https: navigator.clipboard gibt es nur in "sicheren
// Kontexten" — sonst ueber ein unsichtbares Textfeld + execCommand('copy').
function copyText(t) {
const fallback = () => {
const ta = document.createElement('textarea');
ta.value = t; ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:-1000px;left:0;opacity:0;';
document.body.appendChild(ta); ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (_) {}
ta.remove();
return ok;
};
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(t).then(() => true, fallback);
return Promise.resolve(fallback());
}
async function fbShowPw(i) {
const b = fbB.list[i];
if (!b) return;
fbB.pw = fbB.pw || {};
if (fbB.pw[b.datei]) { delete fbB.pw[b.datei]; _fbRender(''); return; } // zweiter Klick: verbergen
_fbRender('Hole Kennwort …');
const r = await fbCmd('fritzbox.backup_password', { file: b.datei });
if (!fbB) return;
if (!r.ok) { _fbRender('⚠ ' + (r.error || 'Kennwort nicht abrufbar')); return; }
fbB.pw[b.datei] = { kennwort: r.result.kennwort, hinweis: r.result.hinweis || '' };
_fbRender('');
}
async function fbCopyPw(datei, btn) {
const e = fbB && fbB.pw && fbB.pw[datei];
if (!e) return;
const ok = await copyText(e.kennwort);
btn.textContent = ok ? '✓ kopiert' : 'Kopieren ging nicht — markieren + Strg+C';
setTimeout(() => { if (btn.isConnected) btn.textContent = 'Kopieren'; }, 2500);
}
async function fbDownloadBackup(i) {
const b = fbB.list[i];
if (!b) return;
_fbRender('Lade ' + b.datei + ' …');
const r = await fbCmd('fritzbox.backup_get', { file: b.datei }, 120000);
if (!fbB) return;
if (!r.ok || !r.result || !r.result.file_b64) { _fbRender('⚠ ' + (r.error || 'Download fehlgeschlagen')); return; }
const bin = atob(r.result.file_b64);
const bytes = new Uint8Array(bin.length);
for (let k = 0; k < bin.length; k++) bytes[k] = bin.charCodeAt(k);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' }));
const a = document.createElement('a');
a.href = url; a.download = r.result.filename || b.datei;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
_fbRender('⬇ ' + (r.result.filename || b.datei) + ' heruntergeladen.');
}
async function fbDeleteBackup(i) {
const b = fbB.list[i];
if (!b || !await uiConfirm(`Sicherung vom ${_fbFmtDate(b.erstellt)} löschen?\n\nSie ist danach endgültig weg.`)) return;
const r = await fbCmd('fritzbox.backup_delete', { file: b.datei, confirmed: true });
if (!fbB) return;
if (r.ok) fbB.list.splice(i, 1);
_fbRender(r.ok ? 'Gelöscht.' : '⚠ ' + (r.error || 'Fehler'));
}
async function fbViewBackup(i) {
const b = fbB.list[i];
if (!b) return;
_fbRender('Lese Sicherung …');
const r = await fbCmd('fritzbox.backup_view', { file: b.datei, full: true }, 60000);
if (!fbB) return;
if (!r.ok) { _fbRender('⚠ ' + (r.error || 'Fehler')); return; }
const v = r.result || {};
const kopf = Object.entries(v.kopf || {}).map(([k, x]) => `<tr><td style="padding:2px 8px 2px 0;color:#8888AA;">${escapeHtml(k)}</td><td>${escapeHtml(x)}</td></tr>`).join('');
const wichtig = (v.wichtig || []).map(w => `<tr><td style="padding:2px 8px 2px 0;color:#8888AA;">${escapeHtml(w.was)}</td><td>${escapeHtml(w.wert)}</td><td style="color:#555570;padding-left:8px;">${escapeHtml(w.datei)}</td></tr>`).join('');
const secs = Object.entries(v.inhalt || {}).map(([n, t]) => `<details data-text="${escapeHtml(t.toLowerCase())}" style="margin-top:4px;"><summary style="cursor:pointer;color:#4DB8FF;">${escapeHtml(n)} <span style="color:#555570;">(${_fmtBytes(t.length)})</span></summary>
<pre style="white-space:pre-wrap;word-break:break-all;background:#000;border-radius:6px;padding:6px 8px;font-size:11px;max-height:320px;overflow:auto;">${escapeHtml(t)}</pre></details>`).join('');
_fbModal(`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h3 style="margin:0;">ℹ Sicherung vom ${escapeHtml(_fbFmtDate(b.erstellt))}</h3>
<button onclick="_fbRender('')" style="background:none;border:none;color:#8888AA;font-size:13px;cursor:pointer;">← zurück</button></div>
<p style="color:#8888AA;font-size:11px;margin:0 0 8px;">Verschlüsselte Werte (Kennwörter, Schlüssel) stehen als «verschluesselt» da.</p>
${kopf ? `<table style="font-size:12px;margin-bottom:10px;">${kopf}</table>` : ''}
${wichtig ? `<h4 style="margin:8px 0 4px;">Wichtigste Einstellungen</h4><table style="font-size:12px;">${wichtig}</table>` : ''}
<h4 style="margin:12px 0 4px;">Alle Bereiche</h4>
<input type="search" placeholder="🔍 in der Sicherung suchen …" oninput="fbFilterView(this.value)" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;margin-bottom:4px;">
<div id="fb-view-secs">${secs || '<span style="color:#6a6a88;">Keine lesbaren Bereiche.</span>'}</div>`);
}
function fbFilterView(q) {
q = String(q || '').toLowerCase();
document.querySelectorAll('#fb-view-secs details').forEach(d => {
const hit = !q || d.dataset.text.includes(q);
d.style.display = hit ? '' : 'none';
d.open = !!q && hit;
});
}
// ── Geraete-Credentials ────────────────────────────────
function dcToggleV3() {
const v = document.getElementById('dc-snmp-version').value;
document.getElementById('dc-snmp-v3').style.display = (v === '3') ? 'block' : 'none';
document.getElementById('dc-snmp-community-row').style.display = (v === '1' || v === '2c') ? 'block' : 'none';
}
function openDeviceCreds(satId, ip, name) {
// Gespeichertes wird beim Oeffnen angezeigt: Benutzer/Port/Version im
// Klartext, Passwoerter nur als "gespeichert". Das Auge holt sie im Klartext
// (geht nur an diesen Browser, nicht an andere offene Diagnostic-Tabs).
const DC_SECRET_FIELDS = {
'dc-snmp-community': ['snmp', 'community'], 'dc-v3-authkey': ['snmp', 'v3', 'authKey'],
'dc-v3-privkey': ['snmp', 'v3', 'privKey'], 'dc-http-pass': ['http', 'pass'],
'dc-fb-pass': ['fritzbox', 'pass'], 'dc-fb-backuppass': ['fritzbox', 'backupPass'],
'dc-ssh-pass': ['ssh', 'pass'], 'dc-ssh-key': ['ssh', 'key'],
};
let dcStored = {}, dcRevealed = false, dcFilled = false, dcFbReq = '';
const _dcEl = id => document.getElementById(id);
function openDeviceCreds(satId, ip, name, isFritz) {
credsModalSat = satId; credsModalIp = ip;
document.getElementById('dev-creds-title').textContent = (name || ip) + ' (' + ip + ')';
// Felder leeren — Secrets werden NIE zurueckgeliefert, nur neu gesetzt.
['dc-snmp-community','dc-v3-user','dc-v3-authkey','dc-v3-privkey','dc-http-user','dc-http-pass','dc-fb-user','dc-fb-pass','dc-ssh-user','dc-ssh-port','dc-ssh-pass','dc-ssh-key'].forEach(id => document.getElementById(id).value = '');
document.getElementById('dc-snmp-version').value = '';
dcStored = {}; dcRevealed = false; dcFilled = false; dcFbReq = '';
_dcEl('dev-creds-title').textContent = (name || ip) + ' (' + ip + ')';
['dc-snmp-community','dc-v3-user','dc-v3-authkey','dc-v3-privkey','dc-http-user','dc-http-pass','dc-fb-user','dc-fb-pass','dc-fb-backuppass','dc-fb-keep','dc-ssh-user','dc-ssh-port','dc-ssh-pass','dc-ssh-key'].forEach(id => {
const el = _dcEl(id);
el.value = '';
if (el.dataset.ph == null) el.dataset.ph = el.placeholder;
el.placeholder = el.dataset.ph;
if (DC_SECRET_FIELDS[id] && el.tagName === 'INPUT') el.type = 'password';
});
_dcEl('dc-snmp-version').value = '';
dcToggleV3();
_dcEl('dc-fb-detect').style.display = isFritz ? '' : 'none';
const set = ((satCreds[satId] || {})[ip]) || [];
document.getElementById('dc-status').textContent = set.length ? ('Bereits hinterlegt: ' + set.join(', ')) : 'Noch keine Zugangsdaten hinterlegt.';
document.getElementById('dev-creds-modal').style.display = 'flex';
_dcEl('dc-status').textContent = set.length ? ('Bereits hinterlegt: ' + set.join(', ') + ' — lade …') : 'Noch keine Zugangsdaten hinterlegt.';
_dcEl('dev-creds-modal').style.display = 'flex';
if (set.length) send({ action: 'sat_creds_list', satellite: satId, ip });
}
function dcFill(detail, revealed) {
const g = path => path.reduce((o, k) => (o ? o[k] : undefined), detail || {});
if (!dcFilled) {
const sn = detail.snmp || {};
if (sn.version) { _dcEl('dc-snmp-version').value = sn.version; dcToggleV3(); }
const v3 = sn.v3 || {};
if (v3.user) _dcEl('dc-v3-user').value = v3.user;
if (v3.level) _dcEl('dc-v3-level').value = v3.level;
if (v3.authProto) _dcEl('dc-v3-authproto').value = v3.authProto;
if (v3.privProto) _dcEl('dc-v3-privproto').value = v3.privProto;
_dcEl('dc-http-user').value = (detail.http || {}).user || '';
_dcEl('dc-fb-user').value = (detail.fritzbox || {}).user || '';
_dcEl('dc-fb-keep').value = (detail.fritzbox || {}).backupKeep || '';
_dcEl('dc-ssh-user').value = (detail.ssh || {}).user || '';
_dcEl('dc-ssh-port').value = (detail.ssh || {}).port || '';
}
for (const [id, path] of Object.entries(DC_SECRET_FIELDS)) {
const has = !!g([...path.slice(0, -1), 'has_' + path[path.length - 1]]);
const el = _dcEl(id);
dcStored[id] = has;
if (!has) continue;
if (revealed) { el.value = g(path) || ''; if (el.tagName === 'INPUT') el.type = 'text'; }
else if (!el.value) el.placeholder = '•••••• gespeichert';
}
dcFilled = true;
if (revealed) dcRevealed = true;
}
function dcEye(id) {
const el = _dcEl(id);
if (dcStored[id] && !dcRevealed && !el.value) {
_dcEl('dc-status').textContent = 'Hole gespeicherte Zugangsdaten …';
send({ action: 'sat_creds_list', satellite: credsModalSat, ip: credsModalIp, reveal: true });
return;
}
if (el.tagName === 'INPUT') el.type = el.type === 'password' ? 'text' : 'password';
}
// FritzBox: login_sid.lua listet die Benutzer auch ohne Anmeldung. Wer sich
// im Browser nur mit Passwort anmeldet, hat trotzdem einen (fritz1234).
function dcDetectFritzUser() {
dcFbReq = 'fb_' + Date.now();
_dcEl('dc-status').textContent = 'Frage die FritzBox nach ihren Benutzern …';
send({ action: 'sat_command', satellite: credsModalSat, cmd: 'http.get', requestId: dcFbReq,
params: { url: 'http://' + credsModalIp + '/login_sid.lua?version=2' } });
}
function dcOnFritzUsers(p) {
dcFbReq = '';
const body = String(((p.result || {}).body) || '');
const users = [...body.matchAll(/<User([^>]*)>([^<]+)<\/User>/g)].map(m => ({ name: m[2].trim(), last: /last="1"/.test(m[1]) }));
const st = _dcEl('dc-status');
if (!p.ok) { st.textContent = '⚠ FritzBox nicht erreichbar: ' + (p.error || 'Fehler'); return; }
if (!users.length) { st.textContent = 'Die Box nennt keine Benutzer — dann reicht das Passwort, Benutzer leer lassen.'; return; }
const pick = users.find(u => u.last) || users[0];
_dcEl('dc-fb-user').value = pick.name;
st.textContent = users.length > 1
? `Benutzer auf der Box: ${users.map(u => u.name).join(', ')} — eingetragen: ${pick.name} (zuletzt angemeldet).`
: `Benutzer ermittelt: ${pick.name}`;
}
function closeDeviceCreds() { document.getElementById('dev-creds-modal').style.display = 'none'; }
function saveDeviceCreds() {
@@ -6319,21 +6687,45 @@
} else if (v === '1' || v === '2c') {
const c = document.getElementById('dc-snmp-community').value.trim();
if (c) creds.snmp = { version: v, community: c };
else if (dcStored['dc-snmp-community']) creds.snmp = { version: v }; // Community bleibt
}
if (creds.snmp && creds.snmp.v3) { // leere Schluesselfelder = gespeicherte behalten
if (!creds.snmp.v3.authKey) delete creds.snmp.v3.authKey;
if (!creds.snmp.v3.privKey) delete creds.snmp.v3.privKey;
}
// Leeres Passwortfeld = gespeichertes Passwort behalten (Satellit fuellt es auf).
// HTTP-Basic: Benutzer optional (manche Geraete nutzen Password-only).
const hu = document.getElementById('dc-http-user').value.trim();
const hp = document.getElementById('dc-http-pass').value;
if (hu || hp) creds.http = { user: hu, pass: hp };
// FritzBox: Benutzer optional (Password-only-Boxen haben keinen) — es reicht
// ein Passwort. Gespeichert wird, sobald User ODER Passwort gesetzt ist.
if (hu || hp || dcStored['dc-http-pass']) { creds.http = { user: hu }; if (hp) creds.http.pass = hp; }
// FritzBox: Benutzer optional — es reicht ein Passwort.
const fu = document.getElementById('dc-fb-user').value.trim();
const fp = document.getElementById('dc-fb-pass').value;
if (fu || fp) creds.fritzbox = { user: fu, pass: fp };
const fbp = document.getElementById('dc-fb-backuppass').value;
if (fbp) {
const miss = [];
if (fbp.length < 12) miss.push('mindestens 12 Zeichen');
if (!/[A-Z]/.test(fbp)) miss.push('ein Großbuchstabe');
if (!/[a-z]/.test(fbp)) miss.push('ein Kleinbuchstabe');
if (!/\d/.test(fbp)) miss.push('eine Ziffer');
if (!/[^A-Za-z0-9]/.test(fbp)) miss.push('ein Sonderzeichen');
if (miss.length) { document.getElementById('dc-status').textContent = '⚠ Sicherungs-Kennwort zu schwach — es fehlt: ' + miss.join(', ') + '.'; return; }
}
const fkeep = parseInt(document.getElementById('dc-fb-keep').value, 10);
if (document.getElementById('dc-fb-keep').value && !(fkeep >= 1 && fkeep <= 100)) {
document.getElementById('dc-status').textContent = '⚠ Maximale Sicherungen: eine Zahl von 1 bis 100.'; return;
}
if (fu || fp || fbp || dcStored['dc-fb-pass'] || dcStored['dc-fb-backuppass']) {
creds.fritzbox = { user: fu };
if (fkeep) creds.fritzbox.backupKeep = fkeep;
if (fp) creds.fritzbox.pass = fp;
if (fbp) creds.fritzbox.backupPass = fbp;
}
// SSH: Benutzer + (Passwort ODER Key). Port optional.
const su = document.getElementById('dc-ssh-user').value.trim();
const sp = document.getElementById('dc-ssh-pass').value;
const sk = document.getElementById('dc-ssh-key').value.trim();
if (su && (sp || sk)) {
if (su && (sp || sk || dcStored['dc-ssh-pass'] || dcStored['dc-ssh-key'])) {
creds.ssh = { user: su };
if (sp) creds.ssh.pass = sp;
if (sk) creds.ssh.key = sk;
@@ -9317,7 +9709,7 @@
const j = await r.json();
_llmCatalog = (j && j.models) || _llmCatalog;
renderLlmCatalog();
if (st) { st.textContent = j.ok ? `✓ ${j.added || 0} neue Modelle von HuggingFace` : `✗ ${j.error || 'Fehler'}`; st.style.color = j.ok ? '#3FFF3F' : '#FF6B6B'; }
if (st) { st.textContent = j.ok ? `✓ ${j.added || 0} neue Modelle von HuggingFace` + (j.removed ? `, ${j.removed} alte aufgeräumt` : '') + ' (läuft auch von selbst einmal am Tag)' : `✗ ${j.error || 'Fehler'}`; st.style.color = j.ok ? '#3FFF3F' : '#FF6B6B'; }
} catch (e) { if (st) { st.textContent = '✗ ' + e.message; st.style.color = '#FF6B6B'; } }
}
// online LLM-Boxen (fuer die Ziel-Auswahl + Verfuegbarkeit)
@@ -9347,7 +9739,11 @@
const q = (document.getElementById('llm-catalog-search') || {}).value || '';
const rows = document.querySelectorAll('#llm-catalog-list tbody tr[data-search]');
let shown = 0;
rows.forEach(tr => { const ok = _llmMatches(tr.dataset.search, q); tr.style.display = ok ? '' : 'none'; if (ok) shown++; });
const onlyTools = (document.getElementById('llm-catalog-tools') || {}).checked;
rows.forEach(tr => {
const ok = _llmMatches(tr.dataset.search, q) && !(onlyTools && tr.dataset.tools === 'nein');
tr.style.display = ok ? '' : 'none'; if (ok) shown++;
});
const none = document.getElementById('llm-catalog-none');
if (none) none.style.display = rows.length && !shown ? '' : 'none';
}
@@ -9432,8 +9828,20 @@
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
: '<option value="">(keine Box online)</option>';
const searchText = [m.id, m.description, m.hfRepo, m.quant].filter(Boolean).join(' ');
return '<tr data-search="' + escapeHtml(searchText) + '" style="border-bottom:1px solid #1E1E2E;">' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
return '<tr data-search="' + escapeHtml(searchText) + '" data-tools="' + (m.tools === false ? 'nein' : 'ja') + '" style="border-bottom:1px solid #1E1E2E;">' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b>'
+ (m.safety && m.safety.flagged && m.safety.flagged.length ? ' <span title="HuggingFace meldet Befunde bei Dateien in diesem Repo — siehe ℹ" style="font-size:10px;color:#FF453A;border:1px solid #FF453A;border-radius:8px;padding:0 5px;">⚠ Befund</span>'
: m.safety && m.safety.risky && m.safety.risky.length ? ' <span title="Im Repo liegen Skripte/ausführbare Dateien neben dem Modell — wir laden sie nicht, aber Vorsicht beim Anbieter (siehe ℹ)" style="font-size:10px;color:#FF9F0A;border:1px solid #FF9F0A;border-radius:8px;padding:0 5px;">⚠ Skripte</span>' : '')
+ (m.safety && m.safety.known ? ' <span title="Bekannter Anbieter (Hersteller oder bekannter GGUF-Umwandler)" style="font-size:10px;color:#8888AA;">✓</span>' : '')
+ (m.tools ? ' <span title="Kann Werkzeuge aufrufen (laut Chat-Vorlage) — z.B. Websuche, wenn ARIA es lokal nutzt" style="font-size:10px;color:#34C759;border:1px solid #34C759;border-radius:8px;padding:0 5px;">🔧</span>' : '')
+ (m.uncensored ? ' <span title="Unzensierte Fassung — lehnt kaum etwas ab" style="font-size:10px;color:#FFB020;border:1px solid #FFB020;border-radius:8px;padding:0 5px;">unzensiert</span>' : '')
+ (m.added_at && (Date.now() - new Date(m.added_at).getTime()) < 7 * 864e5 ? ' <span title="In den letzten 7 Tagen in den Katalog gekommen" style="font-size:10px;color:#4DB8FF;border:1px solid #4DB8FF;border-radius:8px;padding:0 5px;">neu</span>' : '')
+ (m.popularity ? '<div title="HuggingFace: Downloads der letzten 30 Tage (insgesamt) und Likes. Sagt „viel benutzt“, nicht „gut“ — viele Downloads sind Automatik." style="font-size:10px;color:#6a6a88;font-weight:normal;">↓ '
+ Number(m.popularity.downloads).toLocaleString('de') + '/Monat'
+ (m.popularity.downloadsAll ? ' (' + Number(m.popularity.downloadsAll).toLocaleString('de') + ' ges.)' : '')
+ ' · ♥ ' + Number(m.popularity.likes).toLocaleString('de') + '</div>' : '')
+ (m.hfRepo ? ' <button class="btn secondary" onclick="openModelInfo(' + i + ')" title="Was kann das Modell? Zensiert oder nicht?" style="padding:0 6px;font-size:11px;margin-left:4px;">ℹ</button>' : '')
+ '</td>' +
'<td ' + td + 'color:#9a9ab5;min-width:220px;">' + escapeHtml(m.description || m.hfRepo || '')
+ (m.quant ? ' <span style="color:#555570;" title="Quantisierung, die geladen wird' + (m.quants ? ' — vorhanden: ' + escapeHtml(m.quants.join(', ')) : '') + '">· ' + escapeHtml(m.quant) + '</span>' : '')
+ (m.unusable ? ' <span style="color:#FF6B6B;" title="Im Repo liegt keine ladbare GGUF-Modelldatei mehr">· nicht ladbar</span>' : '')
@@ -9823,6 +10231,110 @@
'<td style="' + td + 'text-align:right;white-space:nowrap;">' + act + '</td></tr>';
}).join('') + '</table></div>';
}
// Modell per Link/Repo in den Katalog. Ohne GGUF: GGUF-Fassungen vorschlagen.
async function addCatalogByLink(repo) {
const inp = document.getElementById('llm-catalog-add');
const out = document.getElementById('llm-catalog-add-result');
const val = repo || (inp.value || '').trim();
if (!val) return;
out.innerHTML = '<span style="color:#8888AA;">Prüfe ' + escapeHtml(val) + ' auf HuggingFace …</span>';
try {
const j = await (await fetch('/api/llm-catalog/add', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hfRepo: val, manual: true }) })).json();
if (j.ok) {
_llmCatalog = j.models || _llmCatalog;
renderLlmCatalog();
inp.value = '';
out.innerHTML = '<span style="color:#34C759;">✓ ' + escapeHtml(j.id) + (j.existed ? ' war schon im Katalog' : ' ist jetzt im Katalog') + ' — unten Ziel-Karte wählen und laden.</span>';
const s = document.getElementById('llm-catalog-search');
if (s) { s.value = j.id; filterLlmCatalog(); }
return;
}
const alts = j.alternatives || [];
out.innerHTML = '<span style="color:#FF9F0A;">⚠ ' + escapeHtml(j.error || 'Fehler') + '</span>'
+ (alts.length ? '<div style="color:#9a9ab5;margin-top:4px;">GGUF-Fassungen desselben Modells:</div>' + alts.map(a =>
`<div style="display:flex;gap:8px;align-items:center;margin-top:3px;">
<a href="https://huggingface.co/${escapeHtml(a.repo)}" target="_blank" rel="noopener noreferrer" style="color:#E0E0F0;">${escapeHtml(a.repo)}</a>
<span style="color:#555570;">↓ ${Number(a.downloads).toLocaleString('de')}</span>
<button class="btn secondary" onclick="addCatalogByLink('${escapeHtmlAttr(a.repo)}')" style="padding:1px 8px;font-size:11px;">Diese nehmen</button></div>`).join('')
: '');
} catch (e) { out.innerHTML = '<span style="color:#FF6B6B;">✗ ' + escapeHtml(e.message) + '</span>'; }
}
// ℹ: Kurzbeschreibung, zensiert/unzensiert, Fakten von HuggingFace
async function openModelInfo(i) {
const m = _llmCatalog[i];
if (!m || !m.hfRepo) return;
const box = _modelInfoModal('<div style="color:#8888AA;">Frage HuggingFace nach ' + escapeHtml(m.hfRepo) + ' …</div>');
let j;
try { j = await (await fetch('/api/llm-model-info?repo=' + encodeURIComponent(m.hfRepo))).json(); }
catch (e) { j = { ok: false, error: e.message }; }
if (!document.body.contains(box)) return;
if (!j.ok) { _modelInfoModal('<div style="color:#FF6B6B;">✗ ' + escapeHtml(j.error || 'Fehler') + '</div>'); return; }
const fam = (j.families || []).map(f => `<div style="margin-bottom:8px;"><b>${escapeHtml(f.name)}</b><div style="color:#C8C8DA;margin-top:2px;">${escapeHtml(f.text)}</div></div>`).join('')
|| '<div style="color:#8888AA;">Für diese Modellfamilie habe ich keine Kurzbeschreibung — siehe Modellkarte unten.</div>';
const cens = j.uncensored
? `<span style="background:#3A2410;color:#FFB020;border:1px solid #FFB020;border-radius:10px;padding:1px 8px;">unzensiert</span> <span style="color:#8888AA;font-size:11px;">${escapeHtml(j.uncensoredWhy)} — lehnt kaum etwas ab; du bist selbst verantwortlich, was du damit machst.</span>`
: `<span style="background:#103A20;color:#34C759;border:1px solid #34C759;border-radius:10px;padding:1px 8px;">mit Schutzfiltern</span> <span style="color:#8888AA;font-size:11px;">Standard des Herstellers — lehnt heikle Anfragen ab (keine Hinweise auf „uncensored“ o.ä.).</span>`;
_loadModelSummary(m.hfRepo);
const row = (k, v) => v ? `<tr><td style="color:#8888AA;padding:2px 10px 2px 0;white-space:nowrap;">${k}</td><td>${v}</td></tr>` : '';
const date = x => x ? new Date(x).toLocaleDateString('de-DE') : '';
_modelInfoModal(`
<h3 style="margin:0 0 4px;">ℹ ${escapeHtml(m.id)}</h3>
<a href="https://huggingface.co/${escapeHtml(m.hfRepo)}" target="_blank" rel="noopener noreferrer" style="color:#4DB8FF;font-size:12px;">${escapeHtml(m.hfRepo)}</a>
<div style="margin:12px 0;">${cens}</div>
<h4 style="margin:10px 0 6px;">Was kann es?</h4>
<div id="model-info-summary" style="margin-bottom:10px;color:#8888AA;">Kurzbeschreibung wird vom lokalen Modell aus der Modellkarte erstellt …</div>
${fam}
<table style="font-size:12px;margin-top:8px;">
${row('Sicherheit', _modelSafetyHtml(j.safety))}
${row('Werkzeuge', j.tools === true ? '🔧 ja — die Chat-Vorlage kennt Werkzeug-Aufrufe' : j.tools === false ? 'nein — eher für Übersetzen, Zusammenfassen, Erklären' : 'unbekannt (keine Chat-Vorlage)')}
${row('Größe', m.sizeGB ? '~' + m.sizeGB + ' GB · ' + escapeHtml(m.quant || '') : '')}
${row('Grundmodell', j.base ? escapeHtml(j.base) : '')}
${row('Von', escapeHtml(j.author))}
${row('Lizenz', escapeHtml(j.license))}
${row('Beliebtheit', '↓ ' + Number(j.downloads).toLocaleString('de') + ' Downloads (30 Tage) · ♥ ' + j.likes + ' Likes <span style="color:#6a6a88;font-size:11px;">— sagt „viel benutzt“, nicht „gut“; HuggingFace hat keine Sterne</span>')}
${row('Stand', date(j.updated))}
${row('Schlagworte', (j.tags || []).map(t => '<span style="color:#9a9ab5;">' + escapeHtml(t) + '</span>').join(', '))}
</table>
${j.intro ? `<details style="margin-top:10px;"><summary style="cursor:pointer;color:#8888AA;">Aus der Modellkarte (${escapeHtml(j.introFrom)}, englisch)</summary><div style="white-space:pre-wrap;color:#C8C8DA;font-size:12px;margin-top:6px;">${escapeHtml(j.intro)}</div></details>` : ''}`);
}
function _modelSafetyHtml(sf) {
if (!sf) return 'unbekannt';
const parts = [sf.known ? '✓ bekannter Anbieter' : '<span style="color:#FF9F0A;">unbekannter Anbieter — eher vorsichtig</span>'];
parts.push(sf.flagged.length
? `<span style="color:#FF453A;">⚠ HuggingFace meldet Befunde: ${sf.flagged.map(f => escapeHtml(f.path) + ' (' + escapeHtml(f.status) + ')').join(', ')}</span>`
: `HuggingFace-Scan: ${sf.scanned} Datei(en) geprüft, keine Befunde`);
if (sf.risky.length) parts.push(`<span style="color:#FF9F0A;">Im Repo liegen Skripte/ausführbare Dateien: ${sf.risky.map(escapeHtml).join(', ')}.</span> <span style="color:#8888AA;">Die Box lädt nur die GGUF-Datei — diese Dateien kommen nie auf die Box. Aber: Warnsignal für den Anbieter.</span>`);
return parts.join('<br>');
}
// Deutsche Kurzbeschreibung: einmal vom lokalen Modell erzeugt, danach aus dem Katalog
async function _loadModelSummary(repo) {
let j;
try { j = await (await fetch('/api/llm-model-summary?repo=' + encodeURIComponent(repo))).json(); }
catch (e) { j = { ok: false, error: e.message }; }
const el = document.getElementById('model-info-summary');
if (!el) return;
if (j.ok) {
el.style.color = '#E0E0F0';
el.innerHTML = escapeHtml(j.summary) + `<div style="color:#555570;font-size:11px;margin-top:4px;">Zusammengefasst aus der Modellkarte von ${escapeHtml(j.by || 'dem lokalen Modell')} — kann Fehler enthalten.</div>`;
} else {
el.innerHTML = '<span style="color:#8888AA;">Keine Kurzbeschreibung: ' + escapeHtml(j.error || 'unbekannt') + ' (läuft ein lokales Modell?)</span>';
}
}
function _modelInfoModal(html) {
let m = document.getElementById('model-info-modal');
if (!m) {
m = document.createElement('div');
m.id = 'model-info-modal';
m.style.cssText = 'position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;';
m.addEventListener('click', e => { if (e.target === m) m.remove(); });
document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { m.remove(); document.removeEventListener('keydown', esc); } });
document.body.appendChild(m);
}
m.innerHTML = `<div style="position:relative;background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:640px;width:100%;max-height:86vh;overflow-y:auto;color:#E0E0F0;font-size:13px;line-height:1.5;">
<button onclick="document.getElementById('model-info-modal').remove()" style="position:absolute;top:8px;right:10px;background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">&times;</button>${html}</div>`;
return m;
}
async function addTrendingToCatalog(btn, repo) {
btn.disabled = true; btn.textContent = '…';
try {
@@ -10056,9 +10568,10 @@
}
// Nur auf die Platte der Box (der aus der Ziel-Karten-Auswahl) — in keine Karte
function downloadModelOnly(i) {
async function downloadModelOnly(i) {
const m = _llmCatalog[i];
if (!m) return;
if (!await _safetyGate(m)) return;
const sel = document.getElementById('llm-cat-box-' + i);
const target = String((sel && sel.value) || '').split('|')[0];
if (!target) return;
@@ -10073,9 +10586,16 @@
openLlmDownloads();
}
async function _safetyGate(m) {
const sf = m && m.safety;
if (!sf || !sf.flagged || !sf.flagged.length) return true;
return await uiConfirm(`Vorsicht bei ${m.id}\n\nHuggingFace meldet bei Dateien in diesem Repo Befunde (${sf.flagged.map(f => f.path).join(', ')}). `
+ 'Die Box lädt zwar nur die GGUF-Datei, aber wer Verdächtiges hochlädt, dem ist auch das Modell nicht zu trauen. Trotzdem laden?');
}
async function provisionModel(i) {
const m = _llmCatalog[i];
if (!m) return;
if (!await _safetyGate(m)) return;
const sel = document.getElementById('llm-cat-box-' + i);
const [target, gpu] = String((sel && sel.value) || '').split('|');
if (!target) return;
+346 -28
View File
@@ -517,22 +517,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 +564,58 @@ 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 ─────────────────
@@ -640,17 +704,212 @@ async function discoverGguf(cat, trusted) {
}
// Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden")
async function addHfRepoToCatalog(repo) {
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo");
// 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) throw new Error("kein ladbares GGUF-Modell in diesem Repo");
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: "Aus den HuggingFace-Trends übernommen", source: "hf" });
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 };
}
@@ -836,6 +1095,9 @@ function satelliteList() {
}));
}
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
const credsRevealWaiters = new Map();
function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() });
}
@@ -1412,7 +1674,15 @@ function connectRVS(forcePlain) {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
broadcast({ type: "sat_result", payload: msg.payload || {} });
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
const p = msg.payload || {};
const asker = credsRevealWaiters.get(p.requestId);
if (asker) {
credsRevealWaiters.delete(p.requestId);
if (asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: "sat_result", payload: p }));
} else if (!(p.result && typeof p.result === "object" && "kennwort" in p.result)) { // Kennwort ohne Fragenden: verwerfen
broadcast({ type: "sat_result", payload: p });
}
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
// der Bridge → an den Browser (Agent-Historie).
@@ -1425,7 +1695,16 @@ function connectRVS(forcePlain) {
location: p.location || "", devices: p.devices || [] });
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
// Credential-Store-Antworten eines Satelliten → an den Browser.
broadcast({ type: msg.type, payload: msg.payload || {} });
// Mit Klartext-Passwoertern (Auge im Formular) NUR an den Browser, der
// gefragt hat — nie an alle offenen Diagnostic-Tabs.
const p = msg.payload || {};
if (p.revealed) {
const asker = credsRevealWaiters.get(p.requestId);
credsRevealWaiters.delete(p.requestId);
if (asker && asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: msg.type, payload: p }));
} else {
broadcast({ type: msg.type, payload: p });
}
} else if (msg.type === "worker_hello") {
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
const p = msg.payload || {};
@@ -1644,6 +1923,26 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, tim
freshWs.on("error", () => {});
}
// Wie sendToRVS_withResponse, aber als Promise fuer den Server selbst:
// eigene Verbindung, wartet auf expectType mit passender requestId.
function rvsRequest(sendType, sendPayload, expectType, timeoutMs = 60000) {
return new Promise((resolve) => {
if (!RVS_HOST || !RVS_TOKEN) return resolve({ ok: false, error: "RVS nicht konfiguriert" });
const proto = RVS_TLS === "true" ? "wss" : "ws";
const ws2 = new WebSocket(`${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`);
const done = (v) => { clearTimeout(t); try { ws2.close(); } catch (_) {} resolve(v); };
const t = setTimeout(() => done({ ok: false, error: "Zeitüberschreitung" }), timeoutMs);
ws2.on("open", () => ws2.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })));
ws2.on("message", (raw) => {
try {
const r = JSON.parse(raw.toString());
if (r.type === expectType && (r.payload || {}).requestId === sendPayload.requestId) done(r.payload || {});
} catch {}
});
ws2.on("error", (e) => done({ ok: false, error: e.message }));
});
}
function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj);
@@ -2429,7 +2728,7 @@ 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 => {
@@ -2459,13 +2758,23 @@ const server = http.createServer((req, res) => {
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 = "";
try { repo = JSON.parse(body || "{}").hfRepo; } catch {}
addHfRepoToCatalog(repo)
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 }));
@@ -2473,7 +2782,7 @@ const server = http.createServer((req, res) => {
})
.catch(err => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
res.end(JSON.stringify({ ok: false, error: err.message, alternatives: err.alternatives || [] }));
});
});
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
@@ -3358,6 +3667,10 @@ wss.on("connection", (ws) => {
params.rvs_tls = RVS_TLS === "true";
params.rvs_token = RVS_TOKEN;
}
if (String(msg.cmd || "") === "fritzbox.backup_password" && msg.requestId) {
credsRevealWaiters.set(String(msg.requestId), ws);
setTimeout(() => credsRevealWaiters.delete(String(msg.requestId)), 60000);
}
sendToRVS_raw({
type: "sat_command",
payload: {
@@ -3411,8 +3724,13 @@ wss.on("connection", (ws) => {
requestId: "dn_" + Date.now() },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_list") {
const requestId = "dc_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
if (msg.reveal) {
credsRevealWaiters.set(requestId, ws);
setTimeout(() => credsRevealWaiters.delete(requestId), 30000);
}
sendToRVS_raw({ type: "sat_creds_list",
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
payload: { satellite: msg.satellite || "", ip: msg.ip || "", reveal: !!msg.reveal, requestId },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_set") {
sendToRVS_raw({ type: "sat_creds_set",
+9 -1
View File
@@ -40,6 +40,14 @@ CONTROL_ENABLED=true
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
# fritzbox.describe FritzBox: was DIESE Box kann (alle TR-064-Aktionen, ohne Login)
# fritzbox.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true
# fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten
# fritzbox.backup / backups / backup_view / restore / backup_delete
# FritzBox: Einstellungen sichern (Taste an der Box, 2 min),
# auflisten, lesbar ansehen, wieder einspielen, loeschen.
# Sicherungen liegen unter FB_BACKUP_DIR (/data/fritzbox-backups).
# (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen)
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
# adb.connect ADB-Verbindung aufbauen (params: ip)
@@ -52,7 +60,7 @@ CONTROL_ENABLED=true
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.*,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
+902 -64
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -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
+11 -3
View File
@@ -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", "")