Compare commits
25
Commits
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20601
|
||||
versionName "0.2.6.1"
|
||||
versionCode 20602
|
||||
versionName "0.2.6.2"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.6.1",
|
||||
"version": "0.2.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -35,6 +35,8 @@ interface VoiceButtonProps {
|
||||
onTapStart: () => Promise<boolean>;
|
||||
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
|
||||
onTapStop: () => Promise<void>;
|
||||
/** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */
|
||||
onTapCancel?: () => Promise<void>;
|
||||
/** Button deaktivieren */
|
||||
disabled?: boolean;
|
||||
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
|
||||
@@ -46,6 +48,7 @@ interface VoiceButtonProps {
|
||||
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
onTapStart,
|
||||
onTapStop,
|
||||
onTapCancel,
|
||||
disabled = false,
|
||||
wakeWordActive = false,
|
||||
}) => {
|
||||
@@ -136,6 +139,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
}
|
||||
}, [disabled, onTapStart, onTapStop]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (!onTapCancel || tapBusy.current) return;
|
||||
tapBusy.current = true;
|
||||
try {
|
||||
if (audioService.getRecordingState() === 'recording') await onTapCancel();
|
||||
} finally {
|
||||
tapBusy.current = false;
|
||||
}
|
||||
}, [onTapCancel]);
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const tenths = Math.floor((ms % 1000) / 100);
|
||||
@@ -154,6 +167,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
{wakeWordActive && !isRecording && (
|
||||
<View style={styles.wakeWordDot} />
|
||||
)}
|
||||
{isRecording && onTapCancel && (
|
||||
<TouchableOpacity
|
||||
onPress={handleCancel}
|
||||
accessibilityLabel="Aufnahme verwerfen"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
<Text style={styles.cancelIcon}>🗑</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.buttonOuter,
|
||||
@@ -197,6 +220,22 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#34C759',
|
||||
zIndex: 10,
|
||||
},
|
||||
// Muelleimer links neben dem Aufnahmeknopf — nur waehrend der Aufnahme
|
||||
cancelButton: {
|
||||
position: 'absolute',
|
||||
left: -46,
|
||||
top: 14,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(60, 60, 70, 0.9)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
cancelIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
buttonOuter: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
|
||||
@@ -2624,6 +2624,15 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Muelleimer waehrend der Aufnahme: verwerfen. cancelStreamingRecording
|
||||
// schickt stt_stream_end mit reason "cancel:*" — die STT-Bridge transkribiert
|
||||
// dann nicht, die aria-bridge wirft einen spaeten Text weg, und das leere
|
||||
// Endpoint raeumt die Sprechblase ab. Danach zurueck aufs Wake-Word.
|
||||
const handleVoiceButtonCancel = useCallback(async (): Promise<void> => {
|
||||
console.log('[Chat] Aufnahme verworfen (Muelleimer)');
|
||||
await ariaStopRecording('user-discard');
|
||||
}, [ariaStopRecording]);
|
||||
|
||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||
setShowFileUpload(false);
|
||||
@@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => {
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
onTapCancel={handleVoiceButtonCancel}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
|
||||
+147
-3
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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;">×</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;">×</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;">×</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
@@ -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",
|
||||
|
||||
@@ -40,6 +40,14 @@ CONTROL_ENABLED=true
|
||||
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
|
||||
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
|
||||
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
|
||||
# fritzbox.describe FritzBox: was DIESE Box kann (alle TR-064-Aktionen, ohne Login)
|
||||
# fritzbox.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true
|
||||
# fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten
|
||||
# fritzbox.backup / backups / backup_view / restore / backup_delete
|
||||
# FritzBox: Einstellungen sichern (Taste an der Box, 2 min),
|
||||
# auflisten, lesbar ansehen, wieder einspielen, loeschen.
|
||||
# Sicherungen liegen unter FB_BACKUP_DIR (/data/fritzbox-backups).
|
||||
# (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen)
|
||||
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
|
||||
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
|
||||
# adb.connect ADB-Verbindung aufbauen (params: ip)
|
||||
@@ -52,7 +60,7 @@ CONTROL_ENABLED=true
|
||||
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
|
||||
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
|
||||
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.*,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
|
||||
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
|
||||
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
|
||||
|
||||
+902
-64
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user