Compare commits
3
Commits
d684bbb5f0
...
23feb3ba2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23feb3ba2d | ||
|
|
89cb3a765b | ||
|
|
864237ff2c |
@@ -544,6 +544,8 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
|
||||
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
|
||||
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
|
||||
- **Lokale Modelle: ein Modell pro Karte, Clients waehlen nur aus** — Schnittstelle fuer Drittprogramme: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md). Jede LLM-Karte einer Box (`LLM_GPUS=1,2` in der `.env`, alt `LLM_GPU=1`) haelt genau ein Modell, dauerhaft geladen; welches, legt nur die Diagnostic fest (Modell-Katalog → „Laden" → Ziel-**Karte**, in der Flotte pro Karte „Entladen"). Karten, die nicht fuers LLM vorgesehen sind, erscheinen dort gar nicht. Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port — sehen nur die geladenen Modelle aller Boxen und laden **nie** etwas: eine Anfrage fuer ein anderes Modell wird abgelehnt (`model_not_loaded` bzw. HTTP 404), nichts wird verdraengt. Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen (gemessen: zwei parallel in 4,5 s statt 10 s nacheinander). Technik: der llm-adapter schreibt eine llama-swap-Config nur aus den belegten Karten — pro Modell `CUDA_VISIBLE_DEVICES=<Karte>` (mit `CUDA_DEVICE_ORDER=PCI_BUS_ID`, damit die Nummern wie bei nvidia-smi zaehlen), `ttl: 0`, eine Gruppe mit `swap: false` und Preload beim Start; llama-swap laeuft mit `--watch-config` (die frueher aufgerufenen Reload-Endpunkte gab es nicht). Laden oder Entladen startet die anderen Karten der Box kurz neu. Schlaegt das Laden fehl, wird die Karte auf ihr vorheriges Modell zurueckgesetzt. Bestehende Boxen: das bisherige Standardmodell (`LLM_MODEL`) landet beim ersten Start auf der ersten LLM-Karte
|
||||
- **Lokale Modelle laden mit Fortschritt** (Modell-Katalog → „Laden", Knopf **⬇ Downloads**): echte Prozentanzeige mit Tempo und Restzeit, danach „laedt in die Karte" und am Ende „✓ in Karte N (Name) geladen" samt belegtem VRAM. llama.cpp zieht das GGUF per `-hf` selbst und meldet nichts; der llm-adapter fragt deshalb vorab bei Hugging Face, welche Dateien mit welcher Groesse und sha256 kommen, und misst genau diese im HF-Cache-Layout (`/models/models--org--repo/blobs/<sha256>.downloadInProgress`, nach Abschluss ohne Endung) — klappt auch bei gesplitteten Modellen und parallelen Downloads; liegt es schon im Cache, geht es direkt ans Laden. Der Warmup wartet jetzt bis zu `LLM_WARMUP_TIMEOUT_S` (1900 s) statt 60 s — vorher meldete ein mehrere GB grosses Modell „Fehler", waehrend llama-swap weiterlud. Ein Auftrag, auf den die Box nicht antwortet, erscheint nach 30 s als Warnung statt still zu verschwinden. Pro LLM-Box laeuft **ein** Modell auf ihrer LLM-Karte (`LLM_GPU`), ein anderes zu laden wechselt dorthin. *Wichtig:* `llm_provision_*` fehlte bis hierhin in der RVS-Allow-List — „Laden" hat seit Stage D nie eine Box erreicht
|
||||
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
|
||||
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
|
||||
- **Modell pro Chat**: jeder Chat (Hauptchat und jedes Projekt) kann sein eigenes Modell haben, ohne die Einstellung fuer alle umzustellen. Reihenfolge: **Einstellung** (`brainModel`) < **pro Chat** (`/shared/config/context_models.json`, gesetzt per 🧠-Auswahl neben „+ Projekt") < **Ausweich** (Sicherheitsfilter, s.u.). Angezeigt wird immer, was der **naechste Turn wirklich nutzt**: in den Kontext-Karten, in der Auswahl (orange „↪ läuft auf …" plus **„↩ zurück zu …"**, wenn gerade ausgewichen wird) und an jeder ARIA-Bubble — dort das Modell, das tatsaechlich geantwortet hat (aus `modelUsage` der CLI, also die aufgeloeste Version statt des Alias). Als Grundeinstellung zaehlt das Modell, mit dem das Brain **laeuft**, nicht der Dateiwert — ein ausstehender Neustart nach Aenderung von `brainModel` faellt so auf. In der **App** dasselbe hinter der Quell-Anzeige (Einstellungen, Default aus): Modell-Chip in der Kopfzeile, Tippen oeffnet die Auswahl fuer diesen Chat, die Bubbles zeigen das Modell statt nur „Claude"
|
||||
|
||||
@@ -3774,6 +3774,9 @@ class ARIABridge:
|
||||
# Fallback auf [model] fuer alte Adapter ohne models-Feld.
|
||||
"models": [m for m in _models if m] if isinstance(_models, list)
|
||||
else ([payload.get("model")] if payload.get("model") else []),
|
||||
# Karten-Belegung (neuer llm-adapter): dann ist "models" nur
|
||||
# noch das GELADENE — Clients laden nichts nach.
|
||||
**({"slots": payload["slots"]} if isinstance(payload.get("slots"), list) else {}),
|
||||
"busy": bool(prev.get("busy", False)),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
@@ -3819,6 +3822,13 @@ class ARIABridge:
|
||||
"model": "", "busy": False, "last_seen": 0.0,
|
||||
}
|
||||
w["busy"] = bool(payload.get("busy", False))
|
||||
# LLM-Boxen melden mit jedem Ping, welche Modelle GELADEN sind.
|
||||
# Nach "Karte leeren" in der Diagnostic sonst bis zum naechsten
|
||||
# hello (30 s) veraltet — ARIA wuerde ein entladenes Modell anfragen.
|
||||
if isinstance(payload.get("models"), list):
|
||||
w["models"] = payload["models"]
|
||||
if isinstance(payload.get("slots"), list):
|
||||
w["slots"] = payload["slots"]
|
||||
w["last_seen"] = time.time()
|
||||
return
|
||||
|
||||
@@ -4265,6 +4275,16 @@ class ARIABridge:
|
||||
llm_target = self._pick_worker("llm", model=model or None)
|
||||
if llm_target:
|
||||
req_payload["targetInstance"] = llm_target
|
||||
elif model and self._llm_boxes_with_slots():
|
||||
# Boxen mit Karten-Belegung laden nichts nach und schweigen auf
|
||||
# Broadcasts fuer fremde Modelle. Ein Broadcast liefe also nur in
|
||||
# den Timeout — lieber sofort ehrlich absagen.
|
||||
self._pending_llm.pop(request_id, None)
|
||||
loaded = sorted({m for w in self._llm_boxes_with_slots() for m in (w.get("models") or [])})
|
||||
return {"ok": False, "errorCode": "model_not_loaded",
|
||||
"error": (f"Lokales Modell {model!r} ist auf keiner KI-Box geladen. "
|
||||
f"Geladen: {', '.join(loaded) or 'nichts'}. "
|
||||
"Laden: Diagnostic → Modell-Katalog.")}
|
||||
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)",
|
||||
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
|
||||
model or "-", llm_target or "(broadcast)")
|
||||
@@ -5234,6 +5254,13 @@ class ARIABridge:
|
||||
})
|
||||
return out
|
||||
|
||||
def _llm_boxes_with_slots(self) -> list:
|
||||
"""Online-LLM-Boxen, die schon Karten-Belegung melden (neuer Adapter)."""
|
||||
now = time.time()
|
||||
return [w for w in self._workers.values()
|
||||
if w.get("service") == "llm" and isinstance(w.get("slots"), list)
|
||||
and (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S]
|
||||
|
||||
def _pick_worker(self, service: str, model: Optional[str] = None) -> Optional[str]:
|
||||
"""Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin
|
||||
ueber die freien). Gibt die instanceId oder None. Fuer Stage-3-Routing
|
||||
|
||||
+217
-12
@@ -103,6 +103,7 @@
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85);
|
||||
z-index:1000; justify-content:center; align-items:center; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
@keyframes llmdlpulse { 0%,100% { opacity:.15; } 50% { opacity:.45; } }
|
||||
.modal-box { background:#0D0D1A; border:1px solid #FFD60A44; border-radius:10px; width:90vw; max-width:860px;
|
||||
max-height:90vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:10px 14px;
|
||||
@@ -843,6 +844,7 @@
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
|
||||
<h2 style="margin:0;">Modell-Katalog</h2>
|
||||
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
|
||||
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
|
||||
</div>
|
||||
<div class="card" style="max-width:1100px;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
|
||||
@@ -1859,6 +1861,26 @@
|
||||
</div>
|
||||
|
||||
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
|
||||
<!-- Modell-Downloads auf LLM-Boxen: Fortschritt, dann „in die Karte geladen" -->
|
||||
<div class="modal-overlay" id="llm-dl-modal" onclick="if(event.target===this)closeLlmDownloads()">
|
||||
<div class="modal-box" style="max-width:720px;">
|
||||
<div class="modal-header">
|
||||
<h3>⬇ Modell-Downloads</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn secondary" onclick="send({action:'clear_llm_jobs'})" title="Fertige und fehlgeschlagene aus der Liste nehmen" style="padding:3px 10px;font-size:11px;">Erledigte entfernen</button>
|
||||
<button class="modal-close" onclick="closeLlmDownloads()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
|
||||
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
|
||||
Jede LLM-Karte hält genau <strong style="color:#8888AA;">ein</strong> Modell, dauerhaft. Welche
|
||||
Karten dem LLM gehören, steht in der .env der Box (<code>LLM_GPUS</code>). Laden ersetzt, was auf
|
||||
der Karte lag; die anderen Karten der Box starten dabei kurz neu. Clients (ARIA, Fremdprogramme,
|
||||
OpenHands) wählen nur unter den geladenen Modellen und laden nie selbst.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="info-modal">
|
||||
<div class="modal-box" style="max-width:640px;">
|
||||
<div class="modal-header">
|
||||
@@ -2313,6 +2335,8 @@
|
||||
try { loadModelList(); } catch {}
|
||||
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
|
||||
try { loadSafeguard(); } catch {}
|
||||
// Laufende Modell-Downloads (Badge am ⬇-Knopf)
|
||||
send({ action: 'get_llm_jobs' });
|
||||
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
|
||||
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
|
||||
};
|
||||
@@ -2574,11 +2598,19 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'llm_provision_progress') { onLlmJob(msg.payload); return; }
|
||||
if (msg.type === 'llm_jobs') {
|
||||
_llmJobs = {};
|
||||
for (const j of ((msg.payload || {}).jobs || [])) _llmJobs[_llmJobId(j)] = j;
|
||||
renderLlmDownloads();
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'llm_provision_result') {
|
||||
const p = msg.payload || {};
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) {
|
||||
if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
|
||||
else if (p.unloaded) { st.textContent = `✓ Karte ${p.gpu} geleert (${p.key})`; st.style.color = '#8888AA'; }
|
||||
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
|
||||
else { st.textContent = `✗ '${p.key}': ${p.error || 'Fehler'}`; st.style.color = '#FF6B6B'; }
|
||||
}
|
||||
@@ -5126,16 +5158,26 @@
|
||||
const meta = WORKER_SVC_META[w.service] || { icon: '⚙️', label: w.service };
|
||||
const dot = !w.online ? '#666' : (w.busy ? '#FFB020' : '#3FFF3F');
|
||||
const stat = !w.online ? 'offline' : (w.busy ? 'beschaeftigt' : 'frei – idle – keine Auslastung');
|
||||
// llm-Boxen koennen mehrere Modelle fahren (llama-swap) → Liste zeigen.
|
||||
const modelText = (w.service === 'llm' && Array.isArray(w.models) && w.models.length)
|
||||
? w.models.join(', ') : (w.model || '');
|
||||
// LLM-Boxen: pro Karte genau ein Modell, dauerhaft geladen. Laden und
|
||||
// Entladen nur hier (Modell-Katalog), Clients waehlen nur aus.
|
||||
let modelHtml;
|
||||
if (w.service === 'llm' && Array.isArray(w.slots)) {
|
||||
modelHtml = ''; // Karten folgen als eigene Zeilen unten
|
||||
} else if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) {
|
||||
// Box mit altem Adapter (ohne Karten): nur die Liste
|
||||
modelHtml = '<span style="color:#8888AA;" title="Box meldet noch keine Karten — llm-adapter aktualisieren">'
|
||||
+ escapeHtml(w.models.join(', ')) + '</span>';
|
||||
} else {
|
||||
modelHtml = '<span style="color:#8888AA;">' + escapeHtml(w.model || '') + '</span>';
|
||||
}
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:4px 0;">' +
|
||||
'<span style="width:8px;height:8px;border-radius:50%;background:' + dot + ';display:inline-block;"></span>' +
|
||||
'<span>' + meta.icon + ' <b>' + escapeHtml(meta.label) + '</b></span>' +
|
||||
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
|
||||
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
'<span>' + modelHtml + '</span>' +
|
||||
(w.gpus && !(w.service === 'llm' && Array.isArray(w.slots)) ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
|
||||
'</div>' +
|
||||
(w.service === 'llm' && Array.isArray(w.slots) ? renderLlmSlots(w) : '') +
|
||||
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
|
||||
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
|
||||
}).join('');
|
||||
@@ -8824,7 +8866,9 @@
|
||||
const order = Array.from(ids).sort();
|
||||
sel.innerHTML = order.map(id => {
|
||||
const n = counts[id] || 0;
|
||||
const avail = n > 0 ? ` · ${n} Box${n > 1 ? 'en' : ''}` : ' · offline';
|
||||
// Nur geladene Modelle zaehlen (Karten-Belegung) — alles andere kann
|
||||
// ARIA nicht nutzen; laden geht im Modell-Katalog.
|
||||
const avail = n > 0 ? ` · geladen auf ${n} Box${n > 1 ? 'en' : ''}` : ' · nicht geladen';
|
||||
return `<option value="${id}">${escapeHtml(nameOf(id))}${avail}</option>`;
|
||||
}).join('') || '<option value="">(keine)</option>';
|
||||
if (order.includes(_currentLocalModel)) sel.value = _currentLocalModel;
|
||||
@@ -8921,11 +8965,18 @@
|
||||
const td = 'style="padding:6px 8px;vertical-align:top;"';
|
||||
const rows = _llmCatalog.map((m, i) => {
|
||||
const have = boxesServing(m.id);
|
||||
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(b => escapeHtml(b.node)).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
||||
const where = b => {
|
||||
const sl = (b.slots || []).find(x => x.model === m.id);
|
||||
return escapeHtml(b.node) + (sl ? ' · Karte ' + escapeHtml(sl.gpu) : '');
|
||||
};
|
||||
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(where).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
|
||||
const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—';
|
||||
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
|
||||
const opts = boxes.length
|
||||
? boxes.map(b => `<option value="${escapeHtml(b.instanceId)}">${escapeHtml(b.node)}</option>`).join('')
|
||||
// Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste.
|
||||
const targets = llmCardTargets();
|
||||
const pre = (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
|
||||
const opts = targets.length
|
||||
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
|
||||
: '<option value="">(keine Box online)</option>';
|
||||
return '<tr style="border-bottom:1px solid #1E1E2E;">' +
|
||||
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
|
||||
@@ -8939,18 +8990,172 @@
|
||||
box.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
|
||||
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
|
||||
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
|
||||
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' +
|
||||
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '></th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
// ── Download-Fenster ──────────────────────────────────────────────
|
||||
// Zustand kommt vom Diagnostic-Server (llm_provision_progress / llm_jobs);
|
||||
// der kennt auch Downloads, die vor dem Oeffnen dieses Tabs gestartet sind.
|
||||
let _llmJobs = {};
|
||||
const _llmJobId = j => j.instanceId + '|' + j.key;
|
||||
|
||||
function _fmtBytes(b) {
|
||||
if (!b && b !== 0) return '?';
|
||||
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
|
||||
if (b >= 1e6) return (b / 1e6).toFixed(0) + ' MB';
|
||||
return Math.round(b / 1e3) + ' KB';
|
||||
}
|
||||
function _fmtEta(s) {
|
||||
if (s == null) return '';
|
||||
if (s < 60) return s + ' s';
|
||||
return Math.floor(s / 60) + ' min ' + String(s % 60).padStart(2, '0') + ' s';
|
||||
}
|
||||
function _boxName(instanceId) {
|
||||
const w = (typeof workers !== 'undefined' ? workers : []).find(x => x.instanceId === instanceId);
|
||||
return (w && w.node) || (instanceId || '').replace(/^llm@/, '');
|
||||
}
|
||||
|
||||
function onLlmJob(job) {
|
||||
if (!job || !job.key) return;
|
||||
_llmJobs[_llmJobId(job)] = job;
|
||||
renderLlmDownloads();
|
||||
// Fertig geladen → Katalog-Status ("✓ ki-box") nachziehen.
|
||||
if (job.phase === 'ready' && typeof requestWorkers === 'function') requestWorkers();
|
||||
}
|
||||
|
||||
function renderLlmDownloads() {
|
||||
const jobs = Object.values(_llmJobs).sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
|
||||
const running = jobs.filter(j => !['ready', 'error'].includes(j.phase)).length;
|
||||
const badge = document.getElementById('llm-dl-badge');
|
||||
if (badge) { badge.textContent = running; badge.style.display = running ? 'inline-block' : 'none'; }
|
||||
const box = document.getElementById('llm-dl-list');
|
||||
if (!box) return;
|
||||
if (!jobs.length) {
|
||||
box.innerHTML = '<div style="color:#6a6a88;text-align:center;padding:20px;">Keine Downloads. „Laden" im Modell-Katalog startet einen.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = jobs.map(j => {
|
||||
const box_ = escapeHtml(_boxName(j.instanceId)) + (j.gpu != null && j.gpu !== '' ? ' · Karte ' + escapeHtml(String(j.gpu)) : '')
|
||||
+ (j.replaces ? ' <span style="color:#6a6a88;">(ersetzt ' + escapeHtml(j.replaces) + ')</span>' : '');
|
||||
let pct = 0, color = '#0096FF', line = '', sub = '';
|
||||
if (j.phase === 'queued') {
|
||||
// Kommt hier nie mehr was, erreicht der Auftrag die Box nicht.
|
||||
const age = (Date.now() - (j.requestedAt || j.startedAt || Date.now())) / 1000;
|
||||
line = 'Angefragt — warte auf die Box …';
|
||||
if (age > 30) { line = '⚠ Die Box antwortet nicht.'; color = '#FF9F0A';
|
||||
sub = 'Ist sie online? Nach einem Update von RVS und Box erneut versuchen.'; }
|
||||
} else if (j.phase === 'start') {
|
||||
line = 'Box hat den Auftrag — prüfe, ob das Modell schon da ist …';
|
||||
} else if (j.phase === 'download') {
|
||||
pct = j.percent != null ? j.percent : 0;
|
||||
line = j.percent != null
|
||||
? `${pct.toFixed(1)} % · ${_fmtBytes(j.bytes)} / ${_fmtBytes(j.total)}`
|
||||
: `${_fmtBytes(j.bytes)} heruntergeladen (Größe unbekannt)`;
|
||||
sub = (j.speedBps ? (j.speedBps / 1e6).toFixed(1) + ' MB/s' : '') + (j.etaSeconds != null ? ' · noch ' + _fmtEta(j.etaSeconds) : '');
|
||||
} else if (j.phase === 'load') {
|
||||
pct = 100; color = '#FFD60A';
|
||||
line = j.cached ? 'Liegt schon auf der Box — lädt in die Karte …' : '100 % heruntergeladen — lädt jetzt in die Karte …';
|
||||
sub = 'Das Modell wandert in den Grafikspeicher; das dauert je nach Größe ein paar Sekunden bis Minuten.';
|
||||
} else if (j.phase === 'ready') {
|
||||
pct = 100; color = '#34C759';
|
||||
const card = j.gpu != null && j.gpu !== '' ? `Karte ${escapeHtml(String(j.gpu))}${j.gpuName ? ' (' + escapeHtml(j.gpuName) + ')' : ''}` : 'die Karte';
|
||||
line = `✓ In ${card} geladen — bereit`;
|
||||
const vram = j.vramTotalMB ? ` · VRAM ${(j.vramUsedMB / 1024).toFixed(1)} / ${(j.vramTotalMB / 1024).toFixed(1)} GB belegt` : '';
|
||||
sub = (j.loadSeconds != null ? `Dauer ${_fmtEta(Math.round(j.loadSeconds))}` : '') + vram;
|
||||
} else if (j.phase === 'error') {
|
||||
color = '#FF6B6B'; pct = j.percent || 0;
|
||||
line = '✗ ' + escapeHtml(j.error || 'Fehler');
|
||||
}
|
||||
const bar = `<div style="height:8px;background:#1E1E2E;border-radius:4px;overflow:hidden;margin:6px 0 4px;">
|
||||
<div style="height:100%;width:${Math.max(0, Math.min(100, pct))}%;background:${color};transition:width .6s;
|
||||
${j.phase === 'queued' || j.phase === 'start' ? 'opacity:.35;width:100%;animation:llmdlpulse 1.2s ease-in-out infinite;' : ''}"></div></div>`;
|
||||
return `<div style="background:#12121E;border:1px solid #2A2A3E;border-radius:8px;padding:10px 12px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;">
|
||||
<strong style="color:#E0E0F0;">${escapeHtml(j.key)}</strong>
|
||||
<span style="color:#8888AA;font-size:11px;">→ ${box_}</span>
|
||||
</div>${bar}
|
||||
<div style="color:${color === '#0096FF' ? '#E0E0F0' : color};font-size:12px;">${line}</div>
|
||||
${sub ? `<div style="color:#6a6a88;font-size:11px;margin-top:2px;">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openLlmDownloads() {
|
||||
send({ action: 'get_llm_jobs' });
|
||||
renderLlmDownloads();
|
||||
document.getElementById('llm-dl-modal').classList.add('open');
|
||||
}
|
||||
function closeLlmDownloads() {
|
||||
document.getElementById('llm-dl-modal').classList.remove('open');
|
||||
}
|
||||
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
|
||||
setInterval(() => {
|
||||
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
||||
}, 5000);
|
||||
|
||||
// ── LLM-Karten ────────────────────────────────────────────────────
|
||||
const SLOT_STATE = {
|
||||
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
|
||||
stopping: ['#FF9F0A', 'stoppt …'], stopped: ['#FF6B6B', '✗ nicht gestartet'],
|
||||
unknown: ['#8888AA', '? (llama-swap antwortet nicht)'],
|
||||
};
|
||||
function renderLlmSlots(w) {
|
||||
const rows = (w.slots || []).map(sl => {
|
||||
const card = 'Karte ' + escapeHtml(sl.gpu) + (sl.gpuName ? ' <span style="color:#555570;">(' + escapeHtml(sl.gpuName) + ')</span>' : '');
|
||||
if (!sl.model) {
|
||||
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||
<span style="color:#555570;">— leer · im Modell-Katalog „Laden" → diese Karte</span></div>`;
|
||||
}
|
||||
const [c, label] = SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')];
|
||||
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
|
||||
<span style="min-width:200px;color:#8888AA;">${card}</span>
|
||||
<b style="color:#E0E0F0;">${escapeHtml(sl.model)}</b>
|
||||
<span style="color:${c};">${label}</span>
|
||||
<button class="btn secondary" onclick="unloadLlmCard('${escapeHtmlAttr(w.instanceId)}','${escapeHtmlAttr(sl.gpu)}','${escapeHtmlAttr(sl.model)}')"
|
||||
title="Karte leeren — das Modell bleibt auf der Box gespeichert" style="margin-left:auto;padding:2px 8px;font-size:10px;">Entladen</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div style="margin:2px 0 6px 18px;padding:6px 10px;background:#0D0D18;border-left:2px solid #2A2A3E;font-size:11px;">${rows || '<span style="color:#555570;">Keine LLM-Karte konfiguriert (LLM_GPUS in der .env der Box).</span>'}</div>`;
|
||||
}
|
||||
function unloadLlmCard(instanceId, gpu, model) {
|
||||
if (!confirm(`Karte ${gpu} leeren?\n\n${model} wird entladen; Clients können es danach nicht mehr wählen. `
|
||||
+ `Die Datei bleibt auf der Box — erneutes Laden geht ohne Download.\n\n`
|
||||
+ `Die anderen Karten dieser Box starten dabei kurz neu (wenige Sekunden).`)) return;
|
||||
send({ action: 'llm_unload_model', targetInstance: instanceId, gpu });
|
||||
}
|
||||
// Ziele im Katalog: jede LLM-Karte jeder Box ("instanceId|karte").
|
||||
function llmCardTargets() {
|
||||
const out = [];
|
||||
for (const b of onlineLlmBoxes()) {
|
||||
if (Array.isArray(b.slots) && b.slots.length) {
|
||||
for (const sl of b.slots) out.push({ value: b.instanceId + '|' + sl.gpu, box: b, slot: sl,
|
||||
label: `${b.node} · Karte ${sl.gpu}` + (sl.model ? ` (${sl.model})` : ' (leer)') });
|
||||
} else {
|
||||
out.push({ value: b.instanceId + '|', box: b, slot: null, label: b.node }); // alter Adapter
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function provisionModel(i) {
|
||||
const m = _llmCatalog[i];
|
||||
if (!m) return;
|
||||
const sel = document.getElementById('llm-cat-box-' + i);
|
||||
const target = sel && sel.value;
|
||||
const [target, gpu] = String((sel && sel.value) || '').split('|');
|
||||
if (!target) return;
|
||||
const tgt = llmCardTargets().find(t => t.value === sel.value);
|
||||
if (tgt && tgt.slot && tgt.slot.model === m.id) { alert(`${m.id} liegt schon auf dieser Karte.`); return; }
|
||||
const busyBox = ((tgt && tgt.box.slots) || []).find(sl => sl.model === m.id);
|
||||
if (busyBox) { alert(`${m.id} liegt auf dieser Box schon auf Karte ${busyBox.gpu}. Ein Modell pro Box nur einmal.`); return; }
|
||||
if (tgt && tgt.slot && tgt.slot.model &&
|
||||
!confirm(`Karte ${gpu} auf ${tgt.box.node} hält gerade ${tgt.slot.model}.\n\n`
|
||||
+ `${m.id} ersetzt es. Clients, die ${tgt.slot.model} nutzen, verlieren es dann.\n`
|
||||
+ `Die anderen Karten dieser Box starten dabei kurz neu.\n\nErsetzen?`)) return;
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
|
||||
send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
|
||||
openLlmDownloads();
|
||||
}
|
||||
function runLlmTest() {
|
||||
const inp = document.getElementById('llm-test-input');
|
||||
|
||||
+58
-1
@@ -1260,6 +1260,11 @@ function connectRVS(forcePlain) {
|
||||
instanceId: p.instanceId, service: p.service || "",
|
||||
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
||||
// Nur llm: pro LLM-Karte das Modell + Zustand. "models" ist dann nur
|
||||
// noch die Auswahl der GELADENEN Modelle; "knownModels" das, was man
|
||||
// in eine Karte laden koennte.
|
||||
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
|
||||
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []),
|
||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||
@@ -1282,6 +1287,10 @@ function connectRVS(forcePlain) {
|
||||
workers.set(p.instanceId, w);
|
||||
}
|
||||
w.busy = !!p.busy;
|
||||
// LLM-Boxen schicken mit jedem Ping die Karten-Belegung — Laden,
|
||||
// Entladen und Neustarts sind so nach spaetestens 10 s sichtbar.
|
||||
if (Array.isArray(p.slots)) w.slots = p.slots;
|
||||
if (Array.isArray(p.models)) w.models = p.models;
|
||||
w.last_seen = Date.now();
|
||||
broadcastWorkers();
|
||||
}
|
||||
@@ -1372,7 +1381,17 @@ function connectRVS(forcePlain) {
|
||||
broadcast({ type: "service_status", payload: msg.payload });
|
||||
} else if (msg.type === "llm_provision_result") {
|
||||
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
|
||||
broadcast({ type: "llm_provision_result", payload: msg.payload || {} });
|
||||
const p = msg.payload || {};
|
||||
if (p.key && p.instanceId && !p.removed && !p.unloaded) {
|
||||
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
|
||||
if (job) broadcast({ type: "llm_provision_progress", payload: job });
|
||||
}
|
||||
broadcast({ type: "llm_provision_result", payload: p });
|
||||
} else if (msg.type === "llm_provision_progress") {
|
||||
// Download-Fortschritt einer Box (Prozent, Tempo, Restzeit, dann
|
||||
// "laedt in die Karte" und "geladen"). Merken fuer spaeter geoeffnete Tabs.
|
||||
const job = llmJobUpdate((msg.payload || {}).instanceId, (msg.payload || {}).key, msg.payload || {});
|
||||
if (job) broadcast({ type: "llm_provision_progress", payload: job });
|
||||
} else if (msg.type === "node_stats" || msg.type === "node_stats_history" || msg.type === "node_stats_reset_done") {
|
||||
// Auslastungs-Monitor (Stage E): Box-Antworten an die Browser durchreichen.
|
||||
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||
@@ -2074,6 +2093,27 @@ setInterval(checkDiskSpace, 30000);
|
||||
|
||||
// ── HTTP Server + WebSocket fuer Browser ────────────────
|
||||
|
||||
// ── Modell-Downloads auf LLM-Boxen ──────────────────────────────────
|
||||
// Letzter bekannter Stand pro (Box, Modell). Im Speicher reicht: das Fenster
|
||||
// soll zeigen, was gerade laeuft und was eben fertig wurde, keine Historie.
|
||||
const llmJobs = new Map();
|
||||
function llmJobUpdate(instanceId, key, patch) {
|
||||
if (!instanceId || !key) return null;
|
||||
const id = `${instanceId}\u0000${key}`;
|
||||
const job = { ...(llmJobs.get(id) || { instanceId, key, startedAt: Date.now() }), ...patch,
|
||||
instanceId, key, updatedAt: Date.now() };
|
||||
// Ein neuer Auftrag fuer dasselbe Modell faengt frisch an.
|
||||
if (patch.phase === "queued") { delete job.error; delete job.percent; delete job.bytes; job.startedAt = Date.now(); }
|
||||
llmJobs.set(id, job);
|
||||
// Alte Abgeschlossene nicht ewig mitschleppen.
|
||||
if (llmJobs.size > 30) {
|
||||
const done = [...llmJobs.entries()].filter(([, j]) => j.phase === "ready" || j.phase === "error")
|
||||
.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
||||
while (llmJobs.size > 30 && done.length) llmJobs.delete(done.shift()[0]);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
const htmlPath = path.join(__dirname, "index.html");
|
||||
const mailHtmlPath = path.join(__dirname, "email.html");
|
||||
|
||||
@@ -3320,13 +3360,30 @@ wss.on("connection", (ws) => {
|
||||
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
|
||||
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
|
||||
// restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert.
|
||||
} else if (msg.action === "get_llm_jobs") {
|
||||
ws.send(JSON.stringify({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } }));
|
||||
} else if (msg.action === "clear_llm_jobs") {
|
||||
// Nur Abgeschlossene wegraeumen — laufende Downloads bleiben sichtbar.
|
||||
for (const [k, j] of llmJobs) if (j.phase === "ready" || j.phase === "error") llmJobs.delete(k);
|
||||
broadcast({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } });
|
||||
} else if (msg.action === "llm_provision_model") {
|
||||
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
|
||||
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
|
||||
// sieht man das im Download-Fenster, statt dass einfach nichts passiert.
|
||||
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
|
||||
{ phase: "queued", requestedAt: Date.now() });
|
||||
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
|
||||
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
||||
targetInstance: msg.targetInstance || "",
|
||||
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
||||
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
|
||||
}, timestamp: Date.now() });
|
||||
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_unload_model") {
|
||||
// LLM-Karte leeren (das GGUF bleibt auf der Box).
|
||||
sendToRVS_raw({ type: "llm_unload_model", payload: {
|
||||
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
|
||||
log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_remove_model") {
|
||||
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
||||
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# Lokale Sprachmodelle nutzen — Schnittstelle für Drittprogramme
|
||||
|
||||
Diese Seite beschreibt, wie ein Programm die lokalen Sprachmodelle auf den
|
||||
KI-Boxen von ARIA nutzt: über den RVS (WebSocket) oder direkt über den
|
||||
OpenAI-kompatiblen Port einer Box.
|
||||
|
||||
## Die Grundregel
|
||||
|
||||
**Clients wählen aus, laden aber nie.**
|
||||
|
||||
- Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten
|
||||
hält **genau ein Modell, dauerhaft geladen**.
|
||||
- Welches Modell auf welcher Karte liegt, legt ausschließlich der Betreiber in
|
||||
der ARIA-Diagnostic fest (Modell-Katalog → Laden → Karte).
|
||||
- Ein Client — ARIA selbst, ein Drittprogramm, OpenHands — sieht nur die
|
||||
**geladenen** Modelle aller Boxen und schickt seine Anfrage an eines davon.
|
||||
- Eine Anfrage für ein Modell, das nicht geladen ist, wird **abgelehnt**. Sie
|
||||
lädt nichts nach und verdrängt nichts. Kein Client kann einem anderen das
|
||||
Modell wegnehmen.
|
||||
- Mehrere Clients dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
|
||||
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer, die
|
||||
Karte schafft in Summe aber mehr.
|
||||
|
||||
Welche Karten einer Box dem LLM gehören, steht in deren `.env`:
|
||||
|
||||
```bash
|
||||
# Beispiel: Karte 0 für Spracherkennung + Sprachausgabe, Karten 1 und 2 fürs LLM
|
||||
WHISPER_GPU=0
|
||||
F5TTS_GPU=0
|
||||
LLM_GPUS=1,2
|
||||
```
|
||||
|
||||
## Zwei Zugänge
|
||||
|
||||
| | RVS (WebSocket) | OpenAI-Port der Box |
|
||||
|---|---|---|
|
||||
| Erreichbar | überall, wo der RVS erreichbar ist | nur im Netz der Box (Standard: nur `localhost`) |
|
||||
| Sieht | alle Boxen und ihre Karten | nur diese eine Box |
|
||||
| Format | eigene Nachrichten (unten) | OpenAI `/v1/chat/completions` |
|
||||
| Typische Nutzer | eigene Programme, ARIA | OpenHands, Continue, Open WebUI |
|
||||
|
||||
---
|
||||
|
||||
## A) Über den RVS
|
||||
|
||||
### Verbinden
|
||||
|
||||
```
|
||||
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
|
||||
```
|
||||
|
||||
- Das **Token ist der Raum**. Es ist dasselbe Token, das ARIA, die Diagnostic
|
||||
und die KI-Boxen nutzen (`RVS_TOKEN`). Wer das Token hat, ist im Raum.
|
||||
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an *alle anderen*
|
||||
Teilnehmer des Raums. Du siehst also auch Antworten, die für andere Clients
|
||||
bestimmt sind — ordne Antworten immer über die `requestId` zu.
|
||||
- Der RVS lässt nur bekannte Nachrichtentypen durch. Unbekannte Typen
|
||||
verschwinden **ohne Fehlermeldung**. Die hier beschriebenen sind freigegeben.
|
||||
|
||||
Jede Nachricht hat diese Hülle:
|
||||
|
||||
```json
|
||||
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
|
||||
```
|
||||
|
||||
### 1. Welche Modelle gibt es? — `llm_models_request`
|
||||
|
||||
```json
|
||||
{ "type": "llm_models_request", "payload": { "requestId": "abc-1" } }
|
||||
```
|
||||
|
||||
**Jede** Box im Raum antwortet einzeln mit `llm_models`. Sammle die Antworten
|
||||
ein paar Sekunden lang ein (2–3 s reichen):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_models",
|
||||
"payload": {
|
||||
"requestId": "abc-1",
|
||||
"instanceId": "llm@ki-box",
|
||||
"node": "ki-box",
|
||||
"models": [
|
||||
{ "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
|
||||
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `instanceId` | Adresse der Box — brauchst du gleich als `targetInstance` |
|
||||
| `node` | lesbarer Name der Box |
|
||||
| `models[].model` | Modellname, so in der Anfrage angeben |
|
||||
| `models[].gpu` | Karte, auf der es läuft |
|
||||
| `models[].state` | `ready` = bereit, `starting` = startet gerade neu (Anfragen warten dann kurz), `unknown` = Box konnte es nicht prüfen |
|
||||
|
||||
Es stehen **nur geladene** Modelle in der Liste. Was dort nicht steht, kannst
|
||||
du nicht nutzen.
|
||||
|
||||
Mitlesen statt fragen: Die Boxen melden sich außerdem regelmäßig selbst
|
||||
(`worker_hello` etwa alle 30 s, `worker_ping` alle 10 s, jeweils mit
|
||||
`service: "llm"`, `models` und `slots`). Ein Client, der länger verbunden
|
||||
bleibt, kann die Liste daraus aktuell halten.
|
||||
|
||||
### 2. Fragen — `llm_request`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_request",
|
||||
"payload": {
|
||||
"requestId": "abc-2",
|
||||
"targetInstance": "llm@ki-box",
|
||||
"model": "qwen3-8b",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Du antwortest kurz." },
|
||||
{ "role": "user", "content": "Wie hoch ist die Zugspitze?" }
|
||||
],
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) — damit findest du deine Antwort |
|
||||
| `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) |
|
||||
| `model` | dringend empfohlen | eines aus `llm_models`. Ohne Angabe nimmt die Box das Modell ihrer ersten LLM-Karte. |
|
||||
| `targetInstance` | dringend empfohlen | die `instanceId` aus `llm_models`. Siehe „Ohne `targetInstance`". |
|
||||
| `max_tokens` | nein | Standard 512 |
|
||||
| `temperature` | nein | Standard 0.7 |
|
||||
| `stop` | nein | Liste von Stop-Strings |
|
||||
| `tools` | nein | Tool-Definitionen im OpenAI-Format; Antwort enthält dann ggf. `tool_calls` |
|
||||
|
||||
### 3. Antwort — `llm_response`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "llm_response",
|
||||
"payload": {
|
||||
"requestId": "abc-2",
|
||||
"ok": true,
|
||||
"content": "Die Zugspitze ist 2962 Meter hoch.",
|
||||
"tool_calls": null,
|
||||
"model": "qwen3-8b",
|
||||
"instanceId": "llm@ki-box",
|
||||
"node": "ki-box",
|
||||
"gpu": "1",
|
||||
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
|
||||
"elapsedMs": 640
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Im Fehlerfall ist `ok` `false`, dazu kommen `error` (Text für Menschen) und
|
||||
`errorCode` (für Programme):
|
||||
|
||||
| `errorCode` | Bedeutung | Was tun |
|
||||
|---|---|---|
|
||||
| `model_not_loaded` | Das Modell liegt auf dieser Box nicht. `loadedModels` nennt, was dort geladen ist. | Ein geladenes Modell wählen. Die Liste neu holen (`llm_models_request`). |
|
||||
| `no_model_loaded` | Auf dieser Box ist keine LLM-Karte belegt. | Andere Box wählen. |
|
||||
| *(fehlt)* | Fehler des Modells selbst, z.B. Kontext zu lang | `error` lesen |
|
||||
|
||||
### Ohne `targetInstance`
|
||||
|
||||
Ohne `targetInstance` geht die Anfrage an alle Boxen. Antworten werden nur die,
|
||||
die das Modell geladen haben — die anderen **schweigen**, damit du nicht von
|
||||
jeder Box einen Fehler bekommst. Hat keine Box das Modell, kommt also **gar
|
||||
keine** Antwort und dein Client läuft in seinen Timeout. Darum: immer
|
||||
`targetInstance` setzen. Dann bekommst du bei einem Problem sofort eine klare
|
||||
Absage.
|
||||
|
||||
Haben mehrere Boxen dasselbe Modell geladen und du lässt `targetInstance`
|
||||
weg, antworten **alle**. Auch das spricht für `targetInstance`.
|
||||
|
||||
### Was Clients nicht senden
|
||||
|
||||
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
|
||||
Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der RVS
|
||||
unterscheidet die Teilnehmer nicht. Ein Programm, das sich daran nicht hält,
|
||||
tauscht anderen Clients das Modell unter den Füßen weg; genau das soll diese
|
||||
Schnittstelle verhindern.
|
||||
|
||||
### Beispiel (Python)
|
||||
|
||||
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Genau so gegen einen
|
||||
echten RVS, llm-adapter und llama-swap getestet — nur die Adresse war eine andere.
|
||||
|
||||
```python
|
||||
import asyncio, json, uuid
|
||||
import websockets
|
||||
|
||||
RVS = "wss://rvs.example.org:443/?token=DEIN_RVS_TOKEN"
|
||||
|
||||
|
||||
async def models(ws, wait=2.5):
|
||||
"""Alle geladenen Modelle aller Boxen: [(instanceId, node, model, gpu)]."""
|
||||
rid = str(uuid.uuid4())
|
||||
await ws.send(json.dumps({"type": "llm_models_request", "payload": {"requestId": rid}}))
|
||||
found = []
|
||||
loop = asyncio.get_running_loop()
|
||||
end = loop.time() + wait
|
||||
while (left := end - loop.time()) > 0:
|
||||
try:
|
||||
msg = json.loads(await asyncio.wait_for(ws.recv(), left))
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
p = msg.get("payload") or {}
|
||||
if msg.get("type") == "llm_models" and p.get("requestId") == rid:
|
||||
found += [(p["instanceId"], p["node"], m["model"], m["gpu"]) for m in p["models"]]
|
||||
return found
|
||||
|
||||
|
||||
async def ask(ws, instance_id, model, prompt, timeout=120):
|
||||
rid = str(uuid.uuid4())
|
||||
await ws.send(json.dumps({"type": "llm_request", "payload": {
|
||||
"requestId": rid, "targetInstance": instance_id, "model": model,
|
||||
"messages": [{"role": "user", "content": prompt}], "max_tokens": 256}}))
|
||||
async with asyncio.timeout(timeout):
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
p = msg.get("payload") or {}
|
||||
# Der Raum ist ein Verteiler: nur UNSERE Antwort nehmen.
|
||||
if msg.get("type") == "llm_response" and p.get("requestId") == rid:
|
||||
if not p["ok"]:
|
||||
raise RuntimeError(f"{p.get('errorCode')}: {p.get('error')}")
|
||||
return p["content"]
|
||||
|
||||
|
||||
async def main():
|
||||
async with websockets.connect(RVS, max_size=2**24) as ws:
|
||||
available = await models(ws)
|
||||
for iid, node, model, gpu in available:
|
||||
print(f"{node} / Karte {gpu}: {model} ({iid})")
|
||||
if available:
|
||||
iid, node, model, gpu = available[0]
|
||||
print(await ask(ws, iid, model, "Sag Hallo in einem Satz."))
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B) Über den OpenAI-Port einer Box
|
||||
|
||||
Jede KI-Box bietet einen OpenAI-kompatiblen Endpunkt — für Programme, die nur
|
||||
„OpenAI-kompatibel" können (OpenHands, Continue, Open WebUI …).
|
||||
|
||||
```
|
||||
http://<IP der Box>:<LLM_OPENAI_PORT>/v1 (Standard-Port 8081)
|
||||
```
|
||||
|
||||
- `GET /v1/models` listet **nur die geladenen** Modelle dieser Box.
|
||||
- `POST /v1/chat/completions` mit `"model": "<Name>"` wie gewohnt.
|
||||
- Ein nicht geladenes Modell wird mit **HTTP 404** abgelehnt
|
||||
(`"no router for requested model"`) — es wird nichts nachgeladen.
|
||||
- Ist in der Diagnostic ein API-Key gesetzt, mit
|
||||
`Authorization: Bearer <KEY>` schicken.
|
||||
- Standardmäßig hört der Port nur auf `localhost` der Box. Für Zugriff aus dem
|
||||
Netz `LLM_OPENAI_BIND=0.0.0.0` in der `.env` der Box setzen — dann
|
||||
**unbedingt** einen API-Key vergeben.
|
||||
- Dieser Zugang sieht nur seine eigene Box. Wer mehrere Boxen nutzen will,
|
||||
nimmt den RVS.
|
||||
|
||||
```bash
|
||||
curl http://192.168.177.153:8081/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "qwen3-8b", "messages": [{"role": "user", "content": "Hallo"}]}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gleichzeitige Nutzung und Geschwindigkeit
|
||||
|
||||
- Mehrere Clients können **dasselbe Modell gleichzeitig** nutzen. llama.cpp
|
||||
bedient sie parallel (Continuous Batching); jede Antwort wird etwas
|
||||
langsamer, insgesamt schafft die Karte mehr.
|
||||
- Der Kontext (`-c`, Standard 8192 Tokens) ist bei aktuellen llama.cpp-Versionen
|
||||
ein **gemeinsamer Vorrat** aller gleichzeitigen Anfragen. Schicken mehrere
|
||||
Clients gleichzeitig sehr lange Verläufe, wird es dort eng.
|
||||
- Ältere llama.cpp-Versionen arbeiten Anfragen an dasselbe Modell nacheinander
|
||||
ab statt parallel. Welche Version eine Box fährt, hängt am Image von
|
||||
llama-swap (`docker compose pull llama-swap` holt die aktuelle).
|
||||
|
||||
## Wenn sich etwas ändert
|
||||
|
||||
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte sofort
|
||||
weg; laufende Clients bekommen dann `model_not_loaded` und sollten die Liste
|
||||
neu holen.
|
||||
- Beim Laden oder Entladen startet die Box **alle** ihre Modelle kurz neu
|
||||
(wenige Sekunden). Anfragen in diesem Moment warten, bis das Modell wieder
|
||||
bereit ist (`state: "starting"`).
|
||||
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
|
||||
dazukommen. Ein Client sollte unbekannte Felder ignorieren.
|
||||
+10
-1
@@ -124,6 +124,15 @@ const ALLOWED_TYPES = new Set([
|
||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
||||
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||
"llm_openai_config", "llm_openai_config_result",
|
||||
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
|
||||
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
|
||||
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
|
||||
"llm_provision_model", "llm_provision_result", "llm_provision_progress",
|
||||
"llm_remove_model",
|
||||
// Karten: Diagnostic leert eine LLM-Karte; Clients fragen, welche Modelle
|
||||
// auf welchen Boxen/Karten geladen sind (jede Box antwortet mit llm_models).
|
||||
// Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
|
||||
"llm_unload_model", "llm_models_request", "llm_models",
|
||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||
"node_stats_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
@@ -161,7 +170,7 @@ const pendingReplies = new Map();
|
||||
const PENDING_TTL_MS = 180_000;
|
||||
|
||||
function pendingKey(replyType, requestId) {
|
||||
return `${replyType} | ||||