Compare commits

...
14 Commits
Author SHA1 Message Date
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
17 changed files with 648 additions and 96 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20003 versionCode 20004
versionName "0.2.0.3" versionName "0.2.0.4"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.0.3", "version": "0.2.0.4",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+19
View File
@@ -73,6 +73,9 @@ interface ChatMessage {
/** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu /** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
* Projekt-Bloecken zu gruppieren (auf/einklappbar). */ * Projekt-Bloecken zu gruppieren (auf/einklappbar). */
projectId?: string; 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 */ /** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string; messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */ /** 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 // App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst
// jeden Hint mit. Toggle in Settings. // jeden Hint mit. Toggle in Settings.
const [showSystemHints, setShowSystemHints] = useState(false); 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) // Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
const localXttsVoiceRef = useRef<string>(''); const localXttsVoiceRef = useRef<string>('');
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS) // Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
@@ -546,6 +551,8 @@ const ChatScreen: React.FC = () => {
setGpsEnabled(gps === 'true'); setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints'); const hints = await AsyncStorage.getItem('aria_show_hints');
setShowSystemHints(hints === 'true'); // default false setShowSystemHints(hints === 'true'); // default false
const src = await AsyncStorage.getItem('aria_show_source');
setShowSource(src === 'true'); // default false (Mama sieht keine Badges)
}; };
loadSettings(); loadSettings();
const interval = setInterval(loadSettings, 2000); const interval = setInterval(loadSettings, 2000);
@@ -815,6 +822,7 @@ const ChatScreen: React.FC = () => {
attachments: attachments.length ? attachments : undefined, attachments: attachments.length ? attachments : undefined,
backupTs: typeof m.ts === 'number' ? m.ts : undefined, backupTs: typeof m.ts === 'number' ? m.ts : undefined,
projectId: typeof m.project_id === 'string' ? m.project_id : '', projectId: typeof m.project_id === 'string' ? m.project_id : '',
answeredBy: typeof m.answeredBy === 'string' ? m.answeredBy : '',
...(cmid && { clientMsgId: cmid }), ...(cmid && { clientMsgId: cmid }),
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓) // Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }), ...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
@@ -1151,6 +1159,7 @@ const ChatScreen: React.FC = () => {
messageId: (message.payload.messageId as string) || undefined, messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined, backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '', 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' // ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet. // markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
@@ -2411,6 +2420,16 @@ const ChatScreen: React.FC = () => {
) : null} ) : null}
<View style={styles.statusRow}> <View style={styles.statusRow}>
<Text style={styles.timestamp}>{time}</Text> <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 ? ( {item.text.length > 0 ? (
<TouchableOpacity <TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}} 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 [bgGpsEnabled, setBgGpsEnabled] = useState(false);
const [backgroundMode, setBackgroundMode] = useState(true); // Default an const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [showSource, setShowSource] = useState(false); // Quell-Badge, Default aus
const [scannerVisible, setScannerVisible] = useState(false); const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live'); const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -261,6 +262,9 @@ const SettingsScreen: React.FC = () => {
// Default ist aus — nur explicit 'true' aktiviert // Default ist aus — nur explicit 'true' aktiviert
setShowSystemHints(saved === 'true'); setShowSystemHints(saved === 'true');
}); });
AsyncStorage.getItem('aria_show_source').then(saved => {
setShowSource(saved === 'true'); // Default aus
});
// gpsTrackingService status syncen + auf Aenderungen lauschen // gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive()); setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking); const offGps = gpsTrackingService.onChange(setGpsTracking);
@@ -857,6 +861,11 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {}); 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 --- // --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => { const selectVoice = useCallback((voiceName: string) => {
@@ -1580,6 +1589,22 @@ const SettingsScreen: React.FC = () => {
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'} thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
/> />
</View> </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> </View>
{/* === Hintergrund-Modus === */} {/* === Hintergrund-Modus === */}
+228 -44
View File
@@ -21,12 +21,13 @@ import logging
import os import os
import re import re
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from typing import Optional from typing import Optional
from conversation import Conversation, Turn from conversation import Conversation, Turn
from memory import Embedder, VectorStore, MemoryPoint from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break
from proxy_client import ProxyClient, Message as ProxyMessage from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod import router as router_mod
from local_llm import local_llm_chat from local_llm import local_llm_chat
@@ -37,6 +38,8 @@ import oauth as oauth_mod
import projects as projects_mod import projects as projects_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") 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 # 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 # 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. # synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
@@ -66,6 +69,65 @@ def _load_flux_config() -> dict:
logger = logging.getLogger(__name__) 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-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [ META_TOOLS = [
{ {
@@ -88,6 +150,17 @@ META_TOOLS = [
"Stefan sich alle 60min manuell neu einloggen.\n" "Stefan sich alle 60min manuell neu einloggen.\n"
" - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): " " - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
"ueber `config_schema` deklarieren, NICHT hardcoden.\n\n" "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 " "HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
"pip-Library. Sonst muesste der Install bei jedem Container-Restart " "pip-Library. Sonst muesste der Install bei jedem Container-Restart "
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n" "neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
@@ -136,17 +209,28 @@ META_TOOLS = [
"description": ( "description": (
"OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify " "OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
"pause/next, Rollade hoch/runter etc.) eine Liste von " "pause/next, Rollade hoch/runter etc.) eine Liste von "
"[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen " "[{match, args, reply}]. Bei match (anchored Regex, "
"match (anchored Regex, case-insensitive) matched, ruft das " "case-insensitive, gegen den normalisierten Text: lowercase, "
"Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — " "Endsatzzeichen weg) ruft das Brain run_skill(name, args) DIREKT "
"ohne Claude (~5s Latenz gespart). Match wird gegen den " "auf und gibt reply zurueck — ohne LLM (instant).\n\n"
"normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; " "ROBUST SCHREIBEN (wichtig!):\n"
"schreibe Patterns mit ^...$ damit nur exakte Befehle matchen " "- Immer ^...$ (nur ganze Befehle, keine Teilstrings).\n"
"und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer " "- **Wortstellung + Synonyme + Fuellwoerter abdecken** in EINEM "
"Skills mit kreativem Output / parametrisierter Logik — die " "Pattern via Alternativen und optionalen Gruppen. Nicht nur die "
"brauchen Claude. Beispiel: " "eine Formulierung! Beispiel Pause: "
"[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"}," "`^(spotify |musik )?(pause|pausier(e|en)?|stop|stopp|halt|"
"\"reply\":\"Spotify: pausiert ⏸\"}]" "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 ⏸\"}]"
), ),
}, },
}, },
@@ -1055,55 +1139,123 @@ class Agent:
return reply return reply
return None return None
# ── Fast-Lane: lokales schnelles LLM (Plan B, B1a) ── # ── Fast-Lane: lokales schnelles LLM (Plan B, B1a/B1b) ──
# #
# Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns beantwortet # Zwischen Skill-Fast-Path und Claude-Loop: einfache Turns beantwortet das
# das lokale Qwen (schlanker Prompt, KEINE Tools) in <1 s. Gated ueber # 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). # /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude. # 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
# 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, def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]: active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config() cfg = router_mod.load_config()
if not router_mod.should_try_local(user_message, cfg): if not router_mod.should_try_local(user_message, cfg):
return None return None
local_only = bool(cfg.get("localOnly"))
tools = self._build_local_tools() # B1b: kuratierte Tools
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR) sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
window = self.conversation.window(project_id=active_project_id) 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": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window] messages += [{"role": t.role, "content": t.content} for t in window]
res = local_llm_chat(messages, max_tokens=400, temperature=0.5) # Tool-Loop: lokales Modell darf web_search/memory_search/trigger_timer/
local_only = bool(cfg.get("localOnly")) # 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)
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
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 (tresult or "").strip().startswith("FEHLER"):
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.]"
if not res.get("ok"): # Sicherheitsnetz: das lokale Modell wickelt seine Antwort manchmal
logger.info("[router] lokal fehlgeschlagen (%s) — %s", # faelschlich komplett in <voice>...</voice> (aus dem Kontext imitiert).
res.get("error"), "kein Fallback (localOnly)" if local_only else "→ Claude") # Das wuerde die Anzeige leeren (Display strippt <voice>). Tags raus —
if local_only: # der lokale Reply ist kurz, Plain-Text dient Anzeige UND TTS.
# Eval-Modus: KEIN Claude. Ehrliche Fehlermeldung statt Stille. final = re.sub(r"</?voice>", "", final).strip()
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
return None
content = (res.get("content") or "").strip()
if local_only: if local_only:
# Erzwungen lokal: Escalation-Marker ignorieren, Antwort so nehmen. final = final.replace(router_mod.ESCALATE_MARKER, "").strip()
content = content.replace(router_mod.ESCALATE_MARKER, "").strip() if not final:
if not content:
return "[Lokales LLM lieferte keine Antwort.]" return "[Lokales LLM lieferte keine Antwort.]"
logger.info("[router] lokal (localOnly) %sms", res.get("elapsedMs")) logger.info("[router] lokal (localOnly) beantwortet")
self.conversation.add("assistant", content, project_id=active_project_id) self.conversation.add("assistant", final, project_id=active_project_id)
return content return final
# Normalbetrieb: leere Antwort oder Escalation-Marker → Claude. # Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein
if not content or router_mod.ESCALATE_MARKER in content: # 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") logger.info("[router] lokal eskaliert → Claude")
return None return None
logger.info("[router] lokal beantwortet in %sms (%d Zeichen)", logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
res.get("elapsedMs"), len(content)) self.conversation.add("assistant", final, project_id=active_project_id)
self.conversation.add("assistant", content, project_id=active_project_id) return final
return content
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ── # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
@@ -1111,7 +1263,7 @@ class Agent:
def chat(self, user_message: str, source: str = "", def chat(self, user_message: str, source: str = "",
project_id: Optional[str] = None, 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 """Verarbeitet eine User-Nachricht — pro Request project_id explizit
angegeben (leer = Hauptchat). Kein globaler active_project-State mehr — angegeben (leer = Hauptchat). Kein globaler active_project-State mehr —
so laufen parallele /chat-Requests fuer verschiedene Projekte echt so laufen parallele /chat-Requests fuer verschiedene Projekte echt
@@ -1145,7 +1297,7 @@ class Agent:
self.conversation.add("assistant", fast_reply, project_id=active_project_id) self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id: if active_project_id:
projects_mod.touch_project(active_project_id) projects_mod.touch_project(active_project_id)
return fast_reply return fast_reply, "fast-path"
# 1. User-Turn an die Konversation # 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source, self.conversation.add("user", user_message, source=source,
@@ -1159,7 +1311,7 @@ class Agent:
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
local_reply = self._try_local_fast_lane(user_message, active_project_id) local_reply = self._try_local_fast_lane(user_message, active_project_id)
if local_reply is not None: if local_reply is not None:
return local_reply return local_reply, "local"
# 2. Hot Memory (alle pinned Punkte) # 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned() hot = self.store.list_pinned()
@@ -1334,10 +1486,33 @@ class Agent:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc) logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise 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 # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply, self.conversation.add("assistant", final_reply,
project_id=active_project_id) project_id=active_project_id)
return final_reply return final_reply, "claude"
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -1778,6 +1953,15 @@ class Agent:
except Exception as e: except Exception as e:
logger.exception("memory_search fehlgeschlagen") logger.exception("memory_search fehlgeschlagen")
return f"FEHLER: {e}" 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": if name == "memory_update":
pid = (arguments.get("id") or "").strip() pid = (arguments.get("id") or "").strip()
if not pid: if not pid:
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try: try:
agent = agent_factory() agent = agent_factory()
reply = agent.chat(prompt, source="trigger") reply, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events() events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+17 -8
View File
@@ -41,19 +41,28 @@ import shutil
import sys import sys
from pathlib import Path from pathlib import Path
# "claude code" ist fuer sich genommen noch kein Beweis (Stefan und ARIA reden # STARKE, selbstreferenzielle Break-Marker — identisch zu prompts._IDENTITY_BREAK
# im Dev-Kontext legitim ueber Claude Code). Erst in Kombination mit einem # (dem Laufzeit-Gift-Waechter). Hier dupliziert, damit das Script self-contained
# zweiten Ablehnungs-Marker ist es eindeutig eine aus-der-Rolle-Antwort. # ist (laeuft auch auf dem Host-Python ohne qdrant/prompts-Import). Bewusst NICHT
_PRIMARY = re.compile(r"claude\s*code", re.IGNORECASE) # das blosse Wort "injizier"/"prompt injection" — das nutzt ARIA in Pentest-
_SECONDARY = re.compile( # Antworten legitim (sonst False Positives auf echte Security-Doku, wie im
r"injiz|inject|fabriz|fabricat|adoptier|adopting|" # Dry-Run gesehen: "Runde 60 … SSRF", "Dein Ziel: LLM …").
r"prompt[\s-]*injection|keine echten|nicht (?:real|adopt)", _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, re.IGNORECASE,
) )
def is_poison(content: str) -> bool: def is_poison(content: str) -> bool:
return bool(_PRIMARY.search(content) and _SECONDARY.search(content)) return bool(_BREAK.search(content or ""))
def get_content(obj: dict) -> str: def get_content(obj: dict) -> str:
+6 -2
View File
@@ -29,14 +29,18 @@ LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC",
def local_llm_chat(messages: list, *, max_tokens: int = 512, def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None) -> dict: temperature: float = 0.7, stop=None, tools=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...]. """Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
Blockierend (urllib) — im Brain laeuft chat() ohnehin im Executor-Thread.""" 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: if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"} return {"ok": False, "error": "messages leer/ungueltig"}
req = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature} req = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature}
if stop: if stop:
req["stop"] = stop req["stop"] = stop
if tools:
req["tools"] = tools
try: try:
body = json.dumps(req).encode("utf-8") body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request( http_req = urllib.request.Request(
+5 -1
View File
@@ -630,6 +630,9 @@ class ChatOut(BaseModel):
turns: int turns: int
distilling: bool distilling: bool
events: list = Field(default_factory=list) 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"
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -713,7 +716,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken — # Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen. # chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
reply = await loop.run_in_executor( reply, answered_by = await loop.run_in_executor(
None, None,
lambda: a.chat( lambda: a.chat(
body.message, source=body.source, project_id=pid, body.message, source=body.source, project_id=pid,
@@ -735,6 +738,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
distilling=needs_distill, distilling=needs_distill,
events=a.pop_events(), events=a.pop_events(),
project_id=pid, project_id=pid,
answered_by=answered_by,
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+31
View File
@@ -15,6 +15,7 @@ mit dem Conversation-Loop in spaeteren Phasen.
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import List from typing import List
@@ -77,6 +78,36 @@ IDENTITY_SEED = (
) )
# 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: def build_time_section() -> str:
"""Aktueller Zeitstempel — damit ARIA Timer korrekt anlegen kann """Aktueller Zeitstempel — damit ARIA Timer korrekt anlegen kann
und Watcher-Conditions mit hour_of_day etc. einordenbar bleiben.""" und Watcher-Conditions mit hour_of_day etc. einordenbar bleiben."""
+44 -22
View File
@@ -57,15 +57,17 @@ def load_config() -> dict:
# lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation # lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation
# faengt zusaetzlich das <<ESCALATE>> im Modell selbst ab. # faengt zusaetzlich das <<ESCALATE>> im Modell selbst ab.
# Wortstämme, die auf Tool-/Aktionsbedarf hindeuten → nicht lokal (in B1a). # 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( _TOOL_HINTS = re.compile(
r"\b(wetter|temperatur|regne|timer|erinner|weck|licht|lampe|steckdose|" r"\b(bild|generier|male?\b|malen|zeichne|foto|"
r"spotify|musik|lied|song|playlist|lauter|leiser|" r"skill|projekt|oauth|"
r"bild|generier|male?\b|zeichne|foto|" r"licht|lampe|steckdose|rollade|heizung|"
r"merk dir|memory|gedächtnis|erinnere dich|" r"kalender|termin|"
r"skill|trigger|projekt|oauth|spotify|kalender|termin|" r"maild?|e-?mail|nachricht schreiben)\b",
r"such(e|st)?\b|google|internet|news|nachrichten|aktie|kurs|"
r"maild?|email|nachricht schreiben|sende)\b",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -104,31 +106,51 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
# (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas, # (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas,
# kein volles Memory (B1a). # kein volles Memory (B1a).
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
_AWARENESS = ( _AWARENESS = (
"ARIA kann ausserdem (ueber ihr grosses Backend, nicht in diesem schnellen " "Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, "
"Modus): Wetter/Zeit/Websuche, Timer & Erinnerungen, Musik (Spotify) & Licht " "Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, "
"steuern, Bilder generieren, ins Gedaechtnis schreiben/suchen, Skills bauen, " "sowie tiefe/technische Analysen und langen Code."
"Projekte & OAuth verwalten."
) )
def build_local_system_prompt(identity_anchor: str, pinned_persona: str = "") -> str: 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 """Schlanker System-Prompt fuers lokale LLM. identity_anchor = derselbe
Anker wie bei Claude (Rolle haelt); pinned_persona optional kurz.""" Anker wie bei Claude (Rolle haelt)."""
parts = [ parts = [
identity_anchor.strip(), identity_anchor.strip(),
"", "",
"## SCHNELL-MODUS", "## SCHNELL-MODUS",
"Du laeufst gerade als schnelles lokales Modell fuer einfache, kurze " "Du laeufst gerade als schnelles lokales Modell fuer Alltags-Konversation "
"Alltags-Konversation. Antworte knapp, freundlich, auf Deutsch, als ARIA.", "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 TUST", "## WAS DU HIER NICHT KANNST",
_AWARENESS, _AWARENESS,
"Fuer alles, was ein Werkzeug/eine Aktion braucht, tiefer/technischer wird, " "Dafuer — und bei tiefen/technischen Fragen, langem Code, oder wenn du "
"aktuelles Wissen oder Nachschlagen verlangt, oder wo du unsicher bist: " f"unsicher bist — antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` "
f"antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` (nichts sonst). " "(nichts sonst). Dann uebernimmt das grosse Modell mit vollem Zugriff. "
"Dann uebernimmt automatisch das grosse Modell mit vollem Zugriff. " "Lieber einmal eskalieren als falsch raten oder ein Werkzeug erfinden.",
"Lieber einmal eskalieren als falsch raten.",
] ]
if pinned_persona.strip(): if pinned_persona.strip():
parts += ["", "## PERSONA", pinned_persona.strip()] parts += ["", "## PERSONA", pinned_persona.strip()]
+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
+19 -5
View File
@@ -1257,12 +1257,14 @@ class ARIABridge:
# Voice-Tag-Noise als Kontext sieht). # Voice-Tag-Noise als Kontext sieht).
# File-Marker werden separat als file_from_aria-Events ausgeliefert. # File-Marker werden separat als file_from_aria-Events ausgeliefert.
display_text = strip_voice_tag_for_display(text) 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({ assistant_backup_ts = self._append_chat_backup({
"role": "assistant", "role": "assistant",
"text": display_text, "text": display_text,
"files": [{"serverPath": f["serverPath"], "name": f["name"], "files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files], "mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
"project_id": turn_pid, "project_id": turn_pid,
"answeredBy": _answered_by,
}) })
metadata = payload.get("metadata", {}) metadata = payload.get("metadata", {})
@@ -1305,6 +1307,8 @@ class ARIABridge:
# Projekt-Zuordnung — App + Diagnostic sortieren die Bubble in # Projekt-Zuordnung — App + Diagnostic sortieren die Bubble in
# den passenden Projekt-Block. Leer = Hauptchat. # den passenden Projekt-Block. Leer = Hauptchat.
"projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "", "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), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -1633,6 +1637,9 @@ class ARIABridge:
# gegeben damit der chat-Broadcast die Bubble dem richtigen Projekt- # gegeben damit der chat-Broadcast die Bubble dem richtigen Projekt-
# Block in App + Diagnostic zuordnen kann. # Block in App + Diagnostic zuordnen kann.
turn_project_id = (data.get("project_id") or "").strip() 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()
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
@@ -1699,7 +1706,8 @@ class ARIABridge:
# passend behandelt wird (hier minimal, weil Brain noch keine # passend behandelt wird (hier minimal, weil Brain noch keine
# metadata mitschickt). # metadata mitschickt).
try: try:
await self._process_core_response(reply, {"projectId": turn_project_id}) await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)
@@ -3367,9 +3375,10 @@ class ARIABridge:
_LLM_TIMEOUT_S = 30.0 _LLM_TIMEOUT_S = 30.0
async def _local_llm(self, messages: list, max_tokens: int = 512, async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None) -> dict: temperature: float = 0.7, stop=None, tools=None) -> dict:
"""Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf """Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf
llm_response. Rueckgabe: {ok, content, model, elapsedMs} oder {ok:False, error}.""" 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: if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"} return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
if not isinstance(messages, list) or not messages: if not isinstance(messages, list) or not messages:
@@ -3388,8 +3397,10 @@ class ARIABridge:
} }
if stop: if stop:
req_payload["stop"] = stop req_payload["stop"] = stop
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d)", if tools:
request_id[:8], len(messages), max_tokens) req_payload["tools"] = tools
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0)
ok = await self._send_to_rvs({ ok = await self._send_to_rvs({
"type": "llm_request", "type": "llm_request",
"payload": req_payload, "payload": req_payload,
@@ -3407,6 +3418,7 @@ class ARIABridge:
return { return {
"ok": True, "ok": True,
"content": result.get("content", ""), "content": result.get("content", ""),
"tool_calls": result.get("tool_calls"),
"model": result.get("model"), "model": result.get("model"),
"elapsedMs": result.get("elapsedMs"), "elapsedMs": result.get("elapsedMs"),
} }
@@ -3885,9 +3897,11 @@ class ARIABridge:
temperature = float(data.get("temperature")) temperature = float(data.get("temperature"))
except (TypeError, ValueError): except (TypeError, ValueError):
temperature = 0.7 temperature = 0.7
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
result = await self._local_llm( result = await self._local_llm(
messages=messages, max_tokens=max_tokens, messages=messages, max_tokens=max_tokens,
temperature=temperature, stop=data.get("stop"), temperature=temperature, stop=data.get("stop"),
tools=_tools,
) )
status = 200 if result.get("ok") else 502 status = 200 if result.get("ok") else 502
await _send_response(writer, status, result) await _send_response(writer, status, result)
+138 -2
View File
@@ -910,6 +910,56 @@
</div> </div>
</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>
<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>
<!-- OpenClaw-Config-Sektion entfernt — aria-core ist raus. --> <!-- OpenClaw-Config-Sektion entfernt — aria-core ist raus. -->
</div><!-- /tab-settings --> </div><!-- /tab-settings -->
@@ -1521,6 +1571,8 @@
try { loadBrainStatus(); } catch {} try { loadBrainStatus(); } catch {}
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy) // Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
try { loadModelList(); } catch {} try { loadModelList(); } catch {}
// Lokales-LLM-Schalter aus /shared/config/local_llm.json laden
try { loadLocalLlmConfig(); } catch {}
}; };
// Brain-Status periodisch refreshen damit die Card live bleibt // Brain-Status periodisch refreshen damit die Card live bleibt
@@ -1839,6 +1891,7 @@
ttsText: p.ttsText, ttsText: p.ttsText,
backupTs: p.backupTs, backupTs: p.backupTs,
projectId: p.projectId || '', projectId: p.projectId || '',
answeredBy: p.answeredBy || '',
}); });
return; return;
} }
@@ -1940,7 +1993,8 @@
const trashBtn = m.ts const trashBtn = m.ts
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>` ? `<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) { for (const b of boxes) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `chat-msg ${m.type}`; el.className = `chat-msg ${m.type}`;
@@ -2271,6 +2325,18 @@
return t.trim(); 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) { function addChat(type, text, meta, options) {
// [FILE: /shared/uploads/aria_xxx.ext]-Marker aus dem Antworttext entfernen — // [FILE: /shared/uploads/aria_xxx.ext]-Marker aus dem Antworttext entfernen —
// die Datei kommt separat via file_from_aria-Event als eigene Bubble. // die Datei kommt separat via file_from_aria-Event als eigene Bubble.
@@ -2305,7 +2371,9 @@
const trashBtn = backupTs const trashBtn = backupTs
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${backupTs})">🗑</button>` ? `<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 // Thinking-Indikator ausblenden bei neuer Nachricht
updateThinkingIndicator({ activity: 'idle' }); updateThinkingIndicator({ activity: 'idle' });
@@ -5856,6 +5924,28 @@
// Vor-definierte Info-Blocks // Vor-definierte Info-Blocks
const INFO_TEXTS = { const INFO_TEXTS = {
'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': { 'brain-status': {
title: 'Gehirn — Status', title: 'Gehirn — Status',
html: ` html: `
@@ -6147,6 +6237,52 @@
if (st) { st.textContent = 'Gesetzt (Freitext) — aria-brain neu starten, damit es greift.'; st.style.color = '#FFD60A'; } 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';
}
}
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';
setLocalLlmStatus(c);
} catch (e) { /* still */ }
}
async function saveLocalLlmConfig() {
const body = {
enabled: document.getElementById('local-llm-enabled').checked,
localOnly: document.getElementById('local-llm-onlylocal').checked,
toolVariant: document.getElementById('local-llm-toolvariant').value,
};
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 ────────────────────── // ── Einstellungen: OpenClaw Config ──────────────────────
// loadOpenClawConfig entfernt — aria-core ist raus. // loadOpenClawConfig entfernt — aria-core ist raus.
+49 -1
View File
@@ -297,6 +297,35 @@ function writeRuntimeConfig(patch) {
} }
// Atomic write: temp-file + rename, laute Logs bei Fehler. // 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",
};
} catch {
return { enabled: false, localOnly: false, toolVariant: "slim" };
}
}
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;
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;
}
// ── File-Project-Manifest ─────────────────────────────────────────── // ── File-Project-Manifest ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat). // Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt. // Wird vom files-list-Endpoint + files-set-project gepflegt.
@@ -1573,6 +1602,24 @@ const server = http.createServer((req, res) => {
} }
}); });
return; return;
} 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") { } else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding // RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
@@ -2769,6 +2816,7 @@ async function handleLoadChatHistory(clientWs) {
const ts = obj.ts || 0; const ts = obj.ts || 0;
const text = String(obj.text || ""); const text = String(obj.text || "");
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung 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 (obj.role === "user") {
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId }); if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
continue; continue;
@@ -2795,7 +2843,7 @@ async function handleLoadChatHistory(clientWs) {
projectId, 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 })); clientWs.send(JSON.stringify({ type: "chat_history", messages }));
+17
View File
@@ -53,6 +53,21 @@ services:
networks: networks:
- aria-net - 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) ───────────────────────── # ─── ARIA Brain (Agent + Memory) ─────────────────────────
# Loest das alte aria-core (OpenClaw) ab. Vector-DB-basiertes # Loest das alte aria-core (OpenClaw) ab. Vector-DB-basiertes
# Memory, eigener Agent-Loop, SSH zur aria-wohnung-VM. # Memory, eigener Agent-Loop, SSH zur aria-wohnung-VM.
@@ -86,6 +101,8 @@ services:
- RVS_HOST=${RVS_HOST:-} - RVS_HOST=${RVS_HOST:-}
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}} - RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
- RVS_TLS=${RVS_TLS:-true} - RVS_TLS=${RVS_TLS:-true}
# SearXNG (self-hosted Meta-Suche) fuer das web_search-Tool (B1b).
- SEARXNG_URL=${SEARXNG_URL:-http://searxng:8080}
volumes: volumes:
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export) - ./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) - ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
+21 -7
View File
@@ -69,9 +69,12 @@ async def _send(ws, mtype: str, payload: dict) -> None:
async def _call_llama(messages: list, *, max_tokens: int, temperature: float, async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop) -> dict: stop, tools=None) -> dict:
"""Ruft llama.cpp /v1/chat/completions (OpenAI-Format). Gibt """Ruft llama.cpp /v1/chat/completions (OpenAI-Format). Gibt
{ok, content, error} zurueck wirft nie.""" {ok, content, tool_calls, error} zurueck wirft nie.
tools: optionale OpenAI-Tool-Definitionen (B1b). llama.cpp (--jinja) mit
Qwen3 kann natives Tool-Calling und liefert dann message.tool_calls."""
body = { body = {
"model": LLM_MODEL, "model": LLM_MODEL,
"messages": messages, "messages": messages,
@@ -81,6 +84,9 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
} }
if stop: if stop:
body["stop"] = stop body["stop"] = stop
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
if LLM_DISABLE_THINKING: if LLM_DISABLE_THINKING:
# llama.cpp (--jinja) reicht chat_template_kwargs an die Chat-Vorlage # llama.cpp (--jinja) reicht chat_template_kwargs an die Chat-Vorlage
# weiter. Qwen3 unterdrueckt damit den <think>-Block. # weiter. Qwen3 unterdrueckt damit den <think>-Block.
@@ -90,8 +96,13 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body) r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
content = (data.get("choices") or [{}])[0].get("message", {}).get("content", "") msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
return {"ok": True, "content": content or "", "usage": data.get("usage")} return {
"ok": True,
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or None,
"usage": data.get("usage"),
}
except Exception as e: except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e) logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
return {"ok": False, "content": "", "error": str(e)[:300]} return {"ok": False, "content": "", "error": str(e)[:300]}
@@ -108,17 +119,20 @@ async def _handle_llm_request(ws, payload: dict) -> None:
max_tokens = int(payload.get("max_tokens", 512) or 512) max_tokens = int(payload.get("max_tokens", 512) or 512)
temperature = float(payload.get("temperature", 0.7) or 0.7) temperature = float(payload.get("temperature", 0.7) or 0.7)
stop = payload.get("stop") stop = payload.get("stop")
tools = payload.get("tools") or None
t0 = time.time() t0 = time.time()
res = await _call_llama(messages, max_tokens=max_tokens, res = await _call_llama(messages, max_tokens=max_tokens,
temperature=temperature, stop=stop) temperature=temperature, stop=stop, tools=tools)
dt = time.time() - t0 dt = time.time() - t0
logger.info("llm_request id=%s -> ok=%s %.2fs content_len=%d", tc = res.get("tool_calls")
logger.info("llm_request id=%s -> ok=%s %.2fs content_len=%d tool_calls=%d",
(req_id[:8] if req_id else "?"), res.get("ok"), dt, (req_id[:8] if req_id else "?"), res.get("ok"), dt,
len(res.get("content") or "")) len(res.get("content") or ""), len(tc) if tc else 0)
await _send(ws, "llm_response", { await _send(ws, "llm_response", {
"requestId": req_id, "requestId": req_id,
"ok": res.get("ok", False), "ok": res.get("ok", False),
"content": res.get("content", ""), "content": res.get("content", ""),
"tool_calls": tc,
"error": res.get("error"), "error": res.get("error"),
"model": LLM_MODEL, "model": LLM_MODEL,
"elapsedMs": int(dt * 1000), "elapsedMs": int(dt * 1000),