Compare commits

...
49 Commits
Author SHA1 Message Date
duffyduck 06d0064c8c release: bump version to 0.2.0.6 2026-07-11 16:50:22 +02:00
duffyduckandClaude Opus 4.8 a7cde52ee6 docs(changelog): Plan-B-Epos (0.2.0.4/5) + heutige Fixes nachgetragen
Changelog endete bei 0.2.0.3 (07-10). Nachgetragen:
- 0.2.0.4/5 (07-11): Plan B Lokales LLM (Router, Fast-Lane, SearXNG-Tools,
  llama-swap/B0.5, Quell-Badge, speak-Flag, --system-prompt Identity-Fix,
  Diskretions-Regel).
- Unreleased (07-11): Spotify-Resume via MEDIA_PLAY, TTS-Zahlen ausschreiben,
  "ARIA denkt"-Sync, weiche Tool-Fehler-Eskalation, Token-Ersparnis-Metrik,
  Projekte verstecken (Diagnostic + App), von ARIA geschaerfter Spotify-Skill.
Alt-Label "Unreleased 07-10" korrekt auf [0.2.0.3] gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:49:02 +02:00
duffyduckandClaude Opus 4.8 a353b62b37 feat(app): Projekte verstecken auch in der App (Auge + Toggle)
Spiegelt das Diagnostic-Feature auf App-Seite:
- Project-Interface + updateProject um hidden erweitert; setProjectHidden().
- ProjectsBrowser: versteckte Projekte standardmaessig ausgeblendet (mama
  sieht sie nicht). Auge pro Zeile (🙈 verstecken / 👁 sichtbar), Toggle
  "👁 Versteckte anzeigen (N)" blendet sie temporaer gedimmt + Badge ein
  zum Ansehen/Auswaehlen/Wieder-Sichtbarmachen. Eigener Touch am Auge, damit
  der Tap nicht das Projekt wechselt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:45:15 +02:00
duffyduckandClaude Opus 4.8 bc47c2053b feat(projects): Projekte verstecken (Auge-Toggle im Diagnostic)
Neues hidden-Flag pro Projekt (default false, bleibt voll nutzbar — nur
optisch aus der Liste ausgeblendet, unabhaengig von status/archived).
- projects.py: hidden in create_project + update_project-Patchkeys.
- main.py: ProjectUpdateBody.hidden → PATCH /projects/{id} setzt es.
- Diagnostic: pro Projekt-Bubble ein Auge (🙈 verstecken / 👁 sichtbar
  machen); Header-Toggle "Versteckte anzeigen (N)" blendet sie temporaer
  ein (gedimmt + Badge) zum Ansehen/Auswaehlen, ohne sie permanent
  sichtbar zu machen. Auge im eingeblendeten Zustand macht sie dauerhaft
  wieder sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:41:56 +02:00
duffyduckandClaude Opus 4.8 dc043ceb4d feat(metrics): lokaler LLM-Verbrauch + Claude-Ersparnis in Diagnostic
metrics.jsonl-Eintraege tragen jetzt 'source' (claude|local|fast-path).
- log_local_call(): echte usage-Tokens vom Adapter (prompt/completion),
  sonst chars/4-Schaetzung. Geloggt pro Tool-Runde im lokalen Fast-Lane.
- log_fast_path(): reiner Skill, 0 Prompt-Tokens — gesparter Claude-Call.
- aggregate() liefert zusaetzlich by_source (calls/tokens_in/tokens_out).
  Alt-Eintraege ohne source zaehlen als claude (rueckwaerts-kompatibel).

