Compare commits
14
Commits
c8b7ea322e
...
v0.2.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddcf665f0 | ||
|
|
7ae61701ad | ||
|
|
9cc1aec5ec | ||
|
|
53c098a9c8 | ||
|
|
0e42cf775f | ||
|
|
0e0c5d742f | ||
|
|
5b20bc1743 | ||
|
|
c3652d2f8e | ||
|
|
3cd7350160 | ||
|
|
a58aa5594d | ||
|
|
3a3c14fdc5 | ||
|
|
1d4f23ada3 | ||
|
|
b967999a5f | ||
|
|
71b3fc5d31 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20003
|
||||
versionName "0.2.0.3"
|
||||
versionCode 20004
|
||||
versionName "0.2.0.4"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.0.3",
|
||||
"version": "0.2.0.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -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.
|
||||
@@ -2411,6 +2420,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}}
|
||||
|
||||
@@ -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 === */}
|
||||
|
||||
+228
-44
@@ -21,12 +21,13 @@ 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, 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
|
||||
import router as router_mod
|
||||
from local_llm import local_llm_chat
|
||||
@@ -37,6 +38,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.
|
||||
@@ -66,6 +69,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 = [
|
||||
{
|
||||
@@ -88,6 +150,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"
|
||||
@@ -136,17 +209,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 ⏸\"}]"
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -1055,55 +1139,123 @@ class Agent:
|
||||
return reply
|
||||
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
|
||||
# das lokale Qwen (schlanker Prompt, KEINE Tools) in <1 s. Gated ueber
|
||||
# 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
|
||||
|
||||
# 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"))
|
||||
tools = self._build_local_tools() # B1b: kuratierte Tools
|
||||
|
||||
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR)
|
||||
window = self.conversation.window(project_id=active_project_id)
|
||||
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]
|
||||
|
||||
res = local_llm_chat(messages, max_tokens=400, temperature=0.5)
|
||||
local_only = bool(cfg.get("localOnly"))
|
||||
# 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)
|
||||
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"):
|
||||
logger.info("[router] lokal fehlgeschlagen (%s) — %s",
|
||||
res.get("error"), "kein Fallback (localOnly)" if local_only else "→ Claude")
|
||||
if local_only:
|
||||
# Eval-Modus: KEIN Claude. Ehrliche Fehlermeldung statt Stille.
|
||||
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
|
||||
return None
|
||||
|
||||
content = (res.get("content") or "").strip()
|
||||
# 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:
|
||||
# Erzwungen lokal: Escalation-Marker ignorieren, Antwort so nehmen.
|
||||
content = content.replace(router_mod.ESCALATE_MARKER, "").strip()
|
||||
if not content:
|
||||
final = final.replace(router_mod.ESCALATE_MARKER, "").strip()
|
||||
if not final:
|
||||
return "[Lokales LLM lieferte keine Antwort.]"
|
||||
logger.info("[router] lokal (localOnly) %sms", res.get("elapsedMs"))
|
||||
self.conversation.add("assistant", content, project_id=active_project_id)
|
||||
return content
|
||||
logger.info("[router] lokal (localOnly) beantwortet")
|
||||
self.conversation.add("assistant", final, project_id=active_project_id)
|
||||
return final
|
||||
|
||||
# Normalbetrieb: leere Antwort oder Escalation-Marker → Claude.
|
||||
if not content or router_mod.ESCALATE_MARKER in content:
|
||||
# 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 in %sms (%d Zeichen)",
|
||||
res.get("elapsedMs"), len(content))
|
||||
self.conversation.add("assistant", content, project_id=active_project_id)
|
||||
return content
|
||||
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 ──
|
||||
|
||||
@@ -1111,7 +1263,7 @@ class Agent:
|
||||
|
||||
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
|
||||
@@ -1145,7 +1297,7 @@ 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
|
||||
return fast_reply, "fast-path"
|
||||
|
||||
# 1. User-Turn an die Konversation
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
@@ -1159,7 +1311,7 @@ class Agent:
|
||||
# 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
|
||||
return local_reply, "local"
|
||||
|
||||
# 2. Hot Memory (alle pinned Punkte)
|
||||
hot = self.store.list_pinned()
|
||||
@@ -1334,10 +1486,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"
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
|
||||
@@ -1778,6 +1953,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:
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -41,19 +41,28 @@ import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# "claude code" ist fuer sich genommen noch kein Beweis (Stefan und ARIA reden
|
||||
# im Dev-Kontext legitim ueber Claude Code). Erst in Kombination mit einem
|
||||
# zweiten Ablehnungs-Marker ist es eindeutig eine aus-der-Rolle-Antwort.
|
||||
_PRIMARY = re.compile(r"claude\s*code", re.IGNORECASE)
|
||||
_SECONDARY = re.compile(
|
||||
r"injiz|inject|fabriz|fabricat|adoptier|adopting|"
|
||||
r"prompt[\s-]*injection|keine echten|nicht (?:real|adopt)",
|
||||
# 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(_PRIMARY.search(content) and _SECONDARY.search(content))
|
||||
return bool(_BREAK.search(content or ""))
|
||||
|
||||
|
||||
def get_content(obj: dict) -> str:
|
||||
|
||||
@@ -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,
|
||||
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}, ...].
|
||||
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:
|
||||
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
|
||||
try:
|
||||
body = json.dumps(req).encode("utf-8")
|
||||
http_req = urllib.request.Request(
|
||||
|
||||
+5
-1
@@ -630,6 +630,9 @@ 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"
|
||||
# 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 +716,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 = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: a.chat(
|
||||
body.message, source=body.source, project_id=pid,
|
||||
@@ -735,6 +738,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
distilling=needs_distill,
|
||||
events=a.pop_events(),
|
||||
project_id=pid,
|
||||
answered_by=answered_by,
|
||||
)
|
||||
finally:
|
||||
_project_pending[pid] = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""Aktueller Zeitstempel — damit ARIA Timer korrekt anlegen kann
|
||||
und Watcher-Conditions mit hour_of_day etc. einordenbar bleiben."""
|
||||
|
||||
+44
-22
@@ -57,15 +57,17 @@ def load_config() -> dict:
|
||||
# lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation
|
||||
# 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(
|
||||
r"\b(wetter|temperatur|regne|timer|erinner|weck|licht|lampe|steckdose|"
|
||||
r"spotify|musik|lied|song|playlist|lauter|leiser|"
|
||||
r"bild|generier|male?\b|zeichne|foto|"
|
||||
r"merk dir|memory|gedächtnis|erinnere dich|"
|
||||
r"skill|trigger|projekt|oauth|spotify|kalender|termin|"
|
||||
r"such(e|st)?\b|google|internet|news|nachrichten|aktie|kurs|"
|
||||
r"maild?|email|nachricht schreiben|sende)\b",
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
# kein volles Memory (B1a).
|
||||
|
||||
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
|
||||
_AWARENESS = (
|
||||
"ARIA kann ausserdem (ueber ihr grosses Backend, nicht in diesem schnellen "
|
||||
"Modus): Wetter/Zeit/Websuche, Timer & Erinnerungen, Musik (Spotify) & Licht "
|
||||
"steuern, Bilder generieren, ins Gedaechtnis schreiben/suchen, Skills bauen, "
|
||||
"Projekte & OAuth verwalten."
|
||||
"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, 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
|
||||
Anker wie bei Claude (Rolle haelt); pinned_persona optional kurz."""
|
||||
Anker wie bei Claude (Rolle haelt)."""
|
||||
parts = [
|
||||
identity_anchor.strip(),
|
||||
"",
|
||||
"## SCHNELL-MODUS",
|
||||
"Du laeufst gerade als schnelles lokales Modell fuer einfache, kurze "
|
||||
"Alltags-Konversation. Antworte knapp, freundlich, auf Deutsch, als ARIA.",
|
||||
"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 TUST",
|
||||
"## WAS DU HIER NICHT KANNST",
|
||||
_AWARENESS,
|
||||
"Fuer alles, was ein Werkzeug/eine Aktion braucht, tiefer/technischer wird, "
|
||||
"aktuelles Wissen oder Nachschlagen verlangt, oder wo du unsicher bist: "
|
||||
f"antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` (nichts sonst). "
|
||||
"Dann uebernimmt automatisch das grosse Modell mit vollem Zugriff. "
|
||||
"Lieber einmal eskalieren als falsch raten.",
|
||||
"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()]
|
||||
|
||||
@@ -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
@@ -1257,12 +1257,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", {})
|
||||
@@ -1305,6 +1307,8 @@ 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),
|
||||
})
|
||||
@@ -1633,6 +1637,9 @@ 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()
|
||||
|
||||
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
||||
# damit sie in der UI vor der Reply auftauchen
|
||||
@@ -1699,7 +1706,8 @@ 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})
|
||||
except Exception:
|
||||
logger.exception("[brain] _process_core_response Fehler")
|
||||
await self._emit_activity("idle", "", project_id=project_id)
|
||||
@@ -3367,9 +3375,10 @@ class ARIABridge:
|
||||
_LLM_TIMEOUT_S = 30.0
|
||||
|
||||
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
|
||||
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:
|
||||
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
|
||||
if not isinstance(messages, list) or not messages:
|
||||
@@ -3388,8 +3397,10 @@ class ARIABridge:
|
||||
}
|
||||
if stop:
|
||||
req_payload["stop"] = stop
|
||||
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d)",
|
||||
request_id[:8], len(messages), max_tokens)
|
||||
if tools:
|
||||
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({
|
||||
"type": "llm_request",
|
||||
"payload": req_payload,
|
||||
@@ -3407,6 +3418,7 @@ class ARIABridge:
|
||||
return {
|
||||
"ok": True,
|
||||
"content": result.get("content", ""),
|
||||
"tool_calls": result.get("tool_calls"),
|
||||
"model": result.get("model"),
|
||||
"elapsedMs": result.get("elapsedMs"),
|
||||
}
|
||||
@@ -3885,9 +3897,11 @@ class ARIABridge:
|
||||
temperature = float(data.get("temperature"))
|
||||
except (TypeError, ValueError):
|
||||
temperature = 0.7
|
||||
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
|
||||
result = await self._local_llm(
|
||||
messages=messages, max_tokens=max_tokens,
|
||||
temperature=temperature, stop=data.get("stop"),
|
||||
tools=_tools,
|
||||
)
|
||||
status = 200 if result.get("ok") else 502
|
||||
await _send_response(writer, status, result)
|
||||
|
||||
+138
-2
@@ -910,6 +910,56 @@
|
||||
</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 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.
|
||||
<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. Grafikkarte da ist (siehe ⓘ).
|
||||
<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. -->
|
||||
|
||||
</div><!-- /tab-settings -->
|
||||
@@ -1521,6 +1571,8 @@
|
||||
try { loadBrainStatus(); } catch {}
|
||||
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
|
||||
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
|
||||
@@ -1839,6 +1891,7 @@
|
||||
ttsText: p.ttsText,
|
||||
backupTs: p.backupTs,
|
||||
projectId: p.projectId || '',
|
||||
answeredBy: p.answeredBy || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1940,7 +1993,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}`;
|
||||
@@ -2271,6 +2325,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.
|
||||
@@ -2305,7 +2371,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' });
|
||||
@@ -5856,6 +5924,28 @@
|
||||
|
||||
// Vor-definierte Info-Blocks
|
||||
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 <1 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 3060 (12 GB)</strong>.</p>
|
||||
<p><strong>Voll</strong> — das lokale Modell soll ARIAs komplettes Arsenal kennen. Das sind ~15-20 K Tokens Tool-Schemas → passt <em>nicht</em> in ein 8-K-Kontextfenster. Groesseres Fenster kostet VRAM:</p>
|
||||
<ul>
|
||||
<li><strong>12 GB (1×3060):</strong> nur „Abgespeckt".</li>
|
||||
<li><strong>24 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. Karte) und die lokale Tool-Loop (B1b) da sind.</p>
|
||||
`,
|
||||
},
|
||||
'brain-status': {
|
||||
title: 'Gehirn — Status',
|
||||
html: `
|
||||
@@ -6147,6 +6237,52 @@
|
||||
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 ──────────────────────
|
||||
|
||||
// loadOpenClawConfig entfernt — aria-core ist raus.
|
||||
|
||||
+49
-1
@@ -297,6 +297,35 @@ 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",
|
||||
};
|
||||
} 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 ───────────────────────────────────────────
|
||||
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
|
||||
// Wird vom files-list-Endpoint + files-set-project gepflegt.
|
||||
@@ -1573,6 +1602,24 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
});
|
||||
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") {
|
||||
// RVS-Credentials fuer QR-Code App-Onboarding
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -2769,6 +2816,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;
|
||||
@@ -2795,7 +2843,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 }));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -69,9 +69,12 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
|
||||
|
||||
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
|
||||
{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 = {
|
||||
"model": LLM_MODEL,
|
||||
"messages": messages,
|
||||
@@ -81,6 +84,9 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
|
||||
}
|
||||
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.
|
||||
@@ -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.raise_for_status()
|
||||
data = r.json()
|
||||
content = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||
return {"ok": True, "content": content or "", "usage": data.get("usage")}
|
||||
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]}
|
||||
@@ -108,17 +119,20 @@ async def _handle_llm_request(ws, payload: dict) -> None:
|
||||
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
|
||||
t0 = time.time()
|
||||
res = await _call_llama(messages, max_tokens=max_tokens,
|
||||
temperature=temperature, stop=stop)
|
||||
temperature=temperature, stop=stop, tools=tools)
|
||||
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,
|
||||
len(res.get("content") or ""))
|
||||
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": LLM_MODEL,
|
||||
"elapsedMs": int(dt * 1000),
|
||||
|
||||
Reference in New Issue
Block a user