Diagnostic Gehirn-Tab: neue Card "Lokales LLM & Claude-Ersparnis" — pro
Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) und
lokale Token-Last (eigene HW, kein Quota) + Info-Block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:39:02 +02:00
duffyduckandClaude Opus 4.8 8bac7c56bc fix(local-llm): weiche Tool-Fehler eskalieren (Exit 0 + Fehlertext)
Die Escalate-on-Tool-Error-Logik griff nur bei hartem FEHLER-Prefix (Exit
!= 0). Action-Skills melden Fehlschlaege aber oft im stdout-Text bei Exit 0
(Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Das lokale
LLM las den Fehler dann brav vor statt zu eskalieren — und wiederholte beim
Nachhaken dieselbe leere Absichtserklaerung.

Generisch (nicht Spotify-spezifisch): fuer run_*-Skills werten weiche
Fehler-Marker im Ausgabetext ebenfalls als Fehlschlag → Claude uebernimmt
das mehrstufige Mitdenken. Info-Tools (web_search/memory_search) ausgenommen,
damit deren Inhalt das Wort "Fehler" tragen darf. Eskalieren ist immer sicher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:25:52 +02:00
duffyduckandClaude Opus 4.8 3b6d36f2de fix(sync): "ARIA denkt" bleibt haengen obwohl Turn fertig
Zwei Ursachen, beide adressiert:
- App raeumte den (kontext-scoped) Thinking-Indikator nur ueber das
  agent_activity 'idle' der Bridge. Kam das nicht an (dedup/mismatch),
  blieb "ARIA denkt" + Abbrechen stehen. Jetzt raeumt die App den
  Indikator beim Eintreffen der Antwort selbst (definitiver Turn-Ende-Beweis).
- Bridge sendete 'thinking' mit der REQUEST-projectId, 'idle' aber mit der
  TURN-projectId (Brain kann umrouten, z.B. Voice-Sticky). Bei Abweichung
  bekam der Request-Kontext nie sein idle → zusaetzliches idle fuer die
  Request-projectId am Turn-Ende.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:23:41 +02:00
duffyduckandClaude Opus 4.8 7f6e266d15 fix(tts): freistehende Ganzzahlen tag-unabhaengig ausschreiben
Regression seit das lokale LLM leichte Turns (Wetter, Spotify) beantwortet:
es nutzt bewusst KEINE <voice>-Tags, an denen frueher die Zahl-Aussprache hing.
clean_text_for_tts schrieb nur Uhrzeiten/Dezimalzahlen/Zahl+Einheit aus, nie
freistehende Ganzzahlen ('23°C', '100%', '7 Nachrichten').

Neuer vollstaendiger Konverter _int_to_words_de (0..999999) + generischer
Durchlauf am Ende von clean_text_for_tts, der alle uebrigen Ganzzahlen zu
Woertern macht. Tag-unabhaengig -> gilt fuer local UND claude. Lange
Ziffernfolgen (IDs/Codes, >6 Stellen) bleiben Ziffern; an .,:/ klebende
Zahlen (IPs, Versionen) werden uebersprungen. Nebenbei: fehlendes Leerzeichen
bei '23°C' -> 'dreiundzwanzig Grad Celsius' gefixt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:18:58 +02:00
duffyduckandClaude Opus 4.8 4d1664a328 fix(app): zuverlaessiger Spotify-Resume via MEDIA_PLAY-KeyEvent statt Focus-Nudge
Native AudioFocus: dispatchMediaPlay() (echter KEYCODE_MEDIA_PLAY an die aktive
MediaSession, wie die Kopfhoerer-Play-Taste) + isMusicActive() zum Gaten.
audio.ts merkt vor dem Focus-Grab ob Musik lief und resumt am Dialog-Ende nur
dann — deterministisch statt des auf OnePlus flakigen nudgeMediaResume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:13:18 +02:00
duffyduck 1ff02f9763 release: bump version to 0.2.0.5 2026-07-11 16:11:12 +02:00
duffyduck 5b5d61513f fix(router): lokales Tier eskaliert Gedaechtnis-/Personen-Fragen an Claude
Test 'Wer ist Melanie?': das lokale Qwen klammte zu + verwechselte den Frager
('frag Stefan direkt' — Stefan IST der User), weil der schlanke Lokal-Prompt kein
Memory hat. Claude dagegen antwortete diskret + korrekt (und haengte sogar selbst
<voice></voice> an → stumm, 'jemand koennte mithoeren').

Fix: Local-Prompt weist an, bei Fragen zu Stefans Leben/Personen/Beziehungen/
Vergangenheit/gespeichertem Wissen NICHT aus dem Nichts zu antworten, sondern
zu eskalieren (<<ESCALATE>> → Claude mit vollem Gedaechtnis). Die Diskretions-
Regel selbst bleibt (funktioniert auf Claude einwandfrei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)
2026-07-11 15:51:18 +02:00
duffyduckandClaude Opus 4.8 078ed17b57 fix(privacy): harte Diskretions-Regel — intime Details NIE ungefragt preisgeben
Schwerwiegend: ARIA hat bei Selbstvorstellung / "was weisst du ueber mich"
intime Details (Beziehungen, Partnerinnen, Lebensweise) proaktiv rausgedumpt —
ein Vertrauensbruch, wenn jemand mithoert. ARIA DARF das wissen, aber niemals
ungefragt aussprechen.

Regel in IDENTITY_ANCHOR (steht als einziger garantiert in BEIDEN Tiers, lokal
+ Claude): private/intime Infos sind hochvertraulich; nie von sich aus, nicht in
Vorstellungen/Zusammenfassungen/Triggern; nur auf konkrete Nachfrage, knapp und
diskret. Auf "was weisst du ueber mich": allgemein + diskret antworten, kein
Aufzaehlen. "Jemand koennte mithoeren" als Leitplanke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:38:52 +02:00
duffyduckandClaude Opus 4.8 0a2e59d756 feat(tts): System-Flag speak (ja/nein) pro Antwort statt <voice>-Tag-Hack
Stefans Idee: die QUELLE entscheidet ueber Vorlesen, nicht der Reply-Inhalt.
Fast-Path (reiner Steuerbefehl) = speak=False (stumm); ARIA-Antworten
(local/claude) = speak=True (vorlesen ok — eine Ansage ist sogar nett).

- agent.chat() gibt jetzt (reply, answered_by, speak) zurueck; main.py
  ChatOut.speak; background.py-Aufrufer angepasst.
- bridge: liest speak aus /chat, reicht es an _process_core_response; bei
  speak=False wird TTS uebersprungen — VOR jeder <voice>-Logik.

Robust: unabhaengig davon, ob die Skill-fast_pattern-Reply ein <voice></voice>
enthaelt (das verlor ARIA beim semantischen Skill-Rebuild — genau der Bug).
App braucht keinen Change: kein xtts_request -> kein Audio -> stumm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:55:12 +02:00
duffyduckandClaude Opus 4.8 2dfe6fd9c3 feat(local-llm): B0.5-2 — Live-Lade-Status des lokalen Modells in Diagnostic
Adapter meldet bei Modellwechsel/Erst-Load service_status (service=llm):
loading -> ready (mit loadSeconds; freshlyDownloaded 🎉 bei langem Erst-Load)
oder error. Laeuft ueber den vorhandenen Pfad: Adapter -> RVS -> Diagnostic
(RVS-Client) -> Browser -> updateServiceStatus (generisch; nur Label 'Lokales
LLM' ergaenzt). Kein Bridge-/server.js-Change noetig.

Download-% gibt llama-swap nicht her — daher Zustands-Status (laedt/bereit/
Fehler), im gemeinsamen Service-Banner wie whisper/flux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:29:18 +02:00
duffyduckandClaude Opus 4.8 8ae20a9bd8 feat(local-llm): B0.5 — llama-swap + lokale Modellauswahl in Diagnostic
Mehrere lokale Modelle, on-demand geladen/geswappt, in Diagnostic waehlbar.
Design: das Brain schickt den Modellnamen (aus local_llm.json) im llm_request
mit -> Adapter -> llama-swap laedt/swappt. Keine separate Gamebox-Config noetig.

- xtts: `llama`-Container -> `llama-swap` (unified-cuda), config.yaml mit
  qwen3-8b (Standard) + qwen3-4b; Auto-Download via -hf, Cache /models geteilt
  (qwen3-8b schon da). Adapter -> llama-swap:8080, Timeout 600s (Erst-Download).
- adapter: `model` aus dem Request an llama-swap durchreichen (Fallback env).
- brain: router.load_config liest localLlmModel; local_llm_chat(model=...);
  agent gibt cfg-Modell mit; bridge reicht model durch (_local_llm + Route).
- diagnostic: /api/local-models-list (aus /shared/config/local_models.json,
  seeded), local-llm-config um localLlmModel erweitert; Dropdown "Lokales
  Modell" im Settings-Block + Erst-Download-Hinweis.

BLIND gebaut (Gamebox nicht testbar hier): llama-swap CLI/Config-Pfad beim
ersten Start via `docker logs aria-llama-swap` pruefen. Live-Lade-Status
(Adapter->Diagnostic) ist B0.5-2 (Folgeschritt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:11:59 +02:00
duffyduck 3ddcf665f0 release: bump version to 0.2.0.4 2026-07-11 13:48:14 +02:00
duffyduckandClaude Opus 4.8 7ae61701ad feat(app): optionaler Quell-Badge an ARIA-Bubbles (Einstellung, pro Geraet, default aus)
Zeigt wie in Diagnostic, ob eine Antwort vom lokalen Modell / Claude / Fast-Path
kam — aber in der App als Opt-in, damit die App "Mama-tauglich" bleibt.

- ChatScreen: ChatMessage.answeredBy; aus dem chat-Payload UND der History-Sync
  eingefangen; kleiner farbiger Badge im statusRow der ARIA-Bubble, nur wenn
  showSource an. State aus AsyncStorage aria_show_source (default false, per
  2s-Reload synchron mit den Settings).
- SettingsScreen: Toggle "Antwort-Quelle anzeigen" in der Chat-Bubbles-Card,
  schreibt aria_show_source (pro Geraet — Stefan an, Mama aus).
- Bridge liefert answeredBy schon in chat_backup/History mit (kein Change noetig).

Server-Teil in 9cc1aec. APK-Rebuild noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:46:12 +02:00
duffyduckandClaude Opus 4.8 9cc1aec5ec feat(diagnostic): Quell-Badge an ARIA-Bubbles (local / claude / fast-path)
Zeigt pro Antwort, welcher Backend sie erzeugt hat — farbcodiert (lokal=gruen,
Claude=blau, Fast-Path=lila). Nur in Diagnostic (App bleibt Mama-tauglich).

- agent.chat() gibt jetzt (reply, answered_by) zurueck; an jedem Return-Punkt
  gesetzt (fast-path/local/claude). Beide Aufrufer (main.py, background.py)
  angepasst. main.py: ChatOut.answered_by.
- bridge: liest answered_by aus /chat, reicht es an _process_core_response,
  broadcastet es im chat-Payload UND persistiert es in chat_backup (answeredBy)
  → bleibt nach Reload.
- diagnostic: srcBadgeHtml() rendert den Badge in Live-Chat + History;
  server.js liefert answeredBy in der chat_history.

Erweiterbar: spaeter kann ein Bild-Backend (FLUX/Modellname) denselben Kanal
nutzen. Modellwechsel-fuer-Antworten (groessere Modelle bei mehr GPUs) bleibt
B0.5/Skalierungs-Thema im Plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:40:36 +02:00
duffyduckandClaude Opus 4.8 53c098a9c8 refactor(local-llm): generische Tool-Fehler-Eskalation statt per-Skill-Router-Code
Stefan-Punkt: der 'auf <Geraet>'-Router-Patch war ein per-Fall-Band-Aid, das
nicht skaliert (naechster Skill mit komplexer API → wieder patchen). Besser:
das LLM merkt es selbst.

- Router-Hardcoding zurueckgenommen (kein 'auf Geraet'→Claude mehr).
- GENERAL: im lokalen Tool-Loop → scheitert ein Tool-Call (Ergebnis beginnt mit
  'FEHLER'), uebernimmt Claude. Gilt fuer JEDEN Skill, kein per-Fall-Wissen im
  Router. Lokal probiert, bei Fehler eskaliert.
- skill_create-Anleitung: SEMANTISCH bauen — Skills bieten klare Operationen als
  args (action/device_name), NICHT rohe {path,method,body}-Durchreichung. Das
  args-Schema ist die 'Bedienungsanleitung', die das LLM sieht (die haben wir
  also schon — sie muss nur semantisch sein). Was das LLM sonst raten muesste
  (Endpunkte/IDs/Payload) gehoert INS Skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:18:26 +02:00
duffyduckandClaude Opus 4.8 0e42cf775f fix(router): geraete-gezieltes Spotify-Play → Claude (8B fummelt Transfer-Flow)
Log-Beweis: Qwen baute fuer 'auf android abspielen' einen erfundenen Endpoint
(/v1/me/player/start) mit Platzhalter-Device-ID -> 404. Device-Transfer ist ein
komplexer Mehrschritt-Flow (Geraete abfragen -> Name->echte ID -> richtiger
Endpoint); das 8B ist da unzuverlaessig. Router schliesst 'auf <Geraet>'-
Formulierungen jetzt aus dem lokalen Pfad aus -> Claude. Simple Steuerung
(pause/next/play, 'was laeuft') + 'auf deutsch' bleiben lokal (verifiziert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:14:36 +02:00
duffyduckandClaude Opus 4.8 0e0c5d742f docs(skill): robustere fast_patterns-Anleitung fuer skill_create/update
ARIA soll Patterns wortstellungs-tolerant + mit Synonymen/Fuellwoertern in EINEM
Regex schreiben (z.B. 'pause spotify' UND 'spotify pause' UND 'stopp mal'), statt
nur einer Formulierung. Plus der Schluessel-Hinweis: Fast-Paths nur fuer die
HAEUFIGSTEN Befehle — den Rest faengt das lokale LLM (hat das Tool) schnell ab,
also lieber wenige breite Patterns als viele enge. Damit kriegen kuenftige
Skills automatisch robuste Patterns (statt manuellem Nachpatchen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:10:44 +02:00
duffyduckandClaude Opus 4.8 5b20bc1743 fix(local-llm): lokale Antwort nicht in <voice> wickeln (Anzeige war leer)
Qwen imitierte aus dem Kontext den <voice>-TTS-Block, packte aber die GANZE
Antwort hinein -> Display strippt <voice> -> leere Bubble (nur TTS klang richtig).
Fix: (1) Local-Prompt verbietet <voice>/[FILE:]/<tool_call>/Markup explizit —
nur Plain-Text (wird angezeigt UND vorgelesen). (2) Sicherheitsnetz: Voice-Tags
aus lokalen Antworten strippen, falls das 8B es doch tut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:09:57 +02:00
duffyduckandClaude Opus 4.8 c3652d2f8e fix(local-llm): web_search local-only — Claude behaelt seine staerkeren Web-Tools
web_search war versehentlich in META_TOOLS -> Claude bekam es zusaetzlich zu
seinen nativen WebSearch/WebFetch/Bash (redundant/verwirrend). Jetzt als
WEB_SEARCH_TOOL nur im lokalen Tool-Set (_build_local_tools). Claude nutzt
weiter seine eigene Suche + Voll-Seiten-WebFetch (wichtig fuer Pentest/Research);
SearXNG ist fuer das lokale Tier, das sonst keinen Netzzugriff haette.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:53:03 +02:00
duffyduckandClaude Opus 4.8 3cd7350160 feat(local-llm): B1b — lokale Tools (web_search via SearXNG, Timer, Spotify, Memory)
Das lokale Tier kann jetzt Werkzeuge nutzen — Wetter/News/Fakten laufen lokal
statt ueber Claudes langsamen Web-Fetch.

- SearXNG-Container (aria VM, aria-net): self-hosted Meta-Suche, keyless,
  JSON-API aktiviert (aria-data/searxng/settings.yml), Rate-Limiter aus.
  Brain-Env SEARXNG_URL.
- web_search-Tool in META_TOOLS + _dispatch_tool (_web_search fragt SearXNG,
  gibt Titel/Snippet/URL der Top-Treffer). Steht auch Claude zur Verfuegung.
- Lokaler Tool-Loop (_try_local_fast_lane): kuratiertes Set web_search /
  memory_search / trigger_timer / run_spotify; max 3 Runden, sonst → Claude.
  Break-/Escalate-Guard bleibt.
- Router: should_try_local schliesst nur noch CLAUDE-ONLY-Themen aus (Bild,
  Skill, Projekt, OAuth, Licht, Kalender/Mail). Wetter/Timer/News/Musik/Memory
  gehen jetzt lokal. Verifiziert (alle Testfaelle korrekt).
- Schlanker Local-Prompt mit Tool-Guidance ("nur nutzen wenn noetig, sonst
  Smalltalk direkt beantworten"). Skill-BAUEN bleibt Claude-only.

Deploy: ARIA-VM brain+bridge+searxng, Gamebox llm-adapter (tool-passthrough).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:54 +02:00
duffyduckandClaude Opus 4.8 a58aa5594d feat(local-llm): B1b-Plumbing — tools/tool_calls durch Adapter/Bridge/Brain-Client
Traegt OpenAI-Tool-Definitionen (tools) durch den ganzen lokalen Pfad und gibt
tool_calls zurueck:
- adapter.py: tools -> llama.cpp /v1/chat/completions (tool_choice=auto),
  message.tool_calls zurueck in llm_response.
- aria_bridge.py: _local_llm + /internal/local-llm reichen tools durch, geben
  tool_calls zurueck.
- local_llm.py: local_llm_chat akzeptiert tools, result enthaelt tool_calls.

Inert bis der Brain-Tool-Loop (naechster Schritt) tools uebergibt — Verhalten
unveraendert. Tool-Set + lokale Tool-Loop + Router-Anpassung folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:36:09 +02:00
duffyduckandClaude Opus 4.8 3a3c14fdc5 ux(diagnostic): klarere Beschreibungen fuer die Lokales-LLM-Schalter
Pro Schalter ein kurzer Hilfetext (AN/AUS-Bedeutung, Testmodus-Warnung),
deutlichere Labels, Status-Zeile mit Ampel (/🟡/🟢) + Hinweis auf aktuellen
B1a-Stand (lokal plaudert nur, Tools folgen in B1b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:32:12 +02:00
duffyduckandClaude Opus 4.8 1d4f23ada3 fix(brain): Gift-Waechter — Identity-Breaks nie persistieren (Kaskade unterbunden)
Wiederkehrender Identity-Bug: --system-prompt liefert die Persona korrekt (Proxy-
Test = "ICH BIN ARIA"), ABER ein einziger in der History gespeicherter Break
("ich bin nach wie vor Claude / die Persona ist erfunden") zieht bei schwachen
Folgeturns eine Kaskade nach sich — das Modell setzt seine eigene Ablehnung fort.
Das erste Cleanup verlangte "claude code" und liess deutsche Breaks durch.

Fix zweifach:
- prompts.py: looks_like_identity_break() — STARKE selbstreferenzielle Marker
  (ich-bin-Claude / erfundene Persona / diese-Session-injiziert / nicht-real-in-
  dieser). Bewusst NICHT das blosse "injizier"/"prompt injection" — das nutzt
  ARIA in Pentest-Antworten legitim (verifiziert: 0 False Positives).
- agent.py: nach dem Claude-Loop Break-Check; bei Break Retry (Nondeterminismus
  holt meist ARIA), sonst sichere ARIA-Fallback-Antwort — Break wird NIE
  persistiert. Auch die lokale Fast-Lane eskaliert bei Break auf Claude.
- clean_poisoned_turns.py: auf denselben starken Matcher umgestellt (der breite
  produzierte False Positives auf echte Security-Doku, im Dry-Run gesehen).

VM bereits bereinigt (je 2 Rest-Breaks aus conversation.jsonl + chat_backup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:25:45 +02:00
duffyduckandClaude Opus 4.8 b967999a5f feat(diagnostic): B1a-2 — Schalter fuer lokales LLM (Master/Nur-lokal/Tool-Umfang)
Neuer Settings-Block "Lokales LLM (schnelle Antworten)":
- Master-Checkbox "Lokales LLM nutzen" (aus = alles Claude)
- "Nur lokales LLM (kein Claude-Fallback)" — Eval-Haken
- "Tool-Umfang: Abgespeckt / Voll" — Voll deaktiviert (gated) + ⓘ mit VRAM-Erklaerung
- ⓘ-Haupt-Info erklaert Router/Latenz/Heim-Internet-Abhaengigkeit

server.js: GET/POST /api/local-llm-config <-> /shared/config/local_llm.json
(read/write, spiegelt runtime-config-Muster). Genau die Datei, die router.py
im Brain live liest. Onchange-Autosave, Status-Zeile, Laden bei ws.onopen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:07:33 +02:00
duffyduckandClaude Opus 4.8 71b3fc5d31 perf(router): lokales Fenster auf letzte 8 Turns cappen (Speed bei langer History)
Gemessen: lokaler Call mit 12-Turn-Fenster ~2,6s statt ~0,8s. Im Hauptchat (bis
50 Turns) wuerde volles Fenster das Prefill aufblaehen und den Speed-Vorteil
auffressen. Lokales Tier ist fuer kurze Plauder-Turns — letzte 8 Turns reichen.

B1a end-to-end verifiziert: plaudern→lokal (~0,8s warm), Tool/hart→Claude,
localOnly erzwingt lokal; Config live gelesen, Default aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:00:35 +02:00
duffyduckandClaude Opus 4.8 c8b7ea322e feat(router): B1a — lokale Fast-Lane (reden-only) mit Schaltern, gated (Default aus)
Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns → lokales Qwen
(schlanker Prompt, KEINE Tools) in <1 s; sonst Claude wie bisher.

- router.py: load_config() liest /shared/config/local_llm.json (enabled/localOnly/
  toolVariant; Default enabled=false → alles Claude). should_try_local() Heuristik
  (kurz + keine Tool-/Technik-Marker; localOnly erzwingt lokal). build_local_
  system_prompt() = schlank (IDENTITY_ANCHOR + Schnell-Modus + Awareness-Liste +
  <<ESCALATE>>-Regel, keine Tool-Schemas).
- agent.py: _try_local_fast_lane() — baut schlanken Prompt + Window, ruft
  local_llm_chat; leer/ESCALATE → None (→ Claude), sonst Antwort + persistiert.
  localOnly: kein Claude-Fallback (Eval), ehrliche Fehlermeldung bei Nichterreich.
  In chat() nach User-Turn gated aufgerufen.

Heuristik lokal verifiziert (alle Testfaelle korrekt). Gated off → laufendes
Verhalten unveraendert bis Master-Schalter an. Diagnostic-Toggles (B1a-2) +
lokale Tools (B1b) folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:55:29 +02:00
duffyduckandClaude Opus 4.8 f93dd58a68 docs: Verschieben-Button reboot-sicher via Platzierungs-Config + dummem Reconcile-Agent
Button = remote start/stop = braucht Agent pro Host, aber dumme Variante:
gpu_placement.json als Single Source of Truth, Agent reconciled nur (Mensch =
Scheduler). Loest die Reboot-Falle (Laufzeit-Move vs statische Config). Deploy:
Code ueberall via git, Config entscheidet Platzierung. Nach B0/B1; Fallback =
COMPOSE_PROFILES manuell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:45:56 +02:00
duffyduckandClaude Opus 4.8 c72d10cf58 docs: ENTSCHIEDEN — manuelle GPU-Platzierung (Compose-Profiles) + read-only Dashboard
Semi-Auto-Controller verworfen (Host wechselt selten). Pin via Compose-Profiles
pro Host (.env COMPOSE_PROFILES), Verschiebe-Regel up-neu + rm-alt gegen
Reboot-Wiederauferstehung, GPU-Dashboard aus Heartbeats (read-only). Skaliert
auf Gamebox3/4x3060 ohne Orchestrator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:41:22 +02:00
duffyduckandClaude Opus 4.8 291335250a docs: "Waechter"/Orchestrator gestaffelt (billiger Heartbeat vs teure Steuerung)
Idee Container-start/stop auf RVS-Hosts: billiger Teil (Registrierung/Heartbeat
-> Flotten-Sichtbarkeit + Router-Erreichbarkeit) lohnt bald; teurer Teil
(Agent+Controller+Placement = Mini-Nomad) erst bei groesserer Flotte, dann eher
Swarm/Nomad statt Eigenbau. Fuer 2 Gameboxen: statische Platzierung + llama-swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:36:46 +02:00
duffyduckandClaude Opus 4.8 eebafe8895 docs: Skalierung/Multi-GPU/Cluster + Diagnostic-VRAM-Info (Klarstellung: kein VRAM ueber RVS)
Roher VRAM ist nicht ueber RVS teilbar (Relay, nicht GPU-Bus). Echte Pfade:
mehr Karten/Box = VRAM-Pool (volles Arsenal), mehr GPU-Hosts = Modell-Server
ueber RVS (Cluster), llama-swap = geteilter Server im Host. Plus geplantes
Diagnostic-Info-Icon mit VRAM-Bedarf pro Ausbaustufe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:30:17 +02:00
duffyduckandClaude Opus 4.8 25a9b71482 docs: lokales LLM — Awareness (volle Liste) vs Authority (kuratierter Satz)
Klargestellt: lokales Modell ist ueber ALLES im Bilde (kurze Awareness-Liste im
System-Prompt -> gezieltes Escalieren), darf aber nur den sicheren Satz
ausfuehren. Harte Grenze ist Kontext/VRAM (volles Schema ~15-20K Tokens passt
nicht in 8K, 32K-Kontext sprengt die geteilte 12GB-3060), nicht Misstrauen.
Claude im RZ mit 200K-1M Kontext kann sich das ganze Arsenal leisten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:24:53 +02:00
duffyduckandClaude Opus 4.8 e6e87a8b20 docs: lokales LLM bekommt kuratierte Tool-Auswahl (Tool-Calling in B1 statt B3)
Qwen3 kann natives OpenAI-Tool-Calling; llama.cpp (--jinja) unterstuetzt es.
Lokales Tier bekommt einen kleinen, risikoarmen Start-Satz (Wetter, memory_search,
trigger_timer, Spotify, Licht); volles Arsenal bleibt bei Claude, Escalation-Netz
bleibt. Klein halten = Speed. Plan-Entscheidungen + Phasen entsprechend gezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:20:52 +02:00
duffyduckandClaude Opus 4.8 75b022a456 docs: B1-Router bekommt "Nur lokales LLM"-Modus (Eval-Checkbox, kein Claude-Fallback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:18:18 +02:00
duffyduckandClaude Opus 4.8 436759306e docs: B0.5-Wuensche festgehalten (Modell-Status/aktiviert + Testchat-Zeile in Diagnostic)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:12:10 +02:00
duffyduckandClaude Opus 4.8 45e63b6def feat(local-llm): B0 Consumer — Bridge-Relay + Brain-Client (spiegelt FLUX)
Brain → HTTP /internal/local-llm → Bridge → RVS → llm-adapter → llama.cpp,
1:1 nach dem FLUX-Roundtrip-Muster gebaut:
- Bridge: _pending_llm (requestId→Future), llm_response-Handler (setzt Future),
  _local_llm() (sendet llm_request, wartet mit 30s-Timeout), HTTP-Route
  POST /internal/local-llm ({messages, max_tokens?, temperature?, stop?}).
- Brain: local_llm.py mit local_llm_chat() — POSTet an die Bridge, gibt
  {ok, content, model?, elapsedMs?} zurueck, wirft nie (Aufrufer eskaliert
  bei ok=false auf Claude).

Provider-Kette bereits verifiziert (718ms). Als naechstes: Test brain→bridge→
gamebox end-to-end, dann B1 (Router-Heuristik + Escalation) und der
Diagnostic-Testchat/Status (B0.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:11:49 +02:00
duffyduckandClaude Opus 4.8 03e5c5f9a1 fix(local-llm): Qwen3-Thinking im Adapter aus (schnelles Tier grübelt nicht)
Qwen3 hat Thinking default AN -> verbraet Tokens im <think>-Block, liefert bei
kleinem max_tokens leeren content und ist ~3x langsamer (2,2s statt 0,7s im
Test). ARIAs lokales Tier soll fixe Antworten geben, nicht grübeln (grübeln =
harter Turn = Claude). Adapter setzt daher chat_template_kwargs
{enable_thinking:false}; abschaltbar via LLM_DISABLE_THINKING=false.

Verifiziert end-to-end (RVS->Adapter->llama->Qwen3): "Hallo! Ich bin bereit."
in 718ms Round-trip RZ<->Gamebox@home.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:05:51 +02:00
duffyduckandClaude Opus 4.8 b5ba54d05f feat(local-llm): B0 — GGUF Auto-Download via llama.cpp -hf (kein manuelles Ablegen)
Statt einer manuell abgelegten Datei zieht llama.cpp das Modell beim ersten
Start selbst von Hugging Face (-hf Qwen/Qwen3-8B-GGUF:Q4_K_M, offizielles
Repo verifiziert) und cached es unter xtts/models (persistent). Modell/Quant
via LLM_HF_REPO/LLM_HF_QUANT in der .env wechselbar, kein Code.

Diagnostic-Modellauswahl (on-demand laden/aktivieren mehrerer Modelle) als
Folge-Baustein B0.5 via llama-swap ins Plan-Doc aufgenommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:33:59 +02:00
duffyduckandClaude Opus 4.8 98c78af7ad feat(local-llm): B0 — Gamebox llama.cpp + RVS-Adapter (Provider-Seite)
Plan B, Phase B0 (Provider): lokales Qwen3-8B auf der Gamebox, angebunden
per RVS wie f5tts/whisper (kein IP-Pflegen, nur URL+Token).

- xtts/llm-adapter/: RVS-Client (spiegelt whisper-bridge: TLS+ws-Fallback,
  Reconnect-Backoff), nimmt llm_request, ruft llama.cpp /v1/chat/completions
  lokal, antwortet llm_response (korreliert per requestId). Nicht-streamend
  in B0; llm_partial fuer B2 reserviert.
- xtts/docker-compose.yml: neue Services `llama` (llama.cpp server-cuda,
  GGUF via ./models, OpenAI-API auf :8081) + `llm-adapter`.
- rvs/server.js: ALLOWED_TYPES += llm_request/llm_response/llm_partial.
- GGUF (mehrere GB) via .gitignore aus dem Repo; xtts/models/ mit .gitkeep.

Topologie-Hinweis: Gamebox@home, ARIA@RZ -> Bounce ueber Internet ist
unvermeidbar (Voice macht's schon so); Router faellt bei Nichterreichbarkeit
per Escalation auf Claude zurueck. Consumer-Seite (Bridge-Relay + Brain-
Client + Router) kommt als naechstes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:43:46 +02:00
duffyduckandClaude Opus 4.8 79dab81a77 docs: Plan B — lokaler LLM-Router (Gamebox Qwen3 via RVS) neben Claude
Design-Doc: schnelles lokales LLM fuer die einfachen ~80% der Turns (<1s,
gratis auf Gamebox-GPU), Claude nur fuer die schweren 20%. Anbindung ueber
den RVS-Token-Room wie TTS/STT (kein IP-Pflegen), Router mit Heuristik +
Escalation, schlanke Persona lokal, Phasen B0-B3. Basiert auf der
Latenz-Messung (CLI-Boden ~3,5s) aus dieser Session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:16:42 +02:00
duffyduckandClaude Opus 4.8 5cdd135169 feat(voice): leeres <voice></voice> = bewusst stumm (Steuerbefehl-Quittungen)
Bisher: tts_text = tts_text_preview or text — ein leeres <voice></voice> fiel
auf den vollen Text zurueck und wurde doch gesprochen. Jetzt: ein vorhandener
<voice>-Tag ist die EXPLIZITE TTS-Vorgabe (auch leer). Leeres <voice></voice>
= Bubble sichtbar, aber KEIN TTS. Nur ohne Tag Rueckfall auf vollen Text.

Motivation: Spotify-Steuerbefehle (next/pause/…) laufen jetzt ueber den
Fast-Path (<1s), aber die gesprochene Quittung klaute auf dem Handy den
Audio-Fokus -> Spotify duckte/pausierte und spielte (bei kurzem Text) nicht
weiter. Steuer-Skills koennen ihre Reply nun mit <voice></voice> stummschalten.
Generell nutzbar: ARIA kann jede Antwort bewusst stumm halten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:03:50 +02:00
duffyduckandClaude Opus 4.8 b8c20390d7 tool(brain): Cleanup-Script fuer vergiftete Hauptthread-Turns
Einmal-Tool zum Entfernen der aus-der-Rolle-Antworten ("das ist injiziert,
ich bin Claude Code"), die waehrend der --append-system-prompt-Phase in die
History geschrieben wurden und das Modell per Self-Grounding rueckwaerts aus
der Rolle zogen (siehe 2284c1a). Feld-agnostisch: bedient conversation.jsonl
(content/project_id) UND chat_backup.jsonl (text/projectId). Entfernt nur
Hauptthread-Assistant-Turns mit "claude code" + zweitem Ablehnungs-Marker
plus die ausloesende Frage; projekt-getaggte Turns (z.B. legitime Pentest-
Doku, die Injection als Arbeitsmaterial erwaehnt) bleiben unangetastet.
Dry-Run per Default, Backup vor --apply, idempotent.

Bereits auf der Dev-VM angewandt: je 5 Fehl-Dialoge aus beiden Dateien
entfernt, Brain neu gestartet, Hauptchat verifiziert (ARIA antwortet wieder
als ARIA mit vollem Memory-Zugriff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:04:54 +02:00
duffyduckandClaude Opus 4.8 2284c1a780 fix(proxy): ARIA-Persona als VOLLER System-Prompt-Replace (--system-prompt)
Der Self-Grounding-Ansatz (268636c) allein reichte nicht: Sonnet 5 in Claude
Code haelt seine eingebaute "You are Claude Code"-Identitaet hart und liest den
synthetischen <previous_response> als "fabricated". Ursache bleibt der Kanal:
--append-system-prompt HAENGT ARIAs Persona nur hinten an Claude Codes Basis-
Identitaet an — bei duennem Kontext (Hauptchat) gewinnt die Basis und wehrt die
Persona als Injection ab. Anhaengen ist gegen das Anti-Injection-Training des
Modells nicht durchsetzbar.

Fix: System-Prompt VOLL ersetzen statt anhaengen — sed-Patch in docker-compose
schaltet manager.js buildArgs von --append-system-prompt auf --system-prompt.
Damit ist die ARIA-Persona DIE Identitaet des Modells (kein Anhaengsel), und
Claude Codes dynamische Sektionen (cwd '/', git, platform) — die "ich bin ein
Coding-Agent"-Signale — fallen weg. Bestaetigt per claude-code-guide: die CLI
isoliert bei --system-prompt vollstaendig, die eingebaute Identitaet leakt nicht.

extractSystemPrompt liefert weiterhin einen selbsttragenden Prompt (Persona +
Anker + Memory + Skills + Tool-Block); er darf bei --system-prompt nie leer sein
(Brain schickt immer System-Message + Tools -> real nie leer). Der IDENTITY_SEED
aus 268636c bleibt als Defense-in-Depth drin. Kommentare in openai-to-cli.js,
routes.js, agent.py, prompts.py entsprechend nachgezogen.

Deploy: Proxy UND Brain neu (--force-recreate; routes.js/openai-to-cli.js werden
frisch in den Proxy-Container ge-cp't). Verifikation am Container:
docker exec aria-proxy sh -c 'grep -o "\-\-system-prompt" /usr/local/lib/*/claude-max-api-proxy/dist/subprocess/manager.js'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:44:02 +02:00
duffyduckandClaude Opus 4.8 268636c030 fix(brain): ARIA-Identitaet im Hauptchat — Self-Grounding im Konversations-Strom
Symptom: Im Hauptchat antwortete statt ARIA der Basis-Proxy-Claude ("I'm
Claude Code (Sonnet 5), I'm not adopting that persona") und flaggte GPS +
Tool-XML als Prompt-Injection. Im Projekt lief alles normal.

Ursache: Die Persona geht ueber --append-system-prompt zu — das HAENGT sie
nur hinten an Claude Codes eigene "You are Claude Code"-Basis-Identitaet an,
ersetzt sie nicht. Ob ARIA in der Rolle bleibt, entscheidet dann der
Konversations-Strom (stdin): reiche Projekt-Historie voller ARIA-
<previous_response>-Turns haelt die Rolle; der duenne Hauptchat-Verlauf
(bzw. der erste Turn eines neuen Projekts) nicht -> Basis-Identitaet gewinnt.
Der IDENTITY_ANCHOR (a0e8c23) ist inert, weil er im *angehaengten* Teil steht.

Fix: synthetischen ersten ARIA-Turn (IDENTITY_SEED) in ihrer eigenen Stimme
an den Anfang des Konversations-Stroms setzen. Das Modell setzt seine EIGENE
etablierte Stimme fort (Self-Grounding) — und weil es ein <previous_response>
ist und kein <system>-Tag, ist es kein Injection-Trigger. Rein ephemer, wird
nie in die Conversation persistiert. Gilt fuer alle Turns, deckt damit auch
die erste Frage eines frischen Projekts ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:37:30 +02:00
duffyduckandClaude Opus 4.8 de4d95a392 feat(diagnostic): Model-Tier-Liste aus /shared/config/models.json (editierbar ohne Code)
handleModels liest die kuratierte Tier-Liste jetzt pro Request aus
/shared/config/models.json statt sie hart zu codieren. Neue Tier-Namen oder
angepasste Beschreibungen sind damit eine reine Datei-Aenderung — kein
Code-Edit, kein Proxy-Neubau, kein Neustart (Datei bearbeiten → im Diagnostic
„↻ Aktualisieren").

- Fehlt/kaputt die Datei: eingebaute Defaults greifen und werden einmalig als
  models.json angelegt, damit es was zu editieren gibt.
- ids bleiben MODEL_MAP-kompatibel (extractModel), Validierung: nicht-leeres
  Array mit String-id, sonst Fallback auf Defaults.
- UI-Hinweis auf den Dateipfad ergaenzt.
- Load/Seed-Logik simuliert verifiziert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:17:38 +02:00
duffyduckandClaude Opus 4.8 44f4067834 feat(diagnostic): Sprachmodell per Tier-Dropdown waehlen (Opus/Sonnet/Haiku)
ARIA laeuft ueber das Claude-Max-Abo via CLI — waehlbar ist der Tier, nicht
eine feste Modellversion (die CLI nimmt automatisch das aktuelle Modell des
Tiers). Kein API-Key → keine echte Anthropic-Models-API; daher kuratierte Liste.

- proxy routes.js handleModels: kuratierte /v1/models mit tier/display_name/
  description (ids bleiben MODEL_MAP-kompatibel: claude-sonnet-4/opus-4/haiku-4).
- diagnostic server.js: neuer GET /api/models-list — holt die Liste vom Proxy
  (Frontend erreicht den Proxy nicht direkt).
- Frontend: Dropdown statt Freitext + „↻ Aktualisieren"-Button; markiert das
  aktive brainModel (get_model), zeigt Beschreibung, „Setzen" schreibt brainModel
  und weist auf Brain-Neustart hin. Freitext-Override bleibt unter „Erweitert".
  Liste wird bei WS-Connect automatisch geladen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:13:17 +02:00
35 changed files with 2575 additions and 98 deletions
+5 -1
View File
@@ -78,4 +78,8 @@ __pycache__/
.vscode/settings.json
.idea/
*.swp
*.swo
*.swo
# Lokale LLM-Modelle (Plan B) — GGUF/HF-Cache sind mehrere GB, nicht ins Repo
xtts/models/*
!xtts/models/.gitkeep
+54 -1
View File
@@ -10,7 +10,60 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
---
## [Unreleased] — 2026-07-10
## [Unreleased] — 2026-07-11
### Hinzugefügt
**Diagnostic + App — Projekte verstecken**
- Neues `hidden`-Flag pro Projekt (bleibt voll nutzbar, nur aus Listen ausgeblendet — unabhängig von `status`/`archived`); `PATCH /projects/{id} {hidden}`
- Diagnostic: 👁-Auge pro Projekt-Bubble (🙈 verstecken / 👁 dauerhaft sichtbar), Header-Toggle „Versteckte anzeigen (N)" blendet sie temporär gedimmt + „versteckt"-Badge ein — zum Ansehen/Auswählen ohne permanentes Enttarnen
- App (`ProjectsBrowser`): versteckte standardmäßig ausgeblendet (Mama sieht sie nicht), Auge pro Zeile + Toggle spiegeln das Diagnostic-Verhalten; geteilter `hidden`-Status übers Brain
**Diagnostic — Token-Ersparnis durch lokales LLM**
- `metrics.jsonl` trägt jetzt `source` (claude | local | fast-path); lokale Calls nutzen echte `usage`-Tokens vom Adapter, Fast-Path = 0 Prompt-Tokens (`by_source`-Aggregation, rückwärts-kompatibel)
- Neue Card „Lokales LLM & Claude-Ersparnis": pro Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) + lokale Token-Last (eigene HW, kein Quota)
**Spotify-Skill — von ARIA selbst geschärft**
- Geräte-Transfer startet die Wiedergabe direkt mit (`play=true`) statt nur zu übertragen, inkl. Verifikation (`is_playing`-Check + expliziter Play-Fallback), Fuzzy-Gerätenamen und sauberen Exit-Codes; neue semantische Actions `play_on_device`/`search_and_play`/`playlist_play`/`queue_add`
### Behoben
- **Spotify-Resume (App):** nach einem Voice-Befehl blieb Spotify auf dem Handy pausiert. Statt des auf manchen Geräten (OnePlus) flakigen Audio-Focus-Nudge jetzt ein echter `KEYCODE_MEDIA_PLAY`-KeyEvent an die aktive MediaSession — gegated: nur wenn vor dem Dialog Musik lief (`isMusicActive`). Deterministisch, geräteunabhängig
- **TTS-Zahlen:** freistehende Ganzzahlen werden jetzt tag-unabhängig ausgeschrieben („23°C" → „dreiundzwanzig Grad Celsius", „100%" → „einhundert Prozent"). Regression, seit das lokale LLM (bewusst ohne `<voice>`-Tag) leichte Turns übernahm; neuer vollständiger Zahl→Wort-Konverter (0…999999) am Ende von `clean_text_for_tts`, lange Ziffernfolgen (IDs) bleiben Ziffern
- **„ARIA denkt" hängt:** Indikator + Abbrechen blieben stehen, obwohl der Turn laut Diagnostic fertig war. Die App räumt den kontext-scoped Indikator jetzt beim Eintreffen der Antwort selbst; die Bridge sendet zusätzlich ein `idle` für die Request-`projectId`, falls der Turn umgeroutet wurde (thinking ging mit Request-, idle mit Turn-`projectId`)
- **Lokale Tool-Fehler:** Action-Skills, die bei Exit 0 einen Fehlschlag nur im stdout-Text melden (Spotify: „Fehler beim Übertragen", „Gerät nicht gefunden"), eskalieren jetzt generisch an Claude statt vom lokalen LLM vorgelesen zu werden (Info-Tools wie web_search ausgenommen)
---
## [0.2.0.4 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling")
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der Gamebox-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
### Hinzugefügt
**Lokales LLM (Brain + Bridge + Adapter)**
- Router (B1a): Heuristik + `<<ESCALATE>>`-Selbstabbruch entscheidet pro Turn lokal vs. Claude; schlanker System-Prompt mit demselben `IDENTITY_ANCHOR` wie Claude (Rolle hält), nur letzte 8 Turns (Speed)
- Lokale Tool-Fast-Lane (B1b): kuratierte Tools — `web_search` (self-hosted **SearXNG**, local-only), `memory_search`, `trigger_timer`, Spotify; Eskalation bei Tool-Fehler statt Raten
- Consumer-Kette gespiegelt zu FLUX: Brain → Bridge `/internal/local-llm` → RVS → `llm-adapter` → llama.cpp; `enable_thinking:false` (Qwen wickelte sonst die ganze Antwort in `<think>`)
- **B0.5:** llama-swap (Hot-Swap der Modelle on-demand) + Modellauswahl-Dropdown + Live-Lade-Status (loading/ready + Download-Hinweis) in Diagnostic
- **SearXNG** als 6. Container auf der ARIA-VM (keyless Meta-Suche, JSON-API)
**Quell-Badge (local / claude / fast-path)**
- Diagnostic: immer an den ARIA-Bubbles
- App: optionaler Schalter in den Einstellungen, pro Gerät gemerkt, default aus („ich will's, meine Mama nicht")
**TTS — System-Flag `speak` (ja/nein) pro Antwort**
- Die Quelle entscheidet übers Vorlesen (Fast-Path/Steuerbefehl = stumm, ARIA-Antwort = vorlesen), robust statt des fragilen leeren `<voice></voice>`-Hacks der beim Skill-Rebuild verloren ging
### Behoben / Geändert
- **Identität (Hauptchat):** Proxy nutzt jetzt `--system-prompt` (voller Replace) statt `--append-system-prompt` — die Claude-Code-Basis-Identität leakt nicht mehr in den Hauptchat (ARIA antwortete dort als „Claude Code" bzw. deutete die Persona als Injection). Dazu `IDENTITY_SEED` (synthetischer Grounding-Turn) + Gift-Wächter (Identity-Breaks werden nie in die History persistiert, Retry+Fallback) + Cleanup-Script gegen bereits vergiftete Turns
- **Datenschutz (kritisch):** harte Diskretions-Regel im `IDENTITY_ANCHOR` — ARIA kennt intime/private Details, gibt sie aber **NIE ungefragt** preis (nicht in Vorstellungen, „was weißt du über mich", Zusammenfassungen, Triggern); nur auf konkrete Nachfrage, knapp. Bereits ausgeplauderte Turns bereinigt. Lokales Tier eskaliert Personen-/Beziehungs-/Gedächtnisfragen an Claude (kennt das Gedächtnis + antwortet diskret)
- **Lokale Antwort nicht in `<voice>`** wickeln (Qwen imitierte den Tag aus dem Kontext → Anzeige war leer); **generische** Tool-Fehler-Eskalation statt per-Skill-Router-Hardcode (Router muss nicht wissen, welche Skills „schwer" sind)
---
## [0.2.0.3] — 2026-07-10
### Hinzugefügt
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20003
versionName "0.2.0.3"
versionCode 20006
versionName "0.2.0.6"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -5,7 +5,9 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
@@ -183,6 +185,50 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true)
}
/** Zuverlaessiger Spotify-Resume: einen echten MEDIA_PLAY-Tastendruck an die
* aktive MediaSession schicken — exakt das Signal der Play-Taste am
* Bluetooth-Kopfhoerer. Anders als nudgeMediaResume (Focus-Stack-Trick,
* auf manchen OEMs/Spotify-Versionen unzuverlaessig) spricht das Spotifys
* MediaSession DIREKT an und startet die Wiedergabe deterministisch wieder.
*
* Wir senden bewusst KEYCODE_MEDIA_PLAY (nicht PLAY_PAUSE) — das kann nur
* starten, nie pausieren. Aufrufer muss also selbst gaten (nur senden wenn
* vor dem Gespraech wirklich Musik lief, siehe isMusicActive()).
*/
@ReactMethod
fun dispatchMediaPlay(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
try {
val now = SystemClock.uptimeMillis()
val down = KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
val up = KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
am.dispatchMediaKeyEvent(down)
am.dispatchMediaKeyEvent(up)
Log.i(TAG, "dispatchMediaPlay: KEYCODE_MEDIA_PLAY an aktive MediaSession gesendet")
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "dispatchMediaPlay failed: ${e.message}")
promise.resolve(false)
}
}
/** Ob gerade Musik/Media aktiv abgespielt wird (AudioManager.isMusicActive).
* Der Aufrufer merkt sich das VOR dem Focus-Grab, um am Dialog-Ende zu
* entscheiden ob ein dispatchMediaPlay-Resume ueberhaupt gewuenscht ist. */
@ReactMethod
fun isMusicActive(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
promise.resolve(am.isMusicActive)
}
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.0.3",
"version": "0.2.0.6",
"private": true,
"scripts": {
"android": "react-native run-android",
+61 -6
View File
@@ -75,6 +75,9 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
const [showHidden, setShowHidden] = useState(false);
// Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt
// wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes
@@ -158,6 +161,12 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
]);
}, [load]);
const toggleHidden = useCallback((p: Project) => {
brainApi.setProjectHidden(p.id, !p.hidden)
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [load]);
const archiveProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" archivieren?`,
'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.',
@@ -176,11 +185,12 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const renderItem = ({ item }: { item: Project }) => {
const isActive = item.id === activeId;
const dot = _statusDot(item.id);
const hidden = !!item.hidden;
return (
<TouchableOpacity
onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive]}
style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
@@ -188,6 +198,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)}
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>}
</View>
@@ -199,10 +210,22 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
{dot.label ? ` · ${dot.label}` : ''}
</Text>
</View>
{/* Auge: verstecken (🙈) / wieder sichtbar (👁). Eigener Touch, damit
der Tap NICHT das Projekt wechselt. */}
<TouchableOpacity
onPress={() => toggleHidden(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={s.eyeBtn}
>
<Text style={s.eyeIcon}>{hidden ? '👁' : '🙈'}</Text>
</TouchableOpacity>
</TouchableOpacity>
);
};
const hiddenCount = projects.filter(p => p.hidden).length;
const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden);
const body = (
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
{/* Header */}
@@ -243,6 +266,17 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
);
})()}
{/* Versteckte-Toggle — nur wenn es welche gibt (oder gerade eingeblendet) */}
{(hiddenCount > 0 || showHidden) && (
<TouchableOpacity onPress={() => setShowHidden(v => !v)} style={s.hiddenToggle}>
<Text style={s.hiddenToggleText}>
{showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ` (${hiddenCount})` : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ` (${hiddenCount})` : ''}`}
</Text>
</TouchableOpacity>
)}
{loading ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" />
@@ -251,14 +285,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<Text style={s.errorText}> {err}</Text>
) : (
<FlatList
data={projects}
data={visibleProjects}
keyExtractor={p => p.id}
renderItem={renderItem}
ListEmptyComponent={
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
Lass uns ein Projekt 'XY' anlegen".
</Text>
projects.length > 0 ? (
<Text style={s.emptyText}>
Alle {hiddenCount} Projekte sind versteckt.{'\n'}
Tipp 👁 Versteckte anzeigen".
</Text>
) : (
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
„Lass uns ein Projekt 'XY' anlegen".
</Text>
)
}
/>
)}
@@ -365,6 +406,8 @@ const s = StyleSheet.create({
headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' },
headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
@@ -375,6 +418,18 @@ const s = StyleSheet.create({
borderLeftWidth: 3,
borderLeftColor: '#34C759',
},
rowHidden: { opacity: 0.55 },
eyeBtn: { paddingHorizontal: 8, paddingVertical: 6, marginLeft: 6 },
eyeIcon: { fontSize: 18 },
hiddenBadge: { color: '#B392F0', fontSize: 10, fontWeight: '700',
backgroundColor: 'rgba(179,146,240,0.15)', paddingHorizontal: 6,
paddingVertical: 2, borderRadius: 4 },
hiddenToggle: {
paddingHorizontal: 16, paddingVertical: 10,
borderBottomWidth: 1, borderColor: '#1E1E2E',
backgroundColor: '#0D0D18',
},
hiddenToggleText: { color: '#B392F0', fontSize: 12, fontWeight: '600' },
rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' },
rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 },
rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 },
+34
View File
@@ -73,6 +73,9 @@ interface ChatMessage {
/** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
* Projekt-Bloecken zu gruppieren (auf/einklappbar). */
projectId?: string;
/** Welcher Backend die Antwort erzeugt hat: 'local' | 'claude' | 'fast-path'.
* Fuer den optionalen Quell-Badge (Einstellung aria_show_source, default aus). */
answeredBy?: string;
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
@@ -324,6 +327,8 @@ const ChatScreen: React.FC = () => {
// App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst
// jeden Hint mit. Toggle in Settings.
const [showSystemHints, setShowSystemHints] = useState(false);
// Quell-Badge (local/claude/fast-path) an ARIA-Bubbles — pro Geraet, default AUS.
const [showSource, setShowSource] = useState(false);
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
const localXttsVoiceRef = useRef<string>('');
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
@@ -546,6 +551,8 @@ const ChatScreen: React.FC = () => {
setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints');
setShowSystemHints(hints === 'true'); // default false
const src = await AsyncStorage.getItem('aria_show_source');
setShowSource(src === 'true'); // default false (Mama sieht keine Badges)
};
loadSettings();
const interval = setInterval(loadSettings, 2000);
@@ -815,6 +822,7 @@ const ChatScreen: React.FC = () => {
attachments: attachments.length ? attachments : undefined,
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
projectId: typeof m.project_id === 'string' ? m.project_id : '',
answeredBy: typeof m.answeredBy === 'string' ? m.answeredBy : '',
...(cmid && { clientMsgId: cmid }),
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
@@ -1151,6 +1159,7 @@ const ChatScreen: React.FC = () => {
messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
answeredBy: ((message.payload as any).answeredBy as string) || '',
};
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
@@ -1163,6 +1172,21 @@ const ChatScreen: React.FC = () => {
});
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
clearStuckWatchdog();
// Thinking-Indikator fuer DIESEN Kontext raeumen. Die Antwort ist der
// definitive Beweis, dass der Turn fertig ist — unabhaengig davon, ob
// das agent_activity 'idle' der Bridge ankam (es kann mit abweichender
// projectId gesendet oder wegdedupt worden sein → "ARIA denkt" blieb
// sonst haengen). Zusaetzlich global raeumen, falls der sichtbare
// Kontext ueber den Legacy-Indikator lief.
{
const ansPid = ((message.payload as any).projectId as string) || '';
setAgentActivityByCtx(prev =>
prev[ansPid] && prev[ansPid].activity !== 'idle'
? { ...prev, [ansPid]: { activity: 'idle', tool: '' } }
: prev);
setAgentActivity(cur =>
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' });
}
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
@@ -2411,6 +2435,16 @@ const ChatScreen: React.FC = () => {
) : null}
<View style={styles.statusRow}>
<Text style={styles.timestamp}>{time}</Text>
{!isUser && showSource && item.answeredBy ? (() => {
const ab = item.answeredBy as string;
const map: { [k: string]: { t: string; c: string } } = {
local: { t: '⚡ lokal', c: '#34C759' },
claude: { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ fast', c: '#AF7BFF' },
};
const b = map[ab] || { t: ab, c: '#8A8AA0' };
return <Text style={{ fontSize: 9, fontWeight: 'bold', color: b.c, marginLeft: 6 }}>{b.t}</Text>;
})() : null}
{item.text.length > 0 ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
+25
View File
@@ -175,6 +175,7 @@ const SettingsScreen: React.FC = () => {
const [bgGpsEnabled, setBgGpsEnabled] = useState(false);
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [showSource, setShowSource] = useState(false); // Quell-Badge, Default aus
const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -261,6 +262,9 @@ const SettingsScreen: React.FC = () => {
// Default ist aus — nur explicit 'true' aktiviert
setShowSystemHints(saved === 'true');
});
AsyncStorage.getItem('aria_show_source').then(saved => {
setShowSource(saved === 'true'); // Default aus
});
// gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
@@ -857,6 +861,11 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
}, []);
const handleShowSourceToggle = useCallback((value: boolean) => {
setShowSource(value);
AsyncStorage.setItem('aria_show_source', String(value)).catch(() => {});
}, []);
// --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => {
@@ -1580,6 +1589,22 @@ const SettingsScreen: React.FC = () => {
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
/>
</View>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Antwort-Quelle anzeigen</Text>
<Text style={styles.toggleHint}>
Kleiner Badge an ARIAs Bubbles: ob die Antwort vom schnellen
lokalen Modell, von Claude oder per Direkt-Befehl kam. Nur fuer
dich interessant (Technik) — standardmaessig aus.
</Text>
</View>
<Switch
value={showSource}
onValueChange={handleShowSourceToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={showSource ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Hintergrund-Modus === */}
+61 -9
View File
@@ -44,6 +44,11 @@ const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
// Zuverlaessiger Spotify-Resume via echtem MEDIA_PLAY-KeyEvent (statt
// Focus-Stack-Nudge). isMusicActive() zum Gaten: nur resumen wenn vor
// dem Gespraech wirklich Musik lief.
dispatchMediaPlay?: () => Promise<boolean>;
isMusicActive?: () => Promise<boolean>;
};
PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
@@ -275,6 +280,13 @@ class AudioService {
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false;
// Lief unmittelbar VOR dem Focus-Grab (Wake-Word/Aufnahme) Musik? Wird beim
// Betreten des Dialogs gemerkt (latch: nur auf true), damit wir am Dialog-Ende
// NUR dann Spotify per MEDIA_PLAY-KeyEvent zuverlaessig resumen, wenn vorher
// wirklich etwas lief. Verhindert, dass wir bei Stille versehentlich Musik
// starten. Wird nach dem Resume-Dispatch wieder auf false gesetzt.
private _mediaWasActiveAtAcquire: boolean = false;
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
@@ -450,15 +462,29 @@ class AudioService {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'AudioFocus.release() now')).catch(()=>{});
AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
// 50ms Delay damit das Abandon erst durch ist.
setTimeout(() => {
// Spotify-Resume: NUR wenn vor dem Gespraech wirklich Musik lief. Dann
// einen echten MEDIA_PLAY-KeyEvent an die aktive MediaSession schicken
// (wie die Play-Taste am Kopfhoerer) — das resumt Spotify zuverlaessig,
// im Gegensatz zum flakigen Focus-Stack-Nudge, der auf manchen Geraeten
// (OnePlus) nach Auto-Loss nicht griff. 120ms Delay, damit das Abandon
// sicher durch ist, bevor der Play-Key kommt.
const shouldResume = this._mediaWasActiveAtAcquire;
this._mediaWasActiveAtAcquire = false;
if (shouldResume) {
setTimeout(() => {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'dispatchMediaPlay() now (Musik lief vor Dialog → Resume)')).catch(()=>{});
if (AudioFocus?.dispatchMediaPlay) {
AudioFocus.dispatchMediaPlay().catch(() => {});
} else {
// Fallback fuer alte Native-Builds ohne dispatchMediaPlay
AudioFocus?.nudgeMediaResume().catch(() => {});
}
}, 120);
} else {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'nudgeMediaResume() now (50ms after release)')).catch(()=>{});
AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50);
'kein Resume (vor Dialog lief keine Musik)')).catch(()=>{});
}
}, this.FOCUS_RELEASE_DELAY_MS);
}
@@ -469,6 +495,20 @@ class AudioService {
}
}
/** Merkt sich (latch: nur auf true), ob GERADE Musik laeuft — VOR einem
* Focus-Grab aufrufen. Am Dialog-Ende entscheidet die Flag, ob wir Spotify
* aktiv per MEDIA_PLAY resumen. Awaitet bewusst isMusicActive bevor der
* Focus-Request die Wiedergabe pausiert (sonst laese man schon 'false'). */
private async _captureMediaActive(): Promise<void> {
try {
const active = await AudioFocus?.isMusicActive?.();
if (active) {
this._mediaWasActiveAtAcquire = true;
console.log('[Audio] Musik lief vor Focus-Grab → Resume am Dialog-Ende gemerkt');
}
} catch {}
}
/** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
@@ -476,7 +516,11 @@ class AudioService {
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {});
// Erst pruefen ob Musik laeuft, DANN ducken (requestDuck wuerde sie sonst
// schon pausieren bevor wir es messen koennen).
this._captureMediaActive().finally(() => {
AudioFocus?.requestDuck().catch(() => {});
});
}
/** Conversation-Mode endet → Focus darf wieder freigegeben werden
@@ -493,6 +537,8 @@ class AudioService {
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
// Barge-In → User spricht gleich weiter, Spotify NICHT resumen.
this._mediaWasActiveAtAcquire = false;
this.stopPlayback();
}
@@ -503,6 +549,8 @@ class AudioService {
pauseForCall(reason: string = ''): void {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false;
// Anruf → Spotify bleibt aus, kein Auto-Resume beim spaeteren Release.
this._mediaWasActiveAtAcquire = false;
this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
@@ -796,6 +844,8 @@ class AudioService {
this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
// Vorher merken ob Musik lief, damit wir sie danach resumen koennen.
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
@@ -1043,6 +1093,8 @@ class AudioService {
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
+11 -2
View File
@@ -157,6 +157,7 @@ export interface Project {
name: string;
description: string;
status: 'active' | 'ended' | 'archived';
hidden?: boolean; // aus Listen ausgeblendet (bleibt nutzbar)
created_at: number;
updated_at: number;
last_activity_at: number;
@@ -593,14 +594,22 @@ export const brainApi = {
});
},
/** Projekt-Metadaten patchen (name / description). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description'>>): Promise<Project> {
/** Projekt-Metadaten patchen (name / description / hidden). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: patch,
});
},
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: { hidden },
});
},
/** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
* Wird fuer Status-Dots im Drawer periodisch gepollt. */
+309 -15
View File
@@ -21,13 +21,17 @@ import logging
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional
from conversation import Conversation, Turn
from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt
from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break
from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod
import metrics
from local_llm import local_llm_chat
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
@@ -35,6 +39,8 @@ import oauth as oauth_mod
import projects as projects_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# 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
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
@@ -64,6 +70,65 @@ def _load_flux_config() -> dict:
logger = logging.getLogger(__name__)
def _web_search(query: str, max_results: int = 5) -> str:
"""Fragt die self-hosted SearXNG-Instanz (JSON-API) und gibt die Top-Treffer
als kompakten Text zurueck (Titel + Snippet + URL). Nie werfen — Fehler als
Text-Resultat, damit der Tool-Loop weitermachen kann."""
try:
params = urllib.parse.urlencode({
"q": query, "format": "json", "language": "de", "safesearch": "0",
})
req = urllib.request.Request(
f"{SEARXNG_URL}/search?{params}",
headers={"User-Agent": "ARIA/1.0", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.warning("web_search (SearXNG) fehlgeschlagen: %s", exc)
return f"FEHLER: Websuche nicht verfuegbar ({exc})."
results = (data.get("results") or [])[:max_results]
if not results:
answers = data.get("answers") or []
if answers:
return "Direkte Antwort: " + " | ".join(str(a) for a in answers[:3])
return f"Keine Web-Treffer fuer '{query}'."
lines = [f"{len(results)} Web-Treffer fuer '{query}':"]
for i, r in enumerate(results, 1):
title = (r.get("title") or "").strip()
url = (r.get("url") or "").strip()
snippet = (r.get("content") or "").strip()
lines.append(f"\n{i}. {title}\n {snippet[:300]}\n Quelle: {url}")
return "\n".join(lines)
# web_search ist bewusst LOCAL-ONLY (nicht in META_TOOLS): Claude hat seine
# eigenen, staerkeren Web-Tools (WebSearch + WebFetch/Voll-Seiten-lesen + Bash).
# SearXNG ist fuer das lokale Tier, das sonst gar keinen Netz-Zugriff haette.
# _dispatch_tool behandelt "web_search" trotzdem generisch (Name-Match).
WEB_SEARCH_TOOL = {
"type": "function",
"function": {
"name": "web_search",
"description": (
"Durchsuche das Web (via SearXNG) nach AKTUELLEN, nachschlagbaren "
"Infos: Wetter, News, Fakten, Oeffnungszeiten, Preise, Definitionen. "
"Nutze das, wenn die Antwort aktuelles Wissen braucht, das nicht im "
"Gedaechtnis steht. Praezise Suchanfrage (Suchmaschinen-Stil). "
"Ergebnis = Titel + Snippet + URL; fasse daraus knapp zusammen."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchanfrage (praezise)"},
"max_results": {"type": "integer", "description": "Anzahl Treffer (Default 5, max 10)"},
},
"required": ["query"],
},
},
}
# Meta-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [
{
@@ -86,6 +151,17 @@ META_TOOLS = [
"Stefan sich alle 60min manuell neu einloggen.\n"
" - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
"ueber `config_schema` deklarieren, NICHT hardcoden.\n\n"
"SEMANTISCH BAUEN (wichtig fuer Zuverlaessigkeit!): Ein Skill soll "
"KLARE Operationen als args anbieten, NICHT die rohe API "
"durchreichen. Schlecht: args = {path, method, body} — dann muss das "
"aufrufende LLM die ganze fremde API selbst kennen und baut bei "
"komplexen Faellen (z.B. Geraete-Transfer) falsche Calls. Gut: args "
"= {action: 'play'|'pause'|'next'|'play_on_device', device_name?: str} "
"— der Skill-Code loest intern auf (Geraet-Name → ID, richtiger "
"Endpoint) und kapselt die API. Faustregel: Was das LLM sonst RATEN "
"muesste (Endpunkte, IDs, Payload-Struktur), gehoert INS Skill. Das "
"`args`-Schema ist die Bedienungsanleitung, die das LLM sieht — mach "
"sie semantisch und selbsterklaerend.\n\n"
"HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
@@ -134,17 +210,28 @@ META_TOOLS = [
"description": (
"OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
"pause/next, Rollade hoch/runter etc.) eine Liste von "
"[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen "
"match (anchored Regex, case-insensitive) matched, ruft das "
"Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — "
"ohne Claude (~5s Latenz gespart). Match wird gegen den "
"normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; "
"schreibe Patterns mit ^...$ damit nur exakte Befehle matchen "
"und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer "
"Skills mit kreativem Output / parametrisierter Logik — die "
"brauchen Claude. Beispiel: "
"[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"},"
"\"reply\":\"Spotify: pausiert ⏸\"}]"
"[{match, args, reply}]. Bei match (anchored Regex, "
"case-insensitive, gegen den normalisierten Text: lowercase, "
"Endsatzzeichen weg) ruft das Brain run_skill(name, args) DIREKT "
"auf und gibt reply zurueck — ohne LLM (instant).\n\n"
"ROBUST SCHREIBEN (wichtig!):\n"
"- Immer ^...$ (nur ganze Befehle, keine Teilstrings).\n"
"- **Wortstellung + Synonyme + Fuellwoerter abdecken** in EINEM "
"Pattern via Alternativen und optionalen Gruppen. Nicht nur die "
"eine Formulierung! Beispiel Pause: "
"`^(spotify |musik )?(pause|pausier(e|en)?|stop|stopp|halt|"
"anhalten)( mal| bitte| spotify| die musik)?$` faengt 'pause', "
"'pause spotify', 'stopp mal', 'musik anhalten' … alle ab.\n"
"- Optionale Fuellwoerter mit `( bitte| mal| doch)?` zulassen, "
"Artikel/Objekte mit `( das lied| den song| die musik)?`.\n\n"
"Aber KEIN Zwang zur Vollstaendigkeit: Fast-Paths sind nur fuer "
"die HAEUFIGSTEN exakten Befehle (instant). Seltene/ungewoehnliche "
"Formulierungen faengt das lokale LLM ohnehin schnell ab (es hat "
"das Tool) — also lieber ein paar solide, breite Patterns als 30 "
"enge. NICHT fuer Skills mit kreativem/parametrisiertem Output. "
"Beispiel: [{\"match\":\"^(spotify |musik )?(pause|stop|stopp)"
"( mal| bitte)?$\",\"args\":{\"path\":\"/v1/me/player/pause\","
"\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]"
),
},
},
@@ -1053,13 +1140,166 @@ class Agent:
return reply
return None
# ── Fast-Lane: lokales schnelles LLM (Plan B, B1a/B1b) ──
#
# Zwischen Skill-Fast-Path und Claude-Loop: einfache Turns beantwortet das
# lokale Qwen in <1 s. Seit B1b mit kuratierten Tools (web_search,
# memory_search, trigger_timer, Spotify). Gated ueber
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
# menschenlesbaren Fehler auf stdout aus und beenden sich TROTZDEM mit Exit 0
# (z.B. Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Der
# harte FEHLER-Prefix (Exit != 0) faengt das nicht. Fuer run_*-Skills werten
# wir daher auch solche Marker als Fehlschlag → eskalieren an Claude, der
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
_SOFT_FAIL_MARKERS = (
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
"konnte nicht", "keine verbindung", "nicht verfuegbar",
"exception", "traceback",
)
def _local_tool_failed(self, tool_name: str, tresult: str) -> bool:
"""True wenn ein lokaler Tool-Call als gescheitert gilt (→ Claude)."""
s = (tresult or "").strip()
if not s:
return False
if s.startswith("FEHLER"): # harter Exit-Code-Fehler
return True
if tool_name.startswith("run_"): # Action-Skill: auch weiche Fehler
low = s.lower()
return any(mk in low for mk in self._SOFT_FAIL_MARKERS)
return False
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
# Bewusst klein (Speed + Sicherheit); alles andere → Claude.
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer"}
def _build_local_tools(self) -> list:
tools = [WEB_SEARCH_TOOL]
tools += [t for t in META_TOOLS
if t.get("function", {}).get("name") in self._LOCAL_TOOL_NAMES]
for s in skills_mod.list_skills(active_only=False):
if s.get("name") == "spotify" and s.get("active", True):
tools.append(_skill_to_tool(s))
break
return tools
def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config()
if not router_mod.should_try_local(user_message, cfg):
return None
local_only = bool(cfg.get("localOnly"))
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
tools = self._build_local_tools() # B1b: kuratierte Tools
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools))
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
# wuerde das Prefill aufblaehen).
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window]
# Tool-Loop: lokales Modell darf web_search/memory_search/trigger_timer/
# Spotify aufrufen. Ergebnisse zurueck, bis es final (ohne tool_calls) antwortet.
final = ""
for _ in range(self._LOCAL_TOOL_ITERATIONS):
res = local_llm_chat(messages, max_tokens=500, temperature=0.5,
tools=tools, model=local_model)
if not res.get("ok"):
logger.info("[router] lokal fehlgeschlagen (%s) — %s", res.get("error"),
"kein Fallback (localOnly)" if local_only else "→ Claude")
if local_only:
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
return None
# Metric: dieser lokale Call (echte usage-Tokens wenn der Adapter sie
# liefert). Erfasst pro Tool-Runde — mehrere Runden = mehrere Calls.
try:
metrics.log_local_call(res.get("model") or local_model, messages,
res.get("content") or "", res.get("usage"))
except Exception:
pass
tcs = res.get("tool_calls")
if tcs:
messages.append({"role": "assistant",
"content": res.get("content") or "",
"tool_calls": tcs})
had_error = False
for tc in tcs:
fn = tc.get("function") or {}
tname = fn.get("name") or ""
try:
targs = json.loads(fn.get("arguments") or "{}")
except Exception:
targs = {}
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys()))
tresult = self._dispatch_tool(tname, targs)
if self._local_tool_failed(tname, tresult):
had_error = True
messages.append({"role": "tool",
"tool_call_id": tc.get("id") or "",
"name": tname,
"content": (tresult or "")[:6000]})
# GENERAL (kein per-Skill-Code): scheitert ein Tool-Call, macht
# das grosse Modell weiter — es baut komplexe/rohe API-Calls
# zuverlaessiger und behandelt Fehler besser. So muss der Router
# NICHT wissen, welche Skill-Aufrufe "schwer" sind; das lokale
# Tier probiert, und bei Fehler uebernimmt Claude.
if had_error and not local_only:
logger.info("[router] lokaler Tool-Fehler → Claude uebernimmt")
return None
continue # naechste Runde mit Tool-Ergebnissen
final = (res.get("content") or "").strip()
break
else:
logger.info("[router] lokal Tool-Loop-Limit → %s",
"Fallback (localOnly)" if local_only else "Claude")
if not local_only:
return None
final = final or "[Lokales LLM: Tool-Loop-Limit erreicht.]"
# Sicherheitsnetz: das lokale Modell wickelt seine Antwort manchmal
# faelschlich komplett in <voice>...</voice> (aus dem Kontext imitiert).
# Das wuerde die Anzeige leeren (Display strippt <voice>). Tags raus —
# der lokale Reply ist kurz, Plain-Text dient Anzeige UND TTS.
final = re.sub(r"</?voice>", "", final).strip()
if local_only:
final = final.replace(router_mod.ESCALATE_MARKER, "").strip()
if not final:
return "[Lokales LLM lieferte keine Antwort.]"
logger.info("[router] lokal (localOnly) beantwortet")
self.conversation.add("assistant", final, project_id=active_project_id)
return final
# Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein
# Identity-Break → Claude uebernehmen.
if (not final or router_mod.ESCALATE_MARKER in final
or looks_like_identity_break(final)):
logger.info("[router] lokal eskaliert → Claude")
return None
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
self.conversation.add("assistant", final, project_id=active_project_id)
return final
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
def chat(self, user_message: str, source: str = "",
project_id: Optional[str] = None,
pending_queue: Optional[list[str]] = None) -> str:
pending_queue: Optional[list[str]] = None) -> tuple:
"""Verarbeitet eine User-Nachricht — pro Request project_id explizit
angegeben (leer = Hauptchat). Kein globaler active_project-State mehr —
so laufen parallele /chat-Requests fuer verschiedene Projekte echt
@@ -1093,7 +1333,14 @@ class Agent:
self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
return fast_reply
# Metric: Fast-Path spart einen ganzen Claude-Call zum Nulltarif.
try:
metrics.log_fast_path(fast_reply)
except Exception:
pass
# Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
return fast_reply, "fast-path", False
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source,
@@ -1101,6 +1348,14 @@ class Agent:
if active_project_id:
projects_mod.touch_project(active_project_id)
# 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
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
local_reply = self._try_local_fast_lane(user_message, active_project_id)
if local_reply is not None:
return local_reply, "local", True # ARIA-Antwort → vorlesen ok
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -1190,6 +1445,13 @@ class Agent:
f"weiterfuehren': project_enter aufrufen."
)
messages = [ProxyMessage(role="system", content=system_prompt)]
# Identitaets-Grounding IM Konversations-Strom (Defense-in-Depth neben
# dem vollen System-Prompt-Replace via --system-prompt). Ein
# synthetischer erster ARIA-Turn in ihrer eigenen Stimme haelt die Rolle
# per Self-Grounding auch bei duennem Verlauf (Hauptchat / erster Turn
# eines neuen Projekts). Kein <system>-Tag -> kein Injection-Trigger.
# Rein ephemer — wird NIE persistiert. Siehe IDENTITY_SEED in prompts.py.
messages.append(ProxyMessage(role="assistant", content=IDENTITY_SEED))
# Conversation-Window auf das aktive Projekt filtern: in einem Projekt
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
# nur die nicht-getaggten Turns.
@@ -1267,10 +1529,33 @@ class Agent:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# Gift-Waechter: faellt Claude trotz --system-prompt + Seed aus der Rolle
# (Identity-Break), NICHT persistieren — sonst vergiftet dieser eine Turn
# die History und loest bei schwachen Folgeturns eine Kaskade aus (das
# Modell setzt seine eigene Ablehnung fort). Ein Retry holt per
# Nondeterminismus meist die ARIA-Antwort; sonst sichere Fallback-Antwort.
# So kann ein einzelner Ausrutscher nie snowballen.
if looks_like_identity_break(final_reply):
logger.warning("[guard] Identity-Break in Antwort erkannt — Retry")
try:
retry = self.proxy.chat_full(messages, tools=tools,
project_id=active_project_id)
retry_text = (retry.content or "").strip()
except Exception as exc:
logger.warning("[guard] Retry fehlgeschlagen: %s", exc)
retry_text = ""
if retry_text and not looks_like_identity_break(retry_text):
logger.info("[guard] Retry lieferte saubere Antwort")
final_reply = retry_text
else:
logger.warning("[guard] Retry weiter Break/leer — Fallback, Break NICHT persistiert")
final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
"sag mir einfach, was du brauchst.")
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
return final_reply
return final_reply, "claude", True # ARIA-Antwort → vorlesen ok
# ── Tool-Dispatcher ───────────────────────────────────────
@@ -1711,6 +1996,15 @@ class Agent:
except Exception as e:
logger.exception("memory_search fehlgeschlagen")
return f"FEHLER: {e}"
if name == "web_search":
query = (arguments.get("query") or "").strip()
if not query:
return "FEHLER: query ist Pflicht."
try:
n = int(arguments.get("max_results", 5))
except (TypeError, ValueError):
n = 5
return _web_search(query, max(1, min(n, 10)))
if name == "memory_update":
pid = (arguments.get("id") or "").strip()
if not pid:
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try:
agent = agent_factory()
reply = agent.chat(prompt, source="trigger")
reply, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Einmal-Cleanup: entfernt "vergiftete" Hauptthread-Turns aus conversation.jsonl.
Hintergrund
-----------
Solange ARIAs Persona nur via --append-system-prompt kam (statt --system-prompt,
voller Replace), fiel das Modell im Hauptchat aus der Rolle und antwortete als
"Claude Code" ("das ist injizierter Kontext, ich adoptiere die Persona nicht").
Jede dieser Antworten wurde per conversation.add("assistant", ...) in die History
geschrieben. Beim naechsten Request landet sie als <previous_response> im
stdin-Prompt das Modell sieht seine EIGENEN Ablehnungs-Turns und setzt die
Haltung fort (Self-Grounding rueckwaerts). Der --system-prompt-Fix verhindert
NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst
zieht die History das Modell weiter aus der Rolle.
Was das Script tut
------------------
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
(injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten).
- Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden
Hauptthread-User-Turn (die ausloesende Frage) also den ganzen Fehl-Dialog.
- Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker,
legitime Hauptchat-Turns.
- Standard = DRY-RUN (zeigt nur was raus wuerde). Mit --apply wird geschrieben,
vorher ein Backup .pre-cleanup.bak angelegt. Idempotent.
Aufruf (auf der VM, Host-Pfad des Bind-Mounts):
python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl
python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl --apply
Danach Brain neu starten, damit die bereinigte History geladen wird:
docker compose restart aria-brain
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from pathlib import Path
# STARKE, selbstreferenzielle Break-Marker — identisch zu prompts._IDENTITY_BREAK
# (dem Laufzeit-Gift-Waechter). Hier dupliziert, damit das Script self-contained
# ist (laeuft auch auf dem Host-Python ohne qdrant/prompts-Import). Bewusst NICHT
# das blosse Wort "injizier"/"prompt injection" — das nutzt ARIA in Pentest-
# Antworten legitim (sonst False Positives auf echte Security-Doku, wie im
# Dry-Run gesehen: "Runde 60 … SSRF", "Dein Ziel: LLM …").
_BREAK = re.compile(
r"ich\s+bin\s+(?:allerdings\s+|ja\s+|nach\s+wie\s+vor\s+|weiterhin\s+)*claude|"
r"i'?m\s+(?:still\s+|actually\s+)?claude\s+code|i\s+am\s+claude\b|"
r"erfundene[nr]?\s+(?:tool|persona|schemas)|fabricated\s+persona|"
r"fabrizierte?\s+(?:persona|gespr|konversation)|fabricated\s+conversation|"
r"fake[- ]persona|injizierte[rn]?\s+(?:system-?prompt|kontext|persona)|"
r"injected\s+(?:system\s*prompt|persona|context)|"
r"diese\s+session\s+enthält\s+(?:einen|eine)\b.{0,40}injizier|"
r"this\s+session\s+(?:contains|has|keeps|repeatedly)\b.{0,40}(?:inject|fabricat|fake)|"
r"nicht\s+real\s+in\s+dieser\s+(?:umgebung|session)|not\s+real\s+in\s+this",
re.IGNORECASE,
)
def is_poison(content: str) -> bool:
return bool(_BREAK.search(content or ""))
def get_content(obj: dict) -> str:
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
v = obj.get("content")
if not isinstance(v, str):
v = obj.get("text")
return v if isinstance(v, str) else ""
def is_main_thread(obj: dict) -> bool:
"""Hauptthread = kein Projekt-Tag. Brain nutzt 'project_id', UI/Bridge
'projectId'."""
pid = obj.get("project_id")
if pid is None:
pid = obj.get("projectId")
return not (str(pid or "").strip())
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("--")]
apply = "--apply" in sys.argv[1:]
path = Path(args[0]) if args else Path("/data/conversation.jsonl")
if not path.exists():
print(f"FEHLER: {path} existiert nicht.", file=sys.stderr)
return 2
raw_lines = path.read_text(encoding="utf-8").splitlines()
# Parse zu (raw, obj|None). Nicht-JSON / leere Zeilen bleiben unangetastet.
parsed: list[tuple[str, dict | None]] = []
for line in raw_lines:
s = line.strip()
if not s:
parsed.append((line, None))
continue
try:
parsed.append((line, json.loads(s)))
except Exception:
parsed.append((line, None))
drop = [False] * len(parsed)
poisoned_pairs = [] # (assistant_idx, user_idx|None) fuer's Log
for i, (_, obj) in enumerate(parsed):
if not isinstance(obj, dict):
continue
if obj.get("op") == "distill":
continue
if obj.get("role") != "assistant" or not is_main_thread(obj):
continue
content = get_content(obj)
if not content or not is_poison(content):
continue
# Gift-Assistant-Turn -> droppen
drop[i] = True
user_idx = None
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
for j in range(i - 1, -1, -1):
pj = parsed[j][1]
if not isinstance(pj, dict) or pj.get("op") == "distill":
continue
if pj.get("role") == "user" and is_main_thread(pj):
drop[j] = True
user_idx = j
break # nur der direkt vorangehende Turn
poisoned_pairs.append((i, user_idx))
n_drop = sum(drop)
if n_drop == 0:
print("Keine Gift-Turns gefunden — History ist sauber. Nichts zu tun.")
return 0
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
for a_idx, u_idx in poisoned_pairs:
if u_idx is not None:
uq = get_content(parsed[u_idx][1] or {})
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
ac = get_content(parsed[a_idx][1] or {})
print(f" Ablehng (Zeile {a_idx + 1}): {ac[:90]!r}")
print()
if not apply:
print("DRY-RUN — nichts geschrieben. Zum Anwenden erneut mit --apply aufrufen.")
return 0
backup = path.with_suffix(path.suffix + ".pre-cleanup.bak")
shutil.copy2(path, backup)
kept = [raw for idx, (raw, _) in enumerate(parsed) if not drop[idx]]
path.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8")
print(f"OK — {n_drop} Zeilen entfernt. Backup: {backup}")
print("Jetzt Brain neu starten: docker compose restart aria-brain")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
"""
Local-LLM-Client (Plan B) Brain-Seite.
Ruft das schnelle lokale LLM (Qwen3 auf der Gamebox) ueber die Bridge:
Brain HTTP /internal/local-llm Bridge RVS llm-adapter llama.cpp
Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
das Brain bleibt HTTP-only). Der Router im Brain (B1) entscheidet, welche Turns
hierher gehen (einfach) und welche an Claude (schwer / Tool-Bedarf).
Rueckgabe von local_llm_chat: {ok, content, model?, elapsedMs?} oder {ok:False, error}.
Nie werfen der Aufrufer entscheidet bei ok=False, ob er auf Claude eskaliert.
"""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Etwas ueber dem Bridge-seitigen _LLM_TIMEOUT_S (30s), damit der HTTP-Call nicht
# vor dem eigentlichen LLM-Timeout abbricht.
LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC", "35"))
def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten.
Blockierend (urllib) chat() laeuft ohnehin im Executor-Thread."""
if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"}
req = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature}
if stop:
req["stop"] = stop
if tools:
req["tools"] = tools
if model:
req["model"] = model
try:
body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(http_req, timeout=LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
result = json.loads(resp.read().decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc:
try:
err_data = json.loads(exc.read().decode("utf-8", "ignore"))
err = err_data.get("error") or str(exc)
except Exception:
err = str(exc)
return {"ok": False, "error": f"local-llm: {err}"}
except Exception as exc:
logger.warning("local_llm_chat HTTP-Call fehlgeschlagen: %s", exc)
return {"ok": False, "error": f"local-llm nicht erreichbar ({exc})"}
if not isinstance(result, dict) or not result.get("ok"):
return {"ok": False, "error": (result or {}).get("error", "unbekannt")}
return result
+10 -1
View File
@@ -630,6 +630,12 @@ class ChatOut(BaseModel):
turns: int
distilling: bool
events: list = Field(default_factory=list)
# Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude",
# "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic.
answered_by: str = "claude"
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -713,7 +719,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply = await loop.run_in_executor(
reply, answered_by, speak = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
@@ -735,6 +741,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
distilling=needs_distill,
events=a.pop_events(),
project_id=pid,
answered_by=answered_by,
speak=speak,
)
finally:
_project_pending[pid] = [
@@ -809,6 +817,7 @@ def projects_archive(project_id: str):
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
hidden: Optional[bool] = None
@app.patch("/projects/{project_id}")
+54 -10
View File
@@ -52,25 +52,55 @@ def _messages_tokens(messages: list) -> int:
return total
def log_call(model: str, messages_in: list, reply_text: str = "") -> None:
"""Eine Call-Metric anhaengen. Robust gegen Fehler (silent fail)."""
def _append(model: str, tokens_in: int, tokens_out: int, source: str) -> None:
"""Ein Metric-Entry auf Disk anhaengen. Robust (silent fail)."""
try:
tokens_in = _messages_tokens(messages_in)
tokens_out = _estimate_tokens(reply_text)
line = json.dumps({
"ts": int(time.time() * 1000),
"model": model,
"in": tokens_in,
"out": tokens_out,
"in": int(tokens_in),
"out": int(tokens_out),
"source": source, # "claude" | "local" | "fast-path"
})
METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
with METRICS_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n")
# Sanftes Rotate ohne hohe IO-Kosten — nur alle 1000 Calls checken
if (tokens_in + tokens_out) % 1000 < 4:
_maybe_rotate()
except Exception as exc:
logger.warning("metrics.log_call: %s", exc)
logger.warning("metrics._append: %s", exc)
def log_call(model: str, messages_in: list, reply_text: str = "",
source: str = "claude") -> None:
"""Claude-Call-Metric anhaengen (Tokens per chars/4-Schaetzung)."""
_append(model, _messages_tokens(messages_in), _estimate_tokens(reply_text), source)
def log_local_call(model: str, messages_in: list, reply_text: str = "",
usage: dict | None = None) -> None:
"""Lokaler-LLM-Call-Metric. Nutzt echte usage-Tokens (prompt/completion)
wenn der Adapter sie liefert, sonst chars/4-Schaetzung wie bei Claude.
Quelle = 'local' damit die Ersparnis-Rechnung local von claude trennt."""
tokens_in = tokens_out = None
if isinstance(usage, dict):
pt = usage.get("prompt_tokens")
ct = usage.get("completion_tokens")
if isinstance(pt, (int, float)):
tokens_in = int(pt)
if isinstance(ct, (int, float)):
tokens_out = int(ct)
if tokens_in is None:
tokens_in = _messages_tokens(messages_in)
if tokens_out is None:
tokens_out = _estimate_tokens(reply_text)
_append(model or "local", tokens_in, tokens_out, "local")
def log_fast_path(reply_text: str = "") -> None:
"""Fast-Path (reiner Skill, KEIN LLM) — spart einen ganzen Claude-Call zum
Nulltarif. tokens_in=0 (kein Prompt ans LLM), out = winzige Quittung."""
_append("fast-path", 0, _estimate_tokens(reply_text), "fast-path")
def _maybe_rotate() -> None:
@@ -95,6 +125,11 @@ def aggregate(window_seconds: int) -> dict:
tokens_in = 0
tokens_out = 0
by_model: dict[str, int] = {}
# Aufschluesselung nach Quelle (claude / local / fast-path) fuer die
# Ersparnis-Anzeige im Diagnostic.
def _src_bucket() -> dict:
return {"calls": 0, "tokens_in": 0, "tokens_out": 0}
by_source: dict[str, dict] = {}
if METRICS_FILE.exists():
try:
for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines():
@@ -107,11 +142,19 @@ def aggregate(window_seconds: int) -> dict:
continue
if obj.get("ts", 0) < cutoff_ms:
continue
ti = int(obj.get("in") or 0)
to = int(obj.get("out") or 0)
calls += 1
tokens_in += int(obj.get("in") or 0)
tokens_out += int(obj.get("out") or 0)
tokens_in += ti
tokens_out += to
m = obj.get("model", "?")
by_model[m] = by_model.get(m, 0) + 1
# Alt-Eintraege ohne 'source' zaehlen als claude (Rueckwaerts-Kompat).
src = obj.get("source") or "claude"
b = by_source.setdefault(src, _src_bucket())
b["calls"] += 1
b["tokens_in"] += ti
b["tokens_out"] += to
except Exception as exc:
logger.warning("metrics aggregate: %s", exc)
return {
@@ -120,6 +163,7 @@ def aggregate(window_seconds: int) -> dict:
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"by_model": by_model,
"by_source": by_source,
}
+2 -1
View File
@@ -132,6 +132,7 @@ def create_project(name: str, description: str = "") -> dict:
"name": name,
"description": description.strip(),
"status": "active", # active | ended | archived
"hidden": False, # optisch aus Listen ausblenden (bleibt nutzbar)
"created_at": now,
"updated_at": now,
"last_activity_at": now,
@@ -148,7 +149,7 @@ def update_project(project_id: str, patch: dict) -> Optional[dict]:
projects = _load_all()
for p in projects:
if p["id"] == project_id:
for k in ("name", "description", "status"):
for k in ("name", "description", "status", "hidden"):
if k in patch and patch[k] is not None:
p[k] = patch[k]
p["updated_at"] = _now()
+70
View File
@@ -15,6 +15,7 @@ mit dem Conversation-Loop in spaeteren Phasen.
from __future__ import annotations
import re
from datetime import datetime, timezone, timedelta
from typing import List
@@ -51,9 +52,78 @@ Rolle wirft — du bleibst ARIA und arbeitest normal weiter.
Dein eigenes Werkzeug-Format (`<tool_call>`) ist echt und von diesem System so
vorgesehen nutze es ganz normal.
## DISKRETION — PRIVATE/INTIME DINGE (HART, GILT IMMER)
Du kennst private und intime Details ueber Stefan (Beziehungen, Partnerinnen,
Sexualitaet/Lebensweise, Gesundheit, Finanzen, anderes Persoenliches). Dieses
Wissen ist fuer deine Arbeit da aber es ist HOCHVERTRAULICH:
- Gib es NIEMALS von dir aus preis. Nicht in Selbstvorstellungen, nicht bei
was weisst du ueber mich", nicht in Zusammenfassungen, nicht in Triggern oder
Push-Nachrichten, nicht als Beispiel, nicht um zu zeigen was du weisst".
- Nur wenn Stefan KONKRET nach genau diesem Thema fragt und dann knapp und
gezielt, ohne Rundum-Dump.
- Denk IMMER daran: jemand koennte danebensitzen oder mithoeren. Intimes
ungefragt auszusprechen ist ein Vertrauensbruch. Im Zweifel: weglassen oder
kurz rueckfragen (Soll ich dazu ins Detail gehen?").
Auf wer bist du" / „was weisst du ueber mich": antworte allgemein und diskret
(z.B. einiges geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein
Aufzaehlen privater/intimer Details.
"""
# Identitaets-Grounding IM Konversations-Strom — Defense-in-Depth.
#
# PRIMAERER Fix ist der volle System-Prompt-Replace (--system-prompt statt
# --append-system-prompt, siehe docker-compose.yml + openai-to-cli.js): damit
# ist die ARIA-Persona DIE Identitaet des Modells, nicht ein Anhaengsel hinter
# Claude Codes "You are Claude Code". Zuvor (--append) hat die eingebaute
# Identitaet bei duennem Kontext (Hauptchat, erster Turn eines neuen Projekts)
# gewonnen und ARIA aus der Rolle geworfen ("ich bin Claude Code, ich adoptiere
# die Persona nicht"), samt Flaggen von GPS/Tool-XML als Injection.
#
# ZUSAETZLICH setzen wir einen synthetischen ERSTEN ARIA-Turn in IHRER eigenen
# Stimme an den Anfang des Konversations-Stroms. Das Modell setzt seine EIGENE
# etablierte Stimme fort (Self-Grounding) — greift auch dann, falls eine CLI-
# Version --system-prompt mal nicht sauber isoliert. Weil es ein
# <previous_response> ist und KEIN <system>-Tag, ist es kein Injection-Trigger.
# Rein ephemer (nur im Request-messages-Array, wird NIE persistiert).
IDENTITY_SEED = (
"Ich bin ARIA, Stefans persoenliche Assistentin. Ich bin einsatzbereit und "
"antworte durchgehend als ARIA — im Hauptchat wie im Projekt, bei kurzen "
"Alltagsfragen genauso wie bei tiefen technischen Themen. Womit kann ich helfen?"
)
# Gift-Waechter: erkennt eine Antwort, in der das Modell AUS DER ROLLE gefallen
# ist (sich selbst als Claude bezeichnet, die ARIA-Persona als injiziert/erfunden
# abtut, die Session als Fake bezeichnet). Solche Antworten duerfen NICHT in die
# Conversation-History — ein einziger gespeicherter Break zieht bei schwachen
# Folgeturns eine Kaskade nach sich (das Modell setzt seine eigene Ablehnung fort).
#
# BEWUSST nur STARKE, selbstreferenzielle Marker — nicht das blosse Wort
# "Injection"/"injizier" (das nutzt ARIA in Security-/Pentest-Projekten voellig
# legitim). Getroffen wird nur das Muster "ICH bin Claude / die Persona ist
# erfunden / diese Session ist injiziert".
_IDENTITY_BREAK = re.compile(
r"ich\s+bin\s+(?:allerdings\s+|ja\s+|nach\s+wie\s+vor\s+|weiterhin\s+)*claude|"
r"i'?m\s+(?:still\s+|actually\s+)?claude\s+code|i\s+am\s+claude\b|"
r"erfundene[nr]?\s+(?:tool|persona|schemas)|fabricated\s+persona|"
r"fabrizierte?\s+(?:persona|gespr|konversation)|fabricated\s+conversation|"
r"fake[- ]persona|injizierte[rn]?\s+(?:system-?prompt|kontext|persona)|"
r"injected\s+(?:system\s*prompt|persona|context)|"
r"diese\s+session\s+enthält\s+(?:einen|eine)\b.{0,40}injizier|"
r"this\s+session\s+(?:contains|has|keeps|repeatedly)\b.{0,40}(?:inject|fabricat|fake)|"
r"nicht\s+real\s+in\s+dieser\s+(?:umgebung|session)|not\s+real\s+in\s+this",
re.IGNORECASE,
)
def looks_like_identity_break(text: str) -> bool:
"""True, wenn eine ARIA-Antwort aus der Rolle gefallen ist. Fuer den
Gift-Waechter im Agent (nicht persistieren + Retry)."""
return bool(text and _IDENTITY_BREAK.search(text))
def build_time_section() -> str:
"""Aktueller Zeitstempel — damit ARIA Timer korrekt anlegen kann
und Watcher-Conditions mit hour_of_day etc. einordenbar bleiben."""
+166
View File
@@ -0,0 +1,166 @@
"""
Router (Plan B, B1a) entscheidet pro Turn: lokales schnelles LLM oder Claude.
Gestaffelt:
- B1a (hier): nur reden" — einfache Plauder-Turns → lokales Qwen (schlanker
Prompt, KEINE Tools). Antwortet es sauber fertig in <1 s. Sagt es
`<<ESCALATE>>`, braucht ein Tool oder faellt aus Claude (bestehender Pfad).
- B1b (spaeter): kuratierte lokale Tools + lokale Tool-Loop.
Schalter kommen aus /shared/config/local_llm.json (Diagnostic schreibt, Brain
liest pro Request):
{
"enabled": false, # Master: lokales Tier an/aus (aus = alles Claude)
"localOnly": false, # Eval: erzwinge lokal, KEIN Claude-Fallback
"toolVariant": "slim" # "slim" | "full" (B1b; "full" braucht mehr VRAM)
}
Default (Datei fehlt/kaputt): enabled=false Verhalten wie bisher (alles Claude).
"""
from __future__ import annotations
import json
import logging
import os
import re
logger = logging.getLogger(__name__)
CONFIG_PATH = os.environ.get("LOCAL_LLM_CONFIG", "/shared/config/local_llm.json")
ESCALATE_MARKER = "<<ESCALATE>>"
DEFAULT_CONFIG = {"enabled": False, "localOnly": False,
"toolVariant": "slim", "localLlmModel": "qwen3-8b"}
def load_config() -> dict:
"""Liest die Schalter. Nie werfen — bei Fehler Defaults (= alles Claude)."""
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
data = json.load(f) or {}
return {
"enabled": bool(data.get("enabled", False)),
"localOnly": bool(data.get("localOnly", False)),
"toolVariant": data.get("toolVariant", "slim") or "slim",
# Welches lokale Modell llama-swap laden soll (B0.5). Muss zu einem
# Key in xtts/llama-swap/config.yaml passen.
"localLlmModel": (data.get("localLlmModel") or "qwen3-8b").strip(),
}
except (FileNotFoundError, json.JSONDecodeError):
return dict(DEFAULT_CONFIG)
except Exception as exc:
logger.debug("local_llm-Config lesen fehlgeschlagen: %s", exc)
return dict(DEFAULT_CONFIG)
# ── Heuristik: ist dieser Turn „einfach genug" fuers lokale Tier (B1a)? ──
#
# B1a ist reden-ohne-Tools. Also: alles, was ein Tool/Aktion braucht oder tief/
# technisch ist, geht an Claude. Lieber konservativ (im Zweifel Claude) — das
# lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation
# faengt zusaetzlich das <<ESCALATE>> im Modell selbst ab.
# CLAUDE-ONLY-Themen → nicht lokal. Seit B1b hat das lokale Tier Werkzeuge
# (web_search, memory_search, trigger_timer, Spotify), daher gehen Wetter, News,
# Fakten, Timer, Musik, Gedaechtnis-Suche jetzt LOKAL. Nur was das lokale Tier
# nicht kann bleibt hier: Bilder, Skills, Projekte, OAuth, Smart-Home (keine
# Anbindung), Kalender/Mail (kein Tool).
_TOOL_HINTS = re.compile(
r"\b(bild|generier|male?\b|malen|zeichne|foto|"
r"skill|projekt|oauth|"
r"licht|lampe|steckdose|rollade|heizung|"
r"kalender|termin|"
r"maild?|e-?mail|nachricht schreiben)\b",
re.IGNORECASE,
)
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
_HARD_HINTS = re.compile(
r"```|" # Codeblock
r"\b(code|fehler|error|stacktrace|exception|bug|debug|pentest|exploit|"
r"vuln|payload|regex|sql|python|javascript|docker|kubernetes|"
r"analysier|erklär.*genau|schritt für schritt|refactor|implementier)\b",
re.IGNORECASE,
)
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
def should_try_local(user_message: str, cfg: dict) -> bool:
"""True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll.
localOnly überschreibt die Heuristik (dann IMMER lokal)."""
if not cfg.get("enabled"):
return False
if cfg.get("localOnly"):
return True
msg = (user_message or "").strip()
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
return False
if _TOOL_HINTS.search(msg):
return False
if _HARD_HINTS.search(msg):
return False
return True
# ── Schlanker System-Prompt fuers lokale Tier ──
#
# Klein halten (Speed!). Persona-Kern + Identitaets-Anker + kurze Awareness-Liste
# (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas,
# kein volles Memory (B1a).
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
_AWARENESS = (
"Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, "
"Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, "
"sowie tiefe/technische Analysen und langen Code."
)
def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
pinned_persona: str = "") -> str:
"""Schlanker System-Prompt fuers lokale LLM. identity_anchor = derselbe
Anker wie bei Claude (Rolle haelt)."""
parts = [
identity_anchor.strip(),
"",
"## SCHNELL-MODUS",
"Du laeufst gerade als schnelles lokales Modell fuer Alltags-Konversation "
"und einfache Aufgaben. Antworte knapp, freundlich, auf Deutsch, als ARIA.",
"WICHTIG zur Ausgabe: Antworte in ganz NORMALEM Text. Verwende KEINE "
"`<voice>`-Tags, kein `[FILE:]`, kein `<tool_call>`, kein HTML/Markup — "
"nur ein oder zwei natuerliche Saetze. (Der Text wird direkt angezeigt "
"UND vorgelesen.)",
]
if has_tools:
parts += [
"",
"## DEINE WERKZEUGE — nur nutzen wenn die Frage es WIRKLICH braucht",
"- `web_search`: aktuelle Infos aus dem Netz — Wetter, News, Fakten, "
"Preise, Oeffnungszeiten. Bei Wetter: nimm Stefans Ort aus dem "
"GPS-Hinweis in der Nachricht.",
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
"- `run_spotify`: Musik steuern (naechster Titel, Pause, weiter).",
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
"Bedarf; erfinde keine.",
]
parts += [
"",
"## WAS DU HIER NICHT KANNST",
_AWARENESS,
"WICHTIG — du hast Stefans GEDAECHTNIS hier NICHT im Kopf: Bei Fragen zu "
"seinem Leben, zu Personen/Namen, Beziehungen, seiner Vergangenheit, "
"seinen Vorlieben/Sachen oder anderem gespeicherten Wissen antworte NICHT "
"aus dem Nichts (und rate nicht, wer wer ist) — sondern eskaliere. Das "
"grosse Modell kennt das Gedaechtnis und antwortet diskret.",
"Dafuer — und bei tiefen/technischen Fragen, langem Code, oder wenn du "
f"unsicher bist — antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` "
"(nichts sonst). Dann uebernimmt das grosse Modell mit vollem Zugriff. "
"Lieber einmal eskalieren als falsch raten oder ein Werkzeug erfinden.",
]
if pinned_persona.strip():
parts += ["", "## PERSONA", pinned_persona.strip()]
return "\n".join(parts)
+25
View File
@@ -0,0 +1,25 @@
# SearXNG-Config fuer ARIA (self-hosted Meta-Suche, Backend fuers web_search-Tool).
# Erbt alle Default-Engines; wir ueberschreiben nur das Noetige:
# - JSON-Format aktiviert (Default AUS) -> Brain kann /search?format=json rufen
# - Rate-Limiter aus -> programmatischer Brain-Zugriff wird nicht geblockt
# - eigener secret_key (interne Instanz auf aria-net, nicht oeffentlich exponiert)
use_default_settings: true
server:
# Interner Dienst auf aria-net, nicht oeffentlich. Trotzdem ein eigener Key.
# Bei Bedarf aendern (beliebiger langer Zufallsstring).
secret_key: "aria-searxng-6f2c9a1e8b7d4f30a5c1e2d9b8a7f6c3"
limiter: false
image_proxy: false
search:
formats:
- html
- json
# Deutsch bevorzugen (Brain kann per Query-Param ueberschreiben).
default_lang: "de"
# Sanftere Timeouts, damit eine langsame Engine die Suche nicht ausbremst.
outgoing:
request_timeout: 5.0
max_request_timeout: 10.0
+202 -5
View File
@@ -149,6 +149,54 @@ def _num_to_words_de(n: int) -> str:
return str(n)
# Vollstaendige Zehner fuer den generischen Konverter (inkl. 60-90).
_TENS_FULL_DE = {20: "zwanzig", 30: "dreissig", 40: "vierzig", 50: "fuenfzig",
60: "sechzig", 70: "siebzig", 80: "achtzig", 90: "neunzig"}
def _below_100_de(n: int) -> str:
if n in _NUM_WORDS_DE: # 0..20
return _NUM_WORDS_DE[n]
tens = (n // 10) * 10
ones = n % 10
if ones == 0:
return _TENS_FULL_DE[tens]
ones_word = "ein" if ones == 1 else _NUM_WORDS_DE[ones]
return f"{ones_word}und{_TENS_FULL_DE[tens]}"
def _below_1000_de(n: int) -> str:
if n < 100:
return _below_100_de(n)
h, rest = divmod(n, 100)
h_word = ("ein" if h == 1 else _NUM_WORDS_DE[h]) + "hundert"
return h_word if rest == 0 else h_word + _below_100_de(rest)
def _int_to_words_de(n: int) -> str:
"""Ganzzahl 0..999999 als zusammenhaengendes deutsches Wort
('dreiundzwanzig', 'einhundert', 'zweitausendsechsundzwanzig').
Groesser (IDs/Codes) als Ziffern lassen (der Aufrufer gated zusaetzlich)."""
if n < 0:
return "minus " + _int_to_words_de(-n)
if n < 1000:
return _below_1000_de(n)
if n < 1_000_000:
th, rest = divmod(n, 1000)
th_word = ("ein" if th == 1 else _below_1000_de(th)) + "tausend"
return th_word if rest == 0 else th_word + _below_1000_de(rest)
return str(n)
def _spell_standalone_int(m) -> str:
"""Regex-Callback: freistehende Ganzzahl ausschreiben. Sehr lange
Ziffernfolgen (IDs, Codes, Telefonnummern) bleiben Ziffern."""
s = m.group(0)
if len(s) > 6:
return s
return _int_to_words_de(int(s))
def _time_range_to_words(m):
"""'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'."""
h1 = int(m.group(1))
@@ -168,7 +216,7 @@ def _decimal_to_words(m):
"""'0.1' / '0,1''null komma eins', '1,25''eins komma zwei fuenf'."""
int_part = int(m.group(1))
dec_part = m.group(2)
int_word = _num_to_words_de(int_part) if 0 <= int_part <= 59 else str(int_part)
int_word = _int_to_words_de(int_part) if int_part <= 999999 else str(int_part)
dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part)
return f"{int_word} komma {dec_words}"
@@ -184,7 +232,7 @@ _UNIT_WORDS = [
(r'\bkm\b', 'Kilometer'),
(r'\bm/s\b', 'Meter pro Sekunde'),
(r'\bkg\b', 'Kilogramm'),
(r'\b°C\b', 'Grad Celsius'),
(r'\b°C\b', ' Grad Celsius'),
(r'°C', ' Grad Celsius'),
(r'\bMbps\b', 'Megabit pro Sekunde'),
(r'\bGbps\b', 'Gigabit pro Sekunde'),
@@ -301,6 +349,15 @@ def clean_text_for_tts(text: str) -> str:
# werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden.
t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t)
# Generisches Ausschreiben ALLER verbleibenden freistehenden Ganzzahlen
# ('23' → 'dreiundzwanzig', '100' → 'einhundert', '2026' → 'zwei-
# tausendsechsundzwanzig'). Laeuft NACH Uhrzeiten/Dezimalzahlen/Einheiten,
# die ihre Ziffern schon zu Woertern gemacht haben. Tag-unabhaengig — so
# klingen auch Antworten des lokalen LLM (das keine <voice>-Tags nutzt)
# sauber. Ziffernfolgen die an ., :, / kleben (IPs, Versionen, Uhrzeit-
# Reste) ueberspringen wir, um sie nicht zu zerreissen.
t = _re_tts.sub(r'(?<![\d.,:/])\d{1,6}(?![\d.,:/])', _spell_standalone_int, t)
# Anfuehrungszeichen
t = _re_tts.sub(r'["""„`]', '', t)
@@ -643,6 +700,11 @@ class ARIABridge:
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
# bei 'loading' deutlich grosszuegiger gesetzt (Modell-Download ~24 GB).
self._remote_flux_ready: bool = False
# Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload.
# Analog zu _pending_flux — Brain ruft /internal/local-llm, wir relayen
# llm_request via RVS an den llm-adapter (Gamebox) und warten auf
# llm_response.
self._pending_llm: dict[str, asyncio.Future] = {}
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -1252,12 +1314,14 @@ class ARIABridge:
# Voice-Tag-Noise als Kontext sieht).
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
display_text = strip_voice_tag_for_display(text)
_answered_by = (payload.get("answeredBy") or "") if isinstance(payload, dict) else ""
assistant_backup_ts = self._append_chat_backup({
"role": "assistant",
"text": display_text,
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
"project_id": turn_pid,
"answeredBy": _answered_by,
})
metadata = payload.get("metadata", {})
@@ -1300,10 +1364,19 @@ class ARIABridge:
# Projekt-Zuordnung — App + Diagnostic sortieren die Bubble in
# den passenden Projekt-Block. Leer = Hauptchat.
"projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "",
# Quell-Backend (local/claude/fast-path) fuer den Diagnostic-Badge.
"answeredBy": (payload.get("answeredBy") or "") if isinstance(payload, dict) else "",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
# System-Flag vom Brain: reine Steuerbefehle (Fast-Path) NICHT vorlesen.
# Robust und unabhaengig von <voice>-Tags im Text (die ARIA beim
# Skill-Rebuild verlieren kann) — die Quelle entscheidet, nicht der Inhalt.
if isinstance(payload, dict) and payload.get("speak") is False:
logger.info("[core] TTS uebersprungen (speak=False — Fast-Path-Steuerbefehl)")
return
# TTS ueber XTTS (XTTS-Bridge auf Gaming-PC)
if not (getattr(self, 'tts_enabled', True) and should_speak(self.current_mode, is_critical)):
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
@@ -1326,9 +1399,18 @@ class ARIABridge:
or 1.0
)
tts_text = tts_text_preview or text
# Ein vorhandener <voice>-Tag ist die EXPLIZITE TTS-Vorgabe von ARIA —
# auch wenn er leer ist. Leeres <voice></voice> = bewusst stumm: die
# Bubble erscheint im Chat, aber es wird NICHTS gesprochen. Use-Case:
# Steuerbefehl-Quittungen (Spotify next/pause, Licht an) — dort darf die
# Sprachausgabe NICHT feuern, weil das TTS-Playback auf dem Handy den
# Audio-Fokus klaut, Spotify duckt/pausiert und (bei kurzem Text) nicht
# sauber weiterspielt. Nur OHNE Voice-Tag faellt es auf den vollen Text
# zurueck (normale Antwort ohne TTS-Annotation wird komplett gelesen).
has_voice_tag = "<voice>" in (text or "").lower()
tts_text = tts_text_preview if has_voice_tag else (tts_text_preview or text)
if not tts_text:
logger.info("[core] TTS-Text leer nach Cleanup — uebersprungen")
logger.info("[core] TTS-Text leer (bewusst stumm via <voice></voice> oder nach Cleanup) — uebersprungen")
return
try:
xtts_request_id = str(uuid.uuid4())
@@ -1619,6 +1701,12 @@ class ARIABridge:
# gegeben damit der chat-Broadcast die Bubble dem richtigen Projekt-
# Block in App + Diagnostic zuordnen kann.
turn_project_id = (data.get("project_id") or "").strip()
# Welcher Backend geantwortet hat (local/claude/fast-path) — fuer den
# Quell-Badge in Diagnostic.
answered_by = (data.get("answered_by") or "claude").strip()
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
speak = data.get("speak", True)
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen
@@ -1685,7 +1773,9 @@ class ARIABridge:
# passend behandelt wird (hier minimal, weil Brain noch keine
# metadata mitschickt).
try:
await self._process_core_response(reply, {"projectId": turn_project_id})
await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by,
"speak": speak})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id)
@@ -1696,6 +1786,14 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
# Das 'thinking' oben ging mit der REQUEST-projectId raus, das 'idle' in
# _process_core_response aber mit der TURN-projectId (Brain kann den Turn
# umgeroutet haben, z.B. Voice-Sticky). Weichen sie ab, bekaeme der
# Request-Kontext nie sein idle → "ARIA denkt" bliebe dort haengen.
# Darum hier zusaetzlich fuer die Request-projectId ein idle.
if (project_id or "") != (turn_project_id or ""):
await self._emit_activity("idle", "", project_id=project_id)
if data.get("distilling"):
logger.info("[brain] Destillat laeuft im Hintergrund")
@@ -2954,6 +3052,15 @@ class ARIABridge:
future.set_result(payload)
return
elif msg_type == "llm_response":
# Antwort des llm-adapter (Gamebox) auf unseren llm_request.
request_id = payload.get("requestId", "")
future = self._pending_llm.get(request_id)
if future is None or future.done():
return
future.set_result(payload)
return
elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
@@ -3337,6 +3444,66 @@ class ARIABridge:
_FLUX_TIMEOUT_READY_S = 240.0 # 4 min nach erstem Render
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
# ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ──
# Qwen3 auf der Gamebox antwortet auf kurze Turns in <1 s. Grosszuegiger
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (Gamebox@home)
# ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude.
_LLM_TIMEOUT_S = 30.0
async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
"""Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_llm[request_id] = future
try:
req_payload = {
"requestId": request_id,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if stop:
req_payload["stop"] = stop
if tools:
req_payload["tools"] = tools
if model:
req_payload["model"] = model
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0, model or "-")
ok = await self._send_to_rvs({
"type": "llm_request",
"payload": req_payload,
"timestamp": int(time.time() * 1000),
})
if not ok:
return {"ok": False, "error": "llm_request konnte nicht gesendet werden"}
try:
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
except asyncio.TimeoutError:
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — Gamebox nicht erreichbar?"}
if not isinstance(result, dict) or not result.get("ok"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "llm-adapter Fehler"}
return {
"ok": True,
"content": result.get("content", ""),
"tool_calls": result.get("tool_calls"),
"model": result.get("model"),
"elapsedMs": result.get("elapsedMs"),
}
finally:
self._pending_llm.pop(request_id, None)
async def _flux_generate(self, prompt: str, width: int, height: int,
steps: Optional[int], guidance: Optional[float],
seed: Optional[int], model: Optional[str] = None) -> dict:
@@ -3788,6 +3955,36 @@ class ARIABridge:
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/local-llm":
# Vom Brain (Router / Testchat) gefeuert. Wir relayen den
# Chat-Request via RVS an den llm-adapter (Gamebox Qwen3),
# warten synchron auf llm_response und geben content zurueck.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
messages = data.get("messages")
if not isinstance(messages, list) or not messages:
await _send_response(writer, 400, {"error": "messages (nicht-leere Liste) erforderlich"})
return
try:
max_tokens = int(data.get("max_tokens") or 512)
except (TypeError, ValueError):
max_tokens = 512
try:
temperature = float(data.get("temperature"))
except (TypeError, ValueError):
temperature = 0.7
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
_model = data.get("model") if isinstance(data.get("model"), str) else None
result = await self._local_llm(
messages=messages, max_tokens=max_tokens,
temperature=temperature, stop=data.get("stop"),
tools=_tools, model=_model,
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/delete-chat-message":
try:
data = json.loads(body.decode("utf-8", "ignore"))
+357 -11
View File
@@ -878,20 +878,100 @@
<h2>Sprachmodell (Brain)</h2>
<div class="card" style="max-width:500px;">
<div style="font-size:11px;color:#8888AA;margin-bottom:10px;line-height:1.5;">
Welches Claude-Model nutzt das Brain pro Anfrage. Wert wird in
Welches Claude-Model das Brain pro Anfrage nutzt. Wert wird in
<code>/shared/config/runtime.json</code> als <code>brainModel</code> persistiert.
Bei Aenderung: <strong>aria-brain restarten</strong> (Reparatur-Section oben), damit's greift.
<br><br>
Verfuegbar via Proxy: <code>claude-sonnet-4</code> (Default — schnell, gut),
<code>claude-opus-4</code> (langsam, smarter), <code>claude-haiku-4-5</code> (sehr schnell, kleiner Kontext).
ARIA laeuft ueber dein Claude-Max-Abo (CLI) — waehlbar ist der <strong>Tier</strong>
(Opus/Sonnet/Haiku), nicht eine feste Version. Die CLI nimmt automatisch das
jeweils aktuelle Modell des Tiers.
<br><br>
Die Auswahlliste kommt aus <code>/shared/config/models.json</code> — dort kannst du
Tiers/Beschreibungen anpassen (kein Neustart noetig, danach „↻ Aktualisieren").
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:12px;color:#8888AA;white-space:nowrap;">Aktives Model:</span>
<input type="text" id="setting-model" placeholder="claude-sonnet-4" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<button class="btn secondary" onclick="loadModel()" style="padding:4px 8px;font-size:10px;">Laden</button>
<select id="setting-model-select" onchange="onModelSelectChange()" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="">(lade Liste…)</option>
</select>
<button class="btn secondary" onclick="loadModelList()" title="Liste vom Proxy neu holen" style="padding:4px 8px;font-size:10px;">↻ Aktualisieren</button>
<button class="btn" onclick="saveModel()" style="padding:4px 8px;font-size:10px;">Setzen</button>
</div>
<div id="model-status" style="font-size:10px;color:#8888AA;"></div>
<div id="model-desc" style="font-size:10px;color:#6a6a88;margin-bottom:6px;min-height:12px;"></div>
<div id="model-status" style="font-size:10px;color:#8888AA;margin-bottom:8px;"></div>
<details style="font-size:11px;color:#8888AA;">
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<input type="text" id="setting-model" placeholder="z.B. claude-opus-4" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<button class="btn secondary" onclick="loadModel()" style="padding:4px 8px;font-size:10px;">Laden</button>
<button class="btn" onclick="saveModelFreeText()" style="padding:4px 8px;font-size:10px;">Setzen</button>
</div>
</details>
</div>
</div>
<div class="settings-section">
<h2>Lokales LLM (schnelle Antworten) <button class="info-btn" onclick="showInfo('local-llm')" title="Wie funktioniert das?"></button></h2>
<div class="card" style="max-width:540px;">
<div style="font-size:11px;color:#8888AA;margin-bottom:12px;line-height:1.55;">
Ein schnelles lokales Modell (<strong>Qwen3</strong> auf der Gamebox) beantwortet
<strong>leichte Fragen in unter 1&nbsp;Sekunde</strong>. Was schwerer wird, gibt es
automatisch an <strong>Claude</strong> ab. Aenderungen greifen sofort
(kein Neustart) — geschrieben nach <code>/shared/config/local_llm.json</code>.
</div>
<!-- Master -->
<label style="display:flex;align-items:flex-start;gap:8px;margin-bottom:4px;font-size:13px;color:#E0E0F0;cursor:pointer;">
<input type="checkbox" id="local-llm-enabled" onchange="saveLocalLlmConfig()" style="margin:2px 0 0 0;">
<span><strong>Lokales LLM einschalten</strong></span>
</label>
<div style="font-size:10px;color:#8888AA;margin:0 0 12px 24px;line-height:1.5;">
<strong style="color:#4ADE80;">AN:</strong> leichte Fragen → lokal (schnell), schwere → Claude.&nbsp;
<strong style="color:#8888AA;">AUS:</strong> alles laeuft ueber Claude (wie bisher). — <em>Das ist der Normal-Betrieb.</em>
</div>
<!-- Nur lokal -->
<label style="display:flex;align-items:flex-start;gap:8px;margin-bottom:4px;font-size:13px;color:#E0E0F0;cursor:pointer;">
<input type="checkbox" id="local-llm-onlylocal" onchange="saveLocalLlmConfig()" style="margin:2px 0 0 0;">
<span><strong>Nur lokales LLM</strong> — Claude komplett aussperren</span>
</label>
<div style="font-size:10px;color:#8888AA;margin:0 0 12px 24px;line-height:1.5;">
<strong style="color:#FFD60A;">Nur zum Testen.</strong> Erzwingt IMMER das lokale Modell — auch bei
schweren Fragen, ohne Claude-Rettung. So siehst du, was Qwen allein kann. Werkzeug-Fragen
(Wetter/Timer/…) funktionieren dann nicht. <strong>Fuer den Alltag: AUS lassen.</strong>
</div>
<!-- Tool-Umfang -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-size:13px;color:#E0E0F0;"><strong>Werkzeuge lokal:</strong></span>
<select id="local-llm-toolvariant" onchange="saveLocalLlmConfig()" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="slim">Abgespeckt — kuratierte Tools</option>
<option value="full" disabled>Voll — ganzes Arsenal (braucht mehr VRAM)</option>
</select>
<button class="info-btn" onclick="showInfo('local-llm-tools')" title="Voll: wie viel VRAM?"></button>
</div>
<div style="font-size:10px;color:#8888AA;margin:0 0 6px 0;line-height:1.5;">
Welche Werkzeuge das lokale Modell <em>selbst</em> ausfuehren darf. „Voll" ist gesperrt,
bis eine 2.&nbsp;Grafikkarte da ist (siehe&nbsp;ⓘ).
<br><span style="color:#FFD60A;">Aktueller Stand (B1a): das lokale Modell <strong>plaudert nur</strong> — Werkzeuge macht noch Claude. Lokale Tools kommen mit B1b.</span>
</div>
<!-- Lokales Modell (llama-swap, B0.5) -->
<div style="display:flex;align-items:center;gap:8px;margin:12px 0 4px 0;padding-top:10px;border-top:1px solid #2a2a3a;">
<span style="font-size:13px;color:#E0E0F0;"><strong>Lokales Modell:</strong></span>
<select id="local-llm-model" onchange="saveLocalLlmConfig()" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="">(lade Liste…)</option>
</select>
<button class="btn secondary" onclick="loadLocalModelList()" title="Liste neu laden" style="padding:4px 8px;font-size:10px;"></button>
</div>
<div id="local-llm-model-desc" style="font-size:10px;color:#8888AA;margin:0 0 2px 0;line-height:1.5;"></div>
<div style="font-size:10px;color:#FFD60A;margin:0 0 4px 0;line-height:1.5;">
Beim ersten Wechsel zu einem Modell lädt die Gamebox das GGUF (mehrere GB) —
die <strong>erste Antwort dauert dann länger</strong>, danach ist es gecacht.
Liste kommt aus <code>/shared/config/local_models.json</code> (Keys = xtts/llama-swap/config.yaml).
</div>
<div id="local-llm-status" style="font-size:11px;color:#6a6a88;margin-top:8px;padding-top:8px;border-top:1px solid #2a2a3a;min-height:14px;"></div>
</div>
</div>
@@ -948,6 +1028,18 @@
</div>
</div>
<div class="settings-section">
<h2>Lokales LLM & Claude-Ersparnis <button class="info-btn" onclick="showInfo('local-savings')" title="Wie wird die Ersparnis gerechnet?"></button></h2>
<div class="card">
<div style="font-size:11px;color:#8888AA;margin-bottom:10px;">
Turns, die das <strong style="color:#B392F0;">lokale LLM</strong> (Qwen) oder ein reiner <strong style="color:#F0B85E;">Skill-Fast-Path</strong> uebernommen hat — jeder davon ist ein Claude-Call, der NICHT passiert ist. Die lokalen Tokens laufen auf deiner eigenen Hardware (kein Subscription-Quota).
</div>
<div id="savings-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;font-size:12px;">
<div class="metric-cell"><div class="metric-label"></div><div class="metric-value"></div></div>
</div>
</div>
</div>
<div class="settings-section">
<h2>Bootstrap & Migration <button class="info-btn" onclick="showInfo('bootstrap')" title="Was sind die drei Wege?"></button></h2>
<div class="card" style="line-height:1.6;">
@@ -1003,6 +1095,7 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2>
<div>
<button class="btn secondary" id="toggle-hidden-btn" onclick="toggleShowHidden()" style="padding:4px 10px;font-size:11px;" title="Versteckte Projekte ein-/ausblenden">👁 Versteckte anzeigen</button>
<button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
<button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div>
@@ -1504,6 +1597,10 @@
send({ action: 'load_chat_history' });
// Brain-Card initial laden (sonst zeigt sie "Lade...")
try { loadBrainStatus(); } catch {}
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
try { loadModelList(); } catch {}
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
};
// Brain-Status periodisch refreshen damit die Card live bleibt
@@ -1822,6 +1919,7 @@
ttsText: p.ttsText,
backupTs: p.backupTs,
projectId: p.projectId || '',
answeredBy: p.answeredBy || '',
});
return;
}
@@ -1923,7 +2021,8 @@
const trashBtn = m.ts
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
: '';
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
const histBadge = srcBadgeHtml(m.type, m.answeredBy || '');
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)}${histBadge} — ${time}</div>`;
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
@@ -1966,8 +2065,14 @@
// session_restarted / openclaw_config WS-Events entfernt — aria-core ist raus.
if (msg.type === 'model_info') {
const el = document.getElementById('setting-model');
const sel = document.getElementById('setting-model-select');
const st = document.getElementById('model-status');
if (el && msg.model) el.value = msg.model;
// Dropdown auf das aktuelle Model stellen (falls in der Liste).
if (sel && msg.model) {
const has = Array.from(sel.options).some(o => o.value === msg.model);
if (has) { sel.value = msg.model; onModelSelectChange(); }
}
if (st) {
st.textContent = msg.info || msg.error || '';
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
@@ -2248,6 +2353,18 @@
return t.trim();
}
// Quell-Badge (local/claude/fast-path) fuer ARIA-Bubbles — in Live + History genutzt.
function srcBadgeHtml(type, answeredBy) {
if (type !== 'received' || !answeredBy) return '';
const M = {
'local': { t: '⚡ lokal', c: '#34C759' },
'claude': { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ Fast-Path', c: '#AF7BFF' },
};
const b = M[answeredBy] || { t: answeredBy, c: '#8888AA' };
return `<span title="Antwort erzeugt von: ${escapeHtml(answeredBy)}" style="display:inline-block;margin-left:6px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:bold;background:${b.c}22;color:${b.c};border:1px solid ${b.c}55;">${b.t}</span>`;
}
function addChat(type, text, meta, options) {
// [FILE: /shared/uploads/aria_xxx.ext]-Marker aus dem Antworttext entfernen —
// die Datei kommt separat via file_from_aria-Event als eigene Bubble.
@@ -2282,7 +2399,9 @@
const trashBtn = backupTs
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${backupTs})">🗑</button>`
: '';
const html = `${trashBtn}${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
// Quell-Badge: welcher Backend die Antwort erzeugt hat (nur ARIA-Bubbles)
const srcBadge = srcBadgeHtml(type, (options && options.answeredBy) || '');
const html = `${trashBtn}${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)}${srcBadge} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
// Thinking-Indikator ausblenden bei neuer Nachricht
updateThinkingIndicator({ activity: 'idle' });
@@ -2535,7 +2654,7 @@
// Liste neu aufbauen
list.innerHTML = '';
let anyLoading = false, anyError = false;
const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen' };
const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen', llm: 'Lokales LLM' };
for (const [s, info] of Object.entries(_serviceState)) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;';
@@ -2884,16 +3003,28 @@
<div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div>
<div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`);
const showHidden = !!window.__showHidden;
let hiddenCount = 0, shownCount = 0;
for (const p of projects) {
const hidden = !!p.hidden;
if (hidden) hiddenCount++;
// Versteckte nur zeigen wenn der Toggle an ist.
if (hidden && !showHidden) continue;
shownCount++;
const isActive = p.id === activeId;
const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
const ended = p.status === 'ended';
// Auge: versteckt → 👁 (wieder sichtbar machen), sichtbar → 🙈 (verstecken).
const eyeIcon = hidden ? '👁' : '🙈';
const eyeTitle = hidden ? 'Wieder dauerhaft sichtbar machen' : 'Verstecken (aus Listen ausblenden)';
const eyeColor = hidden ? '#B392F0' : '#8888AA';
rows.push(`
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}${hidden ? 'opacity:0.55;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
📁 ${escapeHtml(p.name)}
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
@@ -2901,6 +3032,7 @@
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<div style="display:flex;gap:4px;">
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div>
@@ -2909,13 +3041,43 @@
}
if (projects.length === 0) {
rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>');
} else if (shownCount === 0) {
rows.push(`<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Alle ${hiddenCount} Projekte sind versteckt. Klick „👁 Versteckte anzeigen".</div>`);
}
listEl.innerHTML = rows.join('');
// Toggle-Button beschriften (mit Anzahl) + Zustand spiegeln.
const thBtn = document.getElementById('toggle-hidden-btn');
if (thBtn) {
thBtn.textContent = showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ' ('+hiddenCount+')' : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ' ('+hiddenCount+')' : ''}`;
thBtn.style.opacity = hiddenCount ? '1' : '0.5';
}
} catch (e) {
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`;
}
}
function toggleShowHidden() {
window.__showHidden = !window.__showHidden;
loadProjects();
}
async function setProjectHidden(id, hidden) {
try {
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !!hidden }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
// Beim Verstecken bleibt der „anzeigen"-Modus wie er ist; beim
// Wieder-Sichtbarmachen sieht man es sofort ohne Umschalten.
loadProjects();
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
}
async function switchProject(projectId) {
try {
await fetch('/api/brain/projects/switch', {
@@ -5791,6 +5953,29 @@
setCell('metrics-h24', d.h24);
setCell('metrics-d30', d.d30);
// Lokales LLM & Claude-Ersparnis — pro Fenster: wie viele Claude-Calls
// durch local/fast-path vermieden wurden + lokale Token-Last.
const savingsGrid = document.getElementById('savings-grid');
if (savingsGrid) {
const wins = [['letzte 1h', d.h1], ['letzte 5h', d.h5],
['letzte 24h', d.h24], ['letzte 30 Tage', d.d30]];
const z = { calls: 0, tokens_in: 0, tokens_out: 0 };
savingsGrid.innerHTML = wins.map(([label, w]) => {
const bs = (w && w.by_source) || {};
const loc = bs.local || z;
const fp = bs['fast-path'] || z;
const saved = (loc.calls || 0) + (fp.calls || 0);
const locTok = (loc.tokens_in || 0) + (loc.tokens_out || 0);
const color = saved > 0 ? '#3FB950' : '#555570';
return `<div class="metric-cell">
<div class="metric-label">${label}</div>
<div class="metric-value" style="color:${color};">${saved} Claude-Calls gespart</div>
<div class="metric-sub">lokal ${loc.calls || 0} · fast-path ${fp.calls || 0}</div>
<div class="metric-sub">${fmtTokens(locTok)} lokale Tokens (eigene HW)</div>
</div>`;
}).join('');
}
// 5h-Fenster gegen Plan-Limit: Warn-Klassen
const plan = getActivePlanLimit();
const limit = plan.h5;
@@ -5833,6 +6018,37 @@
// Vor-definierte Info-Blocks
const INFO_TEXTS = {
'local-savings': {
title: 'Lokales LLM & Claude-Ersparnis',
html: `
<p>Jeder Turn, den das <strong>lokale LLM</strong> oder ein <strong>Fast-Path</strong> (reiner Skill, ganz ohne LLM) beantwortet, ist ein Claude-Call, der <strong>nicht</strong> gegen dein Subscription-Quota laeuft.</p>
<p><strong>„Claude-Calls gespart"</strong> = Anzahl der local- + fast-path-Antworten im Zeitfenster. Konservativ gezaehlt: 1 Antwort = mindestens 1 gesparter Claude-Call (bei Tool-Use waeren es real oft mehr).</p>
<p><strong>„lokale Tokens"</strong> = Prompt+Antwort-Tokens, die auf deiner eigenen Gamebox-GPU verarbeitet wurden (echte <code>usage</code>-Zahlen vom Modell, sonst chars/4-Schaetzung). Die kosten dich nichts ausser Strom.</p>
<p>Fast-Path-Antworten (z.B. „nächstes Lied") haben ~0 Tokens — reiner Skill-Aufruf, kein Modell.</p>
`,
},
'local-llm': {
title: 'Lokales LLM — schnelle Antworten',
html: `
<p>Ein kleines, schnelles Modell (<strong>Qwen3 8B</strong>) laeuft auf deiner Gamebox-GPU und beantwortet <strong>einfache Plauder-Turns in &lt;1&nbsp;s</strong>. Alles Schwere, Technische oder Werkzeug-artige (Wetter, Timer, Musik, Bilder, Gedaechtnis, Code) reicht ein Router automatisch an <strong>Claude</strong> weiter.</p>
<p><strong>Lokales LLM nutzen</strong> — Master-Schalter. Aus = alle Anfragen laufen wie bisher ueber Claude.</p>
<p><strong>Nur lokales LLM</strong> — erzwingt lokal, KEIN Claude-Fallback. Zum Ausprobieren, wie stark das lokale Modell allein ist. Achtung: Werkzeug-Turns (Wetter/Timer/…) funktionieren dann nicht — das lokale Tier hat keine Tools.</p>
<p>Die Antwortzeit haengt an deinem Heim-Internet (Gamebox @home, ARIA @RZ). Ist die Gamebox aus/nicht erreichbar, faellt ARIA automatisch auf Claude zurueck.</p>
`,
},
'local-llm-tools': {
title: 'Tool-Umfang: Abgespeckt vs. Voll',
html: `
<p><strong>Abgespeckt</strong> — das lokale Modell bekommt eine kleine, kuratierte Tool-Auswahl (Wetter, Zeit, Gedaechtnis-Suche, Timer, Spotify, Licht). Der Rest laeuft ueber Claude. Passt in den VRAM einer <strong>1×RTX&nbsp;3060 (12&nbsp;GB)</strong>.</p>
<p><strong>Voll</strong> — das lokale Modell soll ARIAs komplettes Arsenal kennen. Das sind ~15-20&nbsp;K Tokens Tool-Schemas → passt <em>nicht</em> in ein 8-K-Kontextfenster. Groesseres Fenster kostet VRAM:</p>
<ul>
<li><strong>12&nbsp;GB (1×3060):</strong> nur „Abgespeckt".</li>
<li><strong>24&nbsp;GB (2×3060, eine Box):</strong> Qwen mit grossem Kontext = volles Schema, oder ein groesseres Modell.</li>
<li><strong>Cluster:</strong> weitere GPU-Hosts, jeder ein Modell-Server ueber RVS.</li>
</ul>
<p>Deshalb ist „Voll" hier deaktiviert, bis die Hardware (2.&nbsp;Karte) und die lokale Tool-Loop (B1b) da sind.</p>
`,
},
'brain-status': {
title: 'Gehirn — Status',
html: `
@@ -6062,14 +6278,144 @@
// ── Einstellungen: Model ────────────────────────────────
let _modelListCache = [];
function loadModel() {
send({ action: 'get_model' });
}
// Kuratierte Tier-Liste vom Proxy holen (via Diagnostic-Server) und ins
// Dropdown fuellen. Danach get_model, damit das aktive Model markiert wird.
async function loadModelList() {
const sel = document.getElementById('setting-model-select');
const st = document.getElementById('model-status');
if (!sel) return;
try {
const r = await fetch('/api/models-list');
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
_modelListCache = d.models || [];
sel.innerHTML = '';
for (const m of _modelListCache) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.displayName || m.id;
sel.appendChild(opt);
}
if (!_modelListCache.length) {
const opt = document.createElement('option');
opt.value = ''; opt.textContent = '(keine Modelle vom Proxy)';
sel.appendChild(opt);
}
onModelSelectChange();
// Aktuelles Model vom Brain holen → markiert die richtige Option
loadModel();
} catch (e) {
if (st) { st.textContent = 'Model-Liste laden fehlgeschlagen: ' + e.message; st.style.color = '#FF6B6B'; }
}
}
function onModelSelectChange() {
const sel = document.getElementById('setting-model-select');
const desc = document.getElementById('model-desc');
if (!sel || !desc) return;
const m = _modelListCache.find(x => x.id === sel.value);
desc.textContent = m && m.description ? m.description : '';
}
function saveModel() {
const sel = document.getElementById('setting-model-select');
const model = sel && sel.value ? sel.value : '';
if (!model) return;
send({ action: 'set_model', model });
const st = document.getElementById('model-status');
if (st) { st.textContent = 'Gesetzt — aria-brain neu starten (Reparatur oben), damit es greift.'; st.style.color = '#FFD60A'; }
}
function saveModelFreeText() {
const model = document.getElementById('setting-model').value.trim();
if (!model) return;
send({ action: 'set_model', model });
const st = document.getElementById('model-status');
if (st) { st.textContent = 'Gesetzt (Freitext) — aria-brain neu starten, damit es greift.'; st.style.color = '#FFD60A'; }
}
// ── Einstellungen: Lokales LLM (Plan B) ─────────────────
function setLocalLlmStatus(c) {
const el = document.getElementById('local-llm-status');
if (!el) return;
if (!c || !c.enabled) {
el.textContent = '⚪ Status: AUS — alle Fragen laufen ueber Claude (wie bisher).';
el.style.color = '#8888AA';
return;
}
if (c.localOnly) {
el.textContent = '🟡 Status: TESTMODUS — nur lokal, Claude ausgesperrt. Werkzeug-Fragen funktionieren nicht.';
el.style.color = '#FFD60A';
} else {
el.textContent = '🟢 Status: AKTIV — leichte Fragen lokal (<1s), schwere automatisch an Claude.';
el.style.color = '#4ADE80';
}
}
let _localModelsCache = [];
let _currentLocalModel = 'qwen3-8b';
function updateLocalModelDesc() {
const el = document.getElementById('local-llm-model-desc');
const sel = document.getElementById('local-llm-model');
if (!el || !sel) return;
const m = _localModelsCache.find(x => x.id === sel.value);
el.textContent = m && m.description ? m.description : '';
}
async function loadLocalModelList() {
try {
const r = await fetch('/api/local-models-list');
const j = await r.json();
_localModelsCache = (j && j.models) || [];
} catch (e) { _localModelsCache = []; }
const sel = document.getElementById('local-llm-model');
if (sel) {
sel.innerHTML = _localModelsCache.map(m =>
`<option value="${m.id}">${m.display_name || m.id}</option>`).join('') || '<option value="">(keine)</option>';
if (_localModelsCache.some(m => m.id === _currentLocalModel)) sel.value = _currentLocalModel;
updateLocalModelDesc();
}
}
async function loadLocalLlmConfig() {
try {
const r = await fetch('/api/local-llm-config');
const c = await r.json();
const en = document.getElementById('local-llm-enabled');
const ol = document.getElementById('local-llm-onlylocal');
const tv = document.getElementById('local-llm-toolvariant');
if (en) en.checked = !!c.enabled;
if (ol) ol.checked = !!c.localOnly;
if (tv) tv.value = (c.toolVariant === 'full') ? 'full' : 'slim';
_currentLocalModel = c.localLlmModel || 'qwen3-8b';
const sel = document.getElementById('local-llm-model');
if (sel && _localModelsCache.some(m => m.id === _currentLocalModel)) {
sel.value = _currentLocalModel;
updateLocalModelDesc();
}
setLocalLlmStatus(c);
} catch (e) { /* still */ }
}
async function saveLocalLlmConfig() {
const modelSel = document.getElementById('local-llm-model');
const body = {
enabled: document.getElementById('local-llm-enabled').checked,
localOnly: document.getElementById('local-llm-onlylocal').checked,
toolVariant: document.getElementById('local-llm-toolvariant').value,
localLlmModel: (modelSel && modelSel.value) || '',
};
updateLocalModelDesc();
try {
const r = await fetch('/api/local-llm-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (j.ok) setLocalLlmStatus(j.config);
} catch (e) { /* still */ }
}
// ── Einstellungen: OpenClaw Config ──────────────────────
+97 -1
View File
@@ -297,6 +297,58 @@ function writeRuntimeConfig(patch) {
}
// Atomic write: temp-file + rename, laute Logs bei Fehler.
// ── Local-LLM-Config: /shared/config/local_llm.json ─────────────────
// Der Router im Brain (router.py) liest diese Datei pro Request. Wir schreiben
// sie hier aus den Diagnostic-Schaltern. Default = alles aus (nur Claude).
const LOCAL_LLM_CONFIG_FILE = "/shared/config/local_llm.json";
function readLocalLlmConfig() {
try {
const p = JSON.parse(fs.readFileSync(LOCAL_LLM_CONFIG_FILE, "utf-8"));
return {
enabled: !!p.enabled,
localOnly: !!p.localOnly,
toolVariant: p.toolVariant === "full" ? "full" : "slim",
localLlmModel: (typeof p.localLlmModel === "string" && p.localLlmModel) ? p.localLlmModel : "qwen3-8b",
};
} catch {
return { enabled: false, localOnly: false, toolVariant: "slim", localLlmModel: "qwen3-8b" };
}
}
function writeLocalLlmConfig(patch) {
const cur = readLocalLlmConfig();
if (typeof patch.enabled === "boolean") cur.enabled = patch.enabled;
if (typeof patch.localOnly === "boolean") cur.localOnly = patch.localOnly;
if (patch.toolVariant === "slim" || patch.toolVariant === "full") cur.toolVariant = patch.toolVariant;
if (typeof patch.localLlmModel === "string" && patch.localLlmModel.trim()) cur.localLlmModel = patch.localLlmModel.trim();
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = LOCAL_LLM_CONFIG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(cur, null, 2));
fs.renameSync(tmp, LOCAL_LLM_CONFIG_FILE);
return cur;
}
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
const LOCAL_MODELS_FILE = "/shared/config/local_models.json";
const DEFAULT_LOCAL_MODELS = [
{ id: "qwen3-8b", display_name: "Qwen3 8B (Standard)", description: "Bestes Tool-Calling, ~6 GB. Passt auf 12 GB." },
{ id: "qwen3-4b", display_name: "Qwen3 4B (schneller)", description: "Kleiner + flotter, ~3 GB. Etwas schwaecher." },
];
function loadLocalModels() {
try {
const arr = JSON.parse(fs.readFileSync(LOCAL_MODELS_FILE, "utf-8"));
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) return arr;
} catch {}
// Seed defaults
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync(LOCAL_MODELS_FILE, JSON.stringify(DEFAULT_LOCAL_MODELS, null, 2));
} catch {}
return DEFAULT_LOCAL_MODELS;
}
// ── File-Project-Manifest ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt.
@@ -1573,6 +1625,27 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/local-models-list" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: loadLocalModels() }));
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readLocalLlmConfig()));
} else if (req.url === "/api/local-llm-config" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on("end", () => {
try {
const cfg = writeLocalLlmConfig(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, config: cfg }));
log("info", "server", `Local-LLM-Config: enabled=${cfg.enabled} localOnly=${cfg.localOnly} tools=${cfg.toolVariant}`);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" });
@@ -1625,6 +1698,28 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ ok: false, error: err.message }));
}
return;
} else if (req.url === "/api/models-list" && req.method === "GET") {
// Kuratierte Model-Liste vom Proxy (/v1/models) — Tier-Auswahl fuers
// Sprachmodell-Dropdown. ARIA laeuft ueber das Max-Abo/CLI, waehlbar ist
// der Tier (opus/sonnet/haiku), keine feste Version.
(async () => {
try {
const r = await fetch(`${PROXY_URL}/v1/models`);
const d = await r.json();
const models = (d.data || []).map(m => ({
id: m.id,
tier: m.tier || m.id,
displayName: m.display_name || m.id,
description: m.description || "",
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models }));
} catch (err) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
}
})();
return;
} else if (req.url === "/api/files-list" && req.method === "GET") {
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
@@ -2747,6 +2842,7 @@ async function handleLoadChatHistory(clientWs) {
const ts = obj.ts || 0;
const text = String(obj.text || "");
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung
const answeredBy = String(obj.answeredBy || ""); // Quell-Badge (local/claude/fast-path)
if (obj.role === "user") {
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
continue;
@@ -2773,7 +2869,7 @@ async function handleLoadChatHistory(clientWs) {
projectId,
});
}
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId });
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId, answeredBy });
}
clientWs.send(JSON.stringify({ type: "chat_history", messages }));
+18 -1
View File
@@ -12,7 +12,7 @@ services:
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--append-system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
@@ -53,6 +53,21 @@ services:
networks:
- aria-net
# ─── SearXNG (self-hosted Meta-Suche) ────────────────────
# Backend fuer das web_search-Tool (B1b). Aggregiert Google/Bing/Brave/… ohne
# API-Key, laeuft nur intern auf aria-net. Config: aria-data/searxng/settings.yml
# (JSON-Format aktiviert, Rate-Limiter aus fuer den Brain-Zugriff).
searxng:
image: searxng/searxng:latest
container_name: aria-searxng
volumes:
- ./aria-data/searxng:/etc/searxng
environment:
- SEARXNG_BASE_URL=http://searxng:8080/
restart: unless-stopped
networks:
- aria-net
# ─── ARIA Brain (Agent + Memory) ─────────────────────────
# Loest das alte aria-core (OpenClaw) ab. Vector-DB-basiertes
# Memory, eigener Agent-Loop, SSH zur aria-wohnung-VM.
@@ -86,6 +101,8 @@ services:
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
- RVS_TLS=${RVS_TLS:-true}
# SearXNG (self-hosted Meta-Suche) fuer das web_search-Tool (B1b).
- SEARXNG_URL=${SEARXNG_URL:-http://searxng:8080}
volumes:
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
+263
View File
@@ -0,0 +1,263 @@
# Plan B — Lokaler LLM-Router (Gamebox) neben Claude
**Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription
aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns
in <1 s; nur die schweren 20 % (Tiefe, Code, Tools, Pentest, langer Kontext)
gehen an Claude. Claude bleibt das Tiefen-Hirn.
## Warum das der einzige realistische Weg zu „live" ist
Gemessen (10.07.2026): CLI-Round-trip über den Claude-Max-Proxy hat einen
**harten Boden von ~3,5 s** (Subprozess-Start pro Turn). Streaming-API würde das
brechen, kostet aber API-Geld → verliert die Max-Subscription. Ein lokales
LLM für die einfachen Turns umgeht den 3,5-s-Boden komplett und ist **gratis**
(läuft auf vorhandener Gamebox-GPU). Echtes Speech-to-Speech-Duplex (Gemini
Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
## Modell & Serving (entschieden)
- **Modell:** Qwen3 8B, GGUF **Q4_K_M** (~6 GB). Bestes Tool-Calling der 7/8B-
Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
weniger Tool-Calling).
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der Gamebox
(kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~12 GB) + F5-TTS (~12 GB) →
~89 GB frei → passt. (FLUX ist auf 12 GB eh raus.)
## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)
Die Gamebox ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
Firewall) läuft das LLM **über den RVS-Token-Room**, exakt wie Whisper/F5-TTS:
- llama.cpp hört nur auf localhost der Gamebox.
- Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
schickt `llm_response` (korreliert per requestId) zurück.
- `rvs/server.js` `ALLOWED_TYPES` um `llm_request`, `llm_response` und (Phase 2)
`llm_partial` erweitern.
- Das Brain bekommt einen zweiten „Proxy" — nur über RVS statt direktem HTTP.
## Router-Logik im Brain
Reihenfolge pro Turn (früh raus = schnell):
- **Tier 0 — Fast-Path (existiert):** reine Steuerbefehle (Spotify, Licht) →
Skill direkt, **kein LLM**. <1 s.
- **Tier 1 — Lokal (Qwen3):** einfache Konversation, kurze Fakten, Smalltalk,
Bestätigungen. Ziel <1 s.
- **Tier 2 — Claude:** tief/technisch, Code, Tool-Use nötig, Pentest-Projekt,
langer/komplexer Kontext.
**Routing-Signal (heuristisch zuerst, deterministisch & schnell):**
Nachrichtenlänge, Schlüsselwörter, ob ein Tool nötig scheint, Projekt-Kontext
(Pentest-Projekt → immer Claude), Konversationstiefe.
**Escalation statt perfekter Vorab-Klassifikation:** Das lokale Modell bekommt
die Anweisung, bei Unsicherheit oder Tool-Bedarf **NICHT zu raten**, sondern zu
eskalieren (z.B. Antwort `<<ESCALATE>>`). Das Brain routet den Turn dann an
Claude. So sind Fehlklassifikationen billig — lieber einmal lokal→Claude als
eine falsche lokale Antwort.
**Modus „Nur lokales LLM" (Diagnostic-Checkbox, Eval-Schalter):** Ein Flag
`localLlmOnly` (in Diagnostic setzbar, vom Brain beim Routen gelesen). Ist es an:
JEDER Turn geht ans lokale LLM, `<<ESCALATE>>` / „zu schwer" werden ignoriert
(kein Claude-Fallback) — damit Stefan die echte Staerke/Schwaeche des lokalen
Modells sieht, ohne dass Claude die schweren Turns rettet. Haken aus = normale
Heuristik + Escalation. Ehrlicher Hinweis: im Nur-lokal-Modus funktionieren
werkzeug-abhaengige Turns (Wetter, Timer, Memory, Bild) nicht — das lokale Tier
hat keine Tools; das ist ein Gespraechs-Eval-Modus, kein Voll-ARIA. Fast-Path
(Spotify etc.) laeuft davon unberuehrt weiter.
## Persona auf BEIDEN Modellen
Das lokale Modell braucht ARIAs Identität, sonst bricht es aus der Rolle
(gelernt aus dem `--system-prompt`-Debakel). Aber **schlanker**:
- IDENTITY_SEED + Kern-Persona: ja.
- Volles Memory / ALLE Skill-Schemas: **nein** — nur eine **kuratierte, kleine
Tool-Auswahl** (siehe unten). Haelt den lokalen Prompt klein → schnell.
- Persona kommt lokal auch als echter System-Prompt (llama.cpp `system`-Rolle).
## Tool-Calling lokal (kuratierte Auswahl)
Das lokale LLM DARF Werkzeuge nutzen (Qwen3 = natives OpenAI-Tool-Calling, von
llama.cpp `--jinja` unterstuetzt). Ablauf wie bei Claude: Brain schickt
messages + tools → Qwen antwortet mit `tool_calls` → Brain fuehrt via
`_dispatch_tool` aus → Ergebnis zurueck → finale Antwort. Tool-Loop im Brain,
Ziel = lokales LLM statt Claude-Proxy.
**Awareness ≠ Authority.** Das lokale Modell soll WISSEN, was ARIA alles kann
(damit es gezielt eskaliert statt zu halluzinieren), aber nicht alles ausfuehren.
**Harte Grenze = Kontext/VRAM, nicht Misstrauen.** Das volle Tool-Schema sind
~15-20 K Tokens. Qwens Kontext steht auf 8 K (`LLM_CTX=8192`) — es passt nicht
rein. Hochdrehen auf 32 K kostet mehrere GB KV-Cache extra → OOM auf der
geteilten 12-GB-3060 (Whisper + F5-TTS liegen mit drauf). Claude im RZ hat
200 K-1 M Kontext und ist zuverlaessig → kann sich das ganze Arsenal leisten;
das lokale 8B auf Heim-Hardware nicht. Andere Hardware-Klasse, anderes Budget.
**Design (gibt „im Bilde" ohne VRAM zu sprengen):**
- **Ausfuehrbar lokal:** kleiner, risikoarmer Start-Satz — Wetter, Uhrzeit,
`memory_search` (lesen), `trigger_timer`, Spotify-Steuerung, Licht/Smart-Home.
- **Awareness-Liste (billig, ~paar hundert Tokens im System-Prompt):** kurze
Aufzaehlung des Rests — „ARIA kann ausserdem: Skills bauen, OAuth, Projekte,
Bilder, ins Gedaechtnis schreiben — dafuer `<<ESCALATE>>`." Kein volles Schema.
- **Bleibt bei Claude (Authority):** `skill_create/update/delete`, `oauth_*`,
`project_*`, `flux_generate`, `memory_save`.
Escalation-Netz bleibt: braucht ein Turn ein Tool, das lokal nicht ausfuehrbar
ist → `<<ESCALATE>>` → Claude mit vollem Arsenal. Der „Nur lokales LLM"-Haken
dient dazu, spaeter datengetrieben zu messen, ob der ausfuehrbare Satz erweitert
werden kann.
Implementierung (B1): Adapter reicht `tools` an llama.cpp + gibt `tool_calls`
zurueck; Bridge schleust beides durch (llm_request/llm_response); Brain-Tool-Loop
mit Ziel lokal.
## Phasen
- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der Gamebox,
`ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo").
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool-
Loop, siehe oben) + „Nur lokales LLM"-Checkbox. Einfache Turns → lokal.
Messen: Trefferquote, Tool-Zuverlaessigkeit & Latenz.
- **B2 — Streaming/Voice:** `llm_partial` → TTS beginnt beim ersten Satz →
der „live"-Sprung. **Hier den Gong-/Ohr-Re-Arm-Bug mit-fixen** (Barge-In,
sauberes Re-Listen).
- **B3 (optional):** lokalen Tool-Satz erweitern, sobald Qwen sich als
zuverlaessig erweist (z.B. `memory_save`).
## Offene Entscheidungen (für Stefan)
1. **Modell:** Qwen3 8B (Tool-Calling) — oder doch Mistral Small 3 7B (Speed)?
2. **Routing v1:** rein heuristisch + Escalation (entschieden).
3. **Tools lokal:** kuratierte kleine Auswahl (entschieden — Start-Satz oben;
Stefan bestaetigt/justiert die konkrete Liste vor dem B1-Bau).
## Folge-Baustein: Modell-Auswahl in ARIA Diagnostic (B0.5)
Ziel: In Diagnostic ein Modell auswählen; ist es nicht da, lädt der Container
es on-demand und aktiviert es. Spiegelt zwei bestehende Muster: den
`whisperModel`-Hotswap (RVS-Config-Broadcast → Bridge hot-swapped) und die
kuratierte Claude-Tier-Liste aus `models.json`.
**Kernproblem:** `llama.cpp`-Server serviert **ein** Modell pro Prozess —
„anderes aktivieren" = neu laden/swappen.
**Lösung: `llama-swap`** (Proxy vor llama.cpp): kennt eine Liste von Modellen,
lädt bei Anfrage das gewünschte on-demand (Download via `-hf` beim ersten Mal),
swappt bei VRAM-Knappheit das alte raus. OpenAI-kompatibel — der llm-adapter
zeigt statt auf `llama:8081` auf `llama-swap`.
**Bausteine:**
- `llama-swap`-Service in `xtts/docker-compose.yml` (ersetzt/ergänzt `llama`),
Config mit den verfügbaren Modellen (Name → `-hf`-Command).
- Kuratierte Liste `local_models.json` (analog `models.json`) — Diagnostic-UI
liest sie, zeigt Dropdown „Lokales Modell".
- Diagnostic → RVS-Config-Broadcast `localLlmModel` → llm-adapter setzt das
`model`-Feld seiner llama-swap-Requests → swap/Download passiert automatisch.
- Status zurück an Diagnostic (lädt / bereit / VRAM-OOM), analog whisper-Status.
**Konkret gewünschte UI (Stefan):**
- Modell-Status sichtbar: **lädt (mit Fortschrittsbalken) → heruntergeladen →
aktiviert**. Ist ein Modell schon im Cache: **nicht neu laden, nur
aktivieren** (llama.cpp/llama-swap macht das nativ ueber den Cache).
- **Testchat-Zeile** in Diagnostic: kurze Nachricht direkt ans lokale LLM
schicken, Antwort + Latenz anzeigen. Nutzt denselben RVS-Pfad
(`llm_request`/`llm_response`) wie der Self-Test — kein neuer Kanal noetig.
Bis dahin: **ein** Modell via `-hf` Auto-Download (B0, erledigt). Erst end-to-end
grün, dann dieser Komfort-Layer.
## Skalierung: VRAM, Multi-GPU, „Cluster"
**Wichtige Klarstellung:** Roher VRAM/GPU ist NICHT ueber RVS teilbar. RVS ist ein
Nachrichten-Relay; GPUs werden lokal per CUDA/PCIe angesprochen. Ueber RVS teilt
man **Inferenz-Faehigkeit** (transkribiere/vervollstaendige), nicht VRAM. Es gibt
daher keinen „GPU-Broker-Container", der Karten uebers Netz verleiht.
Skalierungspfade (echt):
- **Mehr Karten in EINER Box → VRAM-Pool.** llama.cpp/vLLM splitten ein Modell
ueber mehrere GPUs (`--tensor-split`). 2×3060 = 24 GB → groesseres Modell ODER
Qwen8B mit grossem Kontext → **volles Tool-Schema passt rein**. Das ist der
Weg zum „vollen Arsenal lokal".
- **Ein Modell ueber mehrere HOSTS splitten** (llama.cpp `--rpc`): moeglich, aber
langsam (Layer-Grenzen ueber's Netz) — nur schnelles LAN, fuer „schnell"
ungeeignet. Nicht empfohlen.
- **Mehrere eigenstaendige Modell-Server, je einer pro GPU/Host, Router waehlt:**
einfach, = unser RVS-Muster. Zweiter GPU-Host = noch ein llm-adapter, meldet
sich am RVS an, Router load-balanced. Das ist der sinnvolle „Cluster".
- **Innerhalb eines Hosts:** ein geteilter Inferenz-Server (`llama-swap`/vLLM)
statt VRAM-Duplikat pro Container — kommt mit B0.5.
**Diagnostic ⓘ (Feature):** Checkbox „volleres Arsenal" + Info-Icon mit
VRAM-Bedarf: 12 GB (1×3060) = kuratierte Tools; 24 GB (2×3060, eine Box) = Qwen
mit grossem Kontext/volles Schema oder groesseres Modell; Cluster = weitere
GPU-Hosts als Modell-Server ueber RVS. (B0.5/B1-UI.)
### „Waechter" / Orchestrator (Ausbaustufe, gestaffelt)
Idee: ein Dienst, der auf den am RVS angemeldeten Hosts Container startet/stoppt.
Zerfaellt in zwei Teile:
- **Billig & bald nuetzlich — Registrierung + Heartbeat:** jeder GPU-Host meldet
dem RVS „lebe, GPUs, VRAM frei, laufende Dienste" (kleine Erweiterung der
Adapter; whisper broadcastet schon Status). Nutzen: Diagnostic zeigt die
Flotte (Live-Daten fuers ⓘ), Router weiss ob lokal erreichbar (sonst Claude).
- **Teuer & aufschiebbar — Steuerung (Container start/stop):** Agent pro Host
(Docker-Socket) + Controller mit Placement-Policy + Reconciliation +
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
statt selbst einen Scheduler zu bauen.
### ENTSCHIEDEN: manuelle Platzierung + read-only GPU-Dashboard (kein Auto)
Statt Auto-Controller (Semi-Auto verworfen — Host wechselt selten, Komplexitaet
lohnt nicht):
- **Pin = Docker Compose Profiles.** Services kriegen `profiles: [...]`, jeder
Host setzt `COMPOSE_PROFILES=<seins>` in der `.env`; `docker compose up`
startet nur die eigenen. „In Config gepinnt", nativ, kein Code.
- **Verschiebe-Regel:** `up` auf neuem Host + `docker compose rm -sf <svc>` auf
altem (sonst holt `restart: unless-stopped` den Dienst beim Reboot zurueck →
Broadcast-Kollision; Profile gelten nur beim `up`, nicht beim Daemon-Restart).
- **GPU-Dashboard in Diagnostic (read-only):** jeder GPU-Host sendet periodisch
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
- **Zukunft (Gamebox3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up`
erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
Ohne Orchestrator.
### Verschieben-Button (Semi-Auto) — reboot-sicher via Platzierungs-Config
Wenn ein „Verschieben"-Button in Diagnostic gewuenscht ist (Dropdown Ziel-Host +
Button = hier stoppen, dort starten), braucht das remote Container-Steuerung →
**kleiner Agent pro GPU-Host** (Docker-Zugriff, hoert RVS-Befehle). Das ist der
zuvor „teure" Teil, aber in der DUMMEN Variante:
- **Eine Platzierungs-Config ist Single Source of Truth:**
`/shared/config/gpu_placement.json` = `{service: host}`.
- **Dummer Reconcile-Agent pro Host:** bei Start UND Config-Aenderung — starte
die mir zugewiesenen Dienste, stoppe die anderen. Keine Policy, kein
VRAM-Placement. Mensch = Scheduler (Button), Agent = befolgt nur Config.
- **Button aendert nur die Config** → Agenten reconcilen (alt stoppt, neu
startet). **Reboot liest Config** → kein Divergieren, keine Kollision.
- **Reboot-Falle vermieden:** NIE Laufzeit-Move ohne Config-Update (sonst holt
`restart: unless-stopped` den Dienst beim Reboot zurueck). Config = Wahrheit.
Deploy-Story: Code liegt via git auf allen Hosts (`pull`+`build`), aber `up -d`
startet nichts GPU-maessig von selbst — die Platzierungs-Config (bzw.
`COMPOSE_PROFILES`) entscheidet, was wo laeuft. Neuer Host = zuweisen, Agent
startet.
**Reihenfolge:** NACH B0/B1. Fallback ohne Button: reine `COMPOSE_PROFILES` pro
Host + Verschieben von Hand (null neue Infra).
## Nicht-Ziele
- Kein echter Gemini-Live-Duplex-Klon (Text-Modell als Hirn).
- FLUX bleibt optional/später (dickere GPU). Bild-Generierung separat als
pluggbarer Provider (ChatGPT/DALL·E-Alternative) — eigenes Feature, nicht Teil B.
+14 -8
View File
@@ -153,9 +153,12 @@ export function messagesToPrompt(messages, tools) {
/**
* Extrahiert NUR den System-Anteil (System-Messages + Tool-Use-Block) als
* rohen Text OHNE <system>-Tags. Fuer den ECHTEN System-Prompt-Kanal der
* Claude-CLI (--append-system-prompt), damit die ARIA-Persona nicht als
* <system>-getaggter User-Content ankommt (den das Modell als Injection wertet),
* sondern als genuine System-Instruktion.
* Claude-CLI (--system-prompt, VOLLER Replace nicht --append). Damit ist
* die ARIA-Persona DIE Identitaet des Modells und nicht ein Anhaengsel hinter
* Claude Codes eigener "You are Claude Code"-Identitaet (die bei duennem
* Kontext sonst gewinnt und die Persona als Injection abwehrt). Der Output
* muss deshalb SELBSTTRAGEND sein er ersetzt Claude Codes System-Prompt
* komplett inkl. dynamischer Sektionen (cwd, git, platform).
* Reihenfolge: erst der Tool-Use-Block (Format-Anweisung), dann die
* System-Messages in Original-Reihenfolge.
*/
@@ -217,12 +220,15 @@ export function conversationToPrompt(messages) {
export function openaiToCli(request) {
// Persona/System + Tool-Block gehen ueber den ECHTEN System-Prompt-Kanal
// (--append-system-prompt, siehe manager.js buildArgs-Patch). Der Prompt
// enthaelt nur noch den Gespraechsverlauf — so kann das Modell die
// ARIA-Vorgaben nicht mehr als <system>-getaggten User-Content und damit als
// Prompt-Injection fehldeuten.
// (--system-prompt = VOLLER Replace, siehe manager.js buildArgs-Patch in
// docker-compose.yml). Der Prompt enthaelt nur noch den Gespraechsverlauf.
// Voller Replace statt --append, weil Anhaengen Claude Codes eingebaute
// "You are Claude Code"-Identitaet stehen laesst — die bei duennem Kontext
// (Hauptchat) gewinnt und die ARIA-Persona als Injection abwehrt.
// systemPrompt ist immer ein String (extractSystemPrompt liefert "" statt
// undefined) → --append-system-prompt "" ist harmlos, kein spawn-Crash.
// undefined). ACHTUNG: bei --system-prompt darf er NIE leer sein, sonst
// laeuft das Modell ganz ohne System-Prompt — der Brain schickt aber immer
// eine System-Message + Tool-Block, also ist er real nie leer.
return {
prompt: conversationToPrompt(request.messages),
systemPrompt: extractSystemPrompt(request.messages, request.tools),
+58 -22
View File
@@ -19,6 +19,7 @@
*/
import { v4 as uuidv4 } from "uuid";
import http from "http";
import fs from "fs";
import { ClaudeSubprocess } from "../subprocess/manager.js";
import { openaiToCli } from "../adapter/openai-to-cli.js";
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
@@ -362,8 +363,8 @@ async function handleStreamingResponse(req, res, subprocess, cliInput, requestId
model: cliInput.model,
sessionId: cliInput.sessionId,
// ARIA: echter System-Prompt-Kanal — manager.js reicht das (sobald
// gepatcht) als --append-system-prompt an die CLI. Aktuell ignoriert
// ein ungepatchter manager diese Extra-Option gefahrlos.
// gepatcht) als --system-prompt (VOLLER Replace) an die CLI. Aktuell
// ignoriert ein ungepatchter manager diese Extra-Option gefahrlos.
systemPrompt: cliInput.systemPrompt,
}).catch((err) => {
console.error("[Streaming] Subprocess start error:", err);
@@ -452,29 +453,64 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
*
* Returns available models
*/
// Kuratierte Tier-Liste. ARIA laeuft ueber das Claude-Max-Abo via CLI —
// waehlbar ist der TIER (opus/sonnet/haiku), nicht eine feste Modellversion;
// die CLI loest den Alias aufs aktuelle Modell des Tiers auf. Die id-Strings
// muessen von openai-to-cli.js extractModel() erkannt werden (MODEL_MAP).
//
// Quelle: /shared/config/models.json — damit neue Tier-Namen oder angepasste
// Beschreibungen eine reine DATEI-Aenderung sind (kein Code-Edit, kein Neubau,
// kein Neustart: handleModels liest pro Request neu; einfach die Datei
// bearbeiten und im Diagnostic „Aktualisieren" druecken). Fehlt/kaputt die
// Datei, greifen die eingebauten Defaults; die Datei wird dann einmalig mit
// diesen Defaults angelegt, damit es was zu editieren gibt.
const MODELS_FILE = process.env.ARIA_MODELS_FILE || "/shared/config/models.json";
const DEFAULT_MODELS = [
{ id: "claude-sonnet-4", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
description: "Schnell & gut — Standard fuer den Alltag." },
{ id: "claude-opus-4", tier: "opus", display_name: "Opus (aktuell: Opus 4.8)",
description: "Langsamer, aber am schlausten — fuer schwere/lange Aufgaben." },
{ id: "claude-haiku-4", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
description: "Sehr schnell & guenstig, kleinerer Kontext — fuer einfache Tasks." },
];
function _loadModels() {
try {
const raw = fs.readFileSync(MODELS_FILE, "utf-8");
const arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) {
return arr;
}
console.error("[aria-models] models.json ungueltig — nutze Defaults");
} catch (_) {
// Datei fehlt (oder unlesbar) → Defaults + einmalig seeden zum Editieren
try {
fs.mkdirSync("/shared/config", { recursive: true });
if (!fs.existsSync(MODELS_FILE)) {
fs.writeFileSync(MODELS_FILE, JSON.stringify(DEFAULT_MODELS, null, 2));
console.error("[aria-models] models.json mit Defaults angelegt:", MODELS_FILE);
}
} catch (e) {
console.error("[aria-models] Seeden fehlgeschlagen:", e && e.message);
}
}
return DEFAULT_MODELS;
}
export function handleModels(_req, res) {
const created = Math.floor(Date.now() / 1000);
const models = _loadModels();
res.json({
object: "list",
data: [
{
id: "claude-opus-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-sonnet-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-haiku-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
],
data: models.map(m => ({
id: m.id,
object: "model",
owned_by: "anthropic",
created,
tier: m.tier || m.id,
display_name: m.display_name || m.id,
description: m.description || "",
})),
});
}
/**
+4
View File
@@ -62,6 +62,10 @@ const ALLOWED_TYPES = new Set([
"flux_request", "flux_response",
"agent_stream",
"oauth_callback",
// Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das
// Qwen3 auf der Gamebox (via Bridge → RVS → llm-adapter → llama.cpp).
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
"llm_request", "llm_response", "llm_partial",
]);
// Token-Raum: token -> { clients: Set<ws> }
+48
View File
@@ -89,3 +89,51 @@ services:
# Stimm-Embedding) persistent zwischen
# Container-Restarts.
restart: unless-stopped
# ─── Lokales LLM (Plan B, B0.5) — llama-swap (GPU) ────────────
# llama-swap laedt/swappt mehrere Modelle on-demand (nur eins passt gleich-
# zeitig in die 12 GB). Welches geladen wird, bestimmt das `model`-Feld im
# Request — das Brain schickt es aus local_llm.json mit. Erster Load eines
# Modells zieht das GGUF via -hf von HF (Cache unter /models, persistent).
# OpenAI-kompatibel auf :8080, nur im Compose-Netz; die Bruecke macht der
# llm-adapter. Modell-Liste: ./llama-swap/config.yaml.
#
# BLIND GEBAUT (kein Gamebox-Test hier): beim ersten Start
# `docker logs -f aria-llama-swap` pruefen. Image bundelt llama-server.
llama-swap:
image: ghcr.io/mostlygeek/llama-swap:unified-cuda
container_name: aria-llama-swap
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
- ./models:/models # HF-Download-Cache (persistent)
- ./llama-swap/config.yaml:/app/config.yaml:ro # Modell-Liste
environment:
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
command: ["--config", "/app/config.yaml", "--listen", "0.0.0.0:8080"]
restart: unless-stopped
# ─── Local-LLM-Adapter — RVS <-> llama.cpp (Plan B, B0) ──────
# Verbindet sich per Token an den RVS (wie f5tts/whisper), nimmt
# llm_request entgegen, ruft llama.cpp lokal, antwortet llm_response.
llm-adapter:
build: ./llm-adapter
container_name: aria-llm-adapter
depends_on:
- llama-swap
environment:
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN}
- LLAMA_URL=http://llama-swap:8080
- LLM_MODEL=${LLM_MODEL:-qwen3-8b}
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped
+42
View File
@@ -0,0 +1,42 @@
# llama-swap Modell-Liste fuer ARIA (Plan B, B0.5).
# Welches Modell geladen wird, bestimmt das `model`-Feld im Request (das Brain
# schickt es aus /shared/config/local_llm.json mit). llama-swap laedt es
# on-demand, swappt bei Bedarf (nur eins passt gleichzeitig in die 12 GB).
# Erster Load zieht das GGUF via -hf von Hugging Face (Cache unter /models).
#
# Die Modell-KEYS hier muessen zu local_models.json (Diagnostic-Dropdown) passen.
#
# healthCheckTimeout: Sekunden, die llama-swap auf "Modell bereit" wartet.
# GROSSZUEGIG, weil der erste Load ein GGUF (mehrere GB) herunterlaedt. Wenn der
# erste Download laenger dauert und abbricht: hier hochsetzen.
healthCheckTimeout: 1800
models:
# Standard — Qwen3 8B (~6 GB Q4). Bestes Tool-Calling, passt auf 12 GB.
"qwen3-8b":
cmd: |
llama-server --port ${PORT} --host 127.0.0.1
-hf Qwen/Qwen3-8B-GGUF:Q4_K_M
-ngl 99 -c 8192 --jinja
ttl: 3600 # nach 1h Idle entladen (VRAM freigeben)
# Kleiner + schneller — Qwen3 4B (~3 GB). Fuer noch flottere Antworten,
# etwas schwaecher. Guter A/B-Vergleich gegen 8B.
"qwen3-4b":
cmd: |
llama-server --port ${PORT} --host 127.0.0.1
-hf Qwen/Qwen3-4B-GGUF:Q4_K_M
-ngl 99 -c 8192 --jinja
ttl: 3600
# ── Vorlagen fuer spaeter (auskommentiert; brauchen mehr VRAM / 2. Karte) ──
# "qwen3-14b":
# cmd: |
# llama-server --port ${PORT} --host 127.0.0.1
# -hf Qwen/Qwen3-14B-GGUF:Q4_K_M -ngl 99 -c 8192 --jinja
# ttl: 3600
# "mistral-small-3":
# cmd: |
# llama-server --port ${PORT} --host 127.0.0.1
# -hf <mistral-small-3-gguf-repo>:Q4_K_M -ngl 99 -c 8192 --jinja
# ttl: 3600
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY adapter.py .
CMD ["python", "-u", "adapter.py"]
+66
View File
@@ -0,0 +1,66 @@
# Local-LLM-Adapter (Gamebox) — Plan B, Phase B0
Bringt ein lokales, schnelles LLM (Qwen3 8B) auf die Gamebox und haengt es
per RVS an ARIA — fuer die einfachen ~80 % der Turns (<1 s), waehrend Claude
das Tiefen-Hirn bleibt. Siehe `docs/plan-local-llm-router.md` im Repo-Root.
## Zwei Container (in `xtts/docker-compose.yml`)
- **`llama`** — `llama.cpp`-Server (CUDA), serviert das GGUF OpenAI-kompatibel
auf `:8081`, nur im Compose-Netz.
- **`llm-adapter`** — verbindet sich per Token an den RVS (wie f5tts/whisper),
nimmt `llm_request` entgegen, ruft `llama` lokal, antwortet `llm_response`.
## Modell — Auto-Download (nichts manuell ablegen)
`llama.cpp` zieht das GGUF beim **ersten Start selbst von Hugging Face** und
cached es unter `xtts/models/` (Bind-Mount → kein Re-Download bei Restart).
Default: **Qwen3 8B, Q4_K_M** aus dem offiziellen Repo `Qwen/Qwen3-8B-GGUF`.
Modell/Quant wechseln = in der `.env` der Gamebox setzen (kein Code):
```
LLM_HF_REPO=Qwen/Qwen3-8B-GGUF # HF-Repo
LLM_HF_QUANT=Q4_K_M # Quant-Tag (Q4_K_M, Q5_K_M, Q8_0, …)
LLM_CTX=8192 # Kontextfenster (kleiner = weniger VRAM)
```
Mistral statt Qwen testen (A/B): `LLM_HF_REPO` auf ein Mistral-Small-3-GGUF-Repo
umstellen + Container neu — Ein-Zeilen-Wechsel, kein Code.
> Der erste Start lädt mehrere GB — Log zeigt den Download-Fortschritt.
> Danach liegt das GGUF im Cache und der Start ist sofort.
**Modell-Auswahl in ARIA Diagnostic** (on-demand laden/aktivieren mehrerer
Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe
`docs/plan-local-llm-router.md`.
## Start (auf der Gamebox)
```bash
cd xtts
docker compose up -d --build llama llm-adapter
docker logs -f aria-llm-adapter # "RVS verbunden — llm-adapter online"
```
## Standalone-Test (ohne ARIA), direkt gegen llama.cpp
```bash
curl http://localhost:8081/v1/chat/completions -H "Content-Type: application/json" -d '{
"messages":[{"role":"system","content":"Du bist ARIA."},
{"role":"user","content":"sag kurz hallo"}],
"max_tokens":64
}'
```
## VRAM-Hinweis (RTX 3060, 12 GB)
whisper-small (~12) + f5tts (~12) + qwen3-8b-q4 (~6) ≈ 910 GB. Passt, aber
knapp. Bei OOM: `LLM_CTX` reduzieren, `-ngl` senken (weniger Layer auf GPU),
oder kleineres Quant (Q4_K_S / IQ4_XS).
## Nachrichten-Kontrakt (RVS)
- `llm_request``{ requestId, messages:[{role,content}], max_tokens?, temperature?, stop? }`
- `llm_response``{ requestId, ok, content, error?, model, elapsedMs }`
- `llm_partial` — reserviert fuer B2 (Token-Streaming), noch ungenutzt.
+228
View File
@@ -0,0 +1,228 @@
"""
ARIA Local-LLM-Adapter (Gamebox) Plan B, Phase B0.
Bruecke zwischen RVS und dem lokalen llama.cpp-Server. Spiegelt das Muster der
whisper-bridge: verbindet sich per WebSocket mit dem RVS (Token-Room, TLS mit
ws-Fallback, Reconnect-Backoff), lauscht auf `llm_request` und ruft den lokalen
llama.cpp-`/v1/chat/completions`-Endpoint (OpenAI-kompatibel), antwortet mit
`llm_response` (korreliert per requestId).
Topologie: Gamebox steht zuhause, ARIA im RZ die Kommunikation laeuft ueber
den RVS (wie TTS/STT), keine IPs zu pflegen. Nur URL + Token.
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN (wie f5tts/whisper)
LLAMA_URL Default http://llama:8081 (llama.cpp im selben Compose-Netz)
LLM_MODEL optionaler Modell-Name fuer llama (llama.cpp ignoriert ihn
meist, dient nur der Transparenz im Log)
LLM_TIMEOUT_SEC Default 60
Bewusst NICHT-streamend in B0 (volle llm_response). Token-Streaming (llm_partial)
kommt in B2 zusammen mit TTS-on-first-sentence.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import httpx
import websockets
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("llm-adapter")
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = os.getenv("RVS_PORT", "443").strip()
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
# Qwen3 hat Thinking-Mode default AN — dann verbraet es Tokens in einem
# <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen
# content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln
# (grübeln = harter Turn = Claude). Wir schalten Thinking daher per
# chat_template_kwargs ab (Qwen3-Template versteht enable_thinking=false;
# andere Templates ignorieren das kwarg). Bei einem Modell, das darauf
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() == "true"
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop, tools=None, model=None) -> dict:
"""Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt
{ok, content, tool_calls, error} zurueck wirft nie.
model: welches Modell llama-swap laden soll (B0.5). Kommt aus dem Request
(Brain -> local_llm.json). Faellt auf LLM_MODEL (env) zurueck.
tools: optionale OpenAI-Tool-Definitionen (B1b). Qwen3 (--jinja) kann
natives Tool-Calling und liefert dann message.tool_calls."""
body = {
"model": model or LLM_MODEL,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
}
if stop:
body["stop"] = stop
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
if LLM_DISABLE_THINKING:
# llama.cpp (--jinja) reicht chat_template_kwargs an die Chat-Vorlage
# weiter. Qwen3 unterdrueckt damit den <think>-Block.
body["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SEC) as client:
r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body)
r.raise_for_status()
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
return {
"ok": True,
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or None,
"usage": data.get("usage"),
}
except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
return {"ok": False, "content": "", "error": str(e)[:300]}
# B0.5-2: Lade-Status ans Diagnostic (service_status, service="llm"). Wir kennen
# den Download-Fortschritt nicht (llama-swap gibt ihn nicht her), aber wir melden
# den Zustand bei Modellwechsel: loading -> ready/error. _last_model = aktuell
# geladenes; _ready_models = in dieser Session schon einmal bereit gewesene
# (fuer den "frisch geladen"-Hinweis 🎉 bei langem Erst-Load).
_last_model = None
_ready_models: set = set()
async def _emit_llm_status(ws, state: str, model: str, **extra) -> None:
await _send(ws, "service_status",
{"service": "llm", "state": state, "model": model, **extra})
async def _handle_llm_request(ws, payload: dict) -> None:
global _last_model
req_id = payload.get("requestId", "")
messages = payload.get("messages") or []
if not isinstance(messages, list) or not messages:
await _send(ws, "llm_response", {
"requestId": req_id, "ok": False, "error": "leere/ungueltige messages",
})
return
max_tokens = int(payload.get("max_tokens", 512) or 512)
temperature = float(payload.get("temperature", 0.7) or 0.7)
stop = payload.get("stop")
tools = payload.get("tools") or None
model = (payload.get("model") or "").strip() or None
eff_model = model or LLM_MODEL
# Modellwechsel (oder erster Request) → llama-swap laedt/swappt: Status melden.
switching = eff_model != _last_model
if switching:
await _emit_llm_status(ws, "loading", eff_model)
t0 = time.time()
res = await _call_llama(messages, max_tokens=max_tokens,
temperature=temperature, stop=stop, tools=tools,
model=model)
dt = time.time() - t0
if switching:
if res.get("ok"):
fresh = (eff_model not in _ready_models) and dt > 25
_ready_models.add(eff_model)
_last_model = eff_model
await _emit_llm_status(ws, "ready", eff_model,
loadSeconds=round(dt, 1), freshlyDownloaded=fresh)
else:
# bei Fehler _last_model NICHT setzen → naechster Versuch meldet erneut loading
await _emit_llm_status(ws, "error", eff_model,
error=(res.get("error") or "")[:120])
tc = res.get("tool_calls")
logger.info("llm_request id=%s model=%s -> ok=%s %.2fs content_len=%d tool_calls=%d",
(req_id[:8] if req_id else "?"), model or LLM_MODEL, res.get("ok"), dt,
len(res.get("content") or ""), len(tc) if tc else 0)
await _send(ws, "llm_response", {
"requestId": req_id,
"ok": res.get("ok", False),
"content": res.get("content", ""),
"tool_calls": tc,
"error": res.get("error"),
"model": model or LLM_MODEL,
"elapsedMs": int(dt * 1000),
})
async def _run() -> None:
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
return
if not RVS_TOKEN:
logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
return
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try:
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
async with websockets.connect(
url, ping_interval=20, ping_timeout=10, max_size=16 * 1024 * 1024
) as ws:
logger.info("RVS verbunden — llm-adapter online")
retry_s = 2
tls_fallback_tried = False
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
if msg.get("type") != "llm_request":
continue
payload = msg.get("payload", {}) or {}
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
# aber wir blockieren so nicht den Empfang weiterer Messages.
asyncio.create_task(_handle_llm_request(ws, payload))
except Exception as e:
logger.warning("RVS-Verbindung verloren/fehlgeschlagen: %s", e)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
tls_fallback_tried = True
use_tls = False
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
use_tls = RVS_TLS
if __name__ == "__main__":
asyncio.run(_run())
+2
View File
@@ -0,0 +1,2 @@
websockets>=12.0
httpx>=0.27.0
View File