diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 022ca0c..648119b 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -48,6 +48,11 @@ BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") # Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war # (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten. _NO_MODEL = {"model": "", "requested": "", "reason": ""} + +# Lokale Modelle bewusst waehlen: pro Chat ("local:" im Modell-Override) +# oder fuer eine Frage ("@lokal …" / "@lokal: …"). +LOCAL_MODEL_PREFIX = "local:" +_AT_LOCAL_RE = re.compile(r"^\s*@lokal(?::([\w.\-]+))?(?=[\s:,]|$)[\s:,]*(.*)$", re.IGNORECASE | re.DOTALL) # SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b). SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/") # FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start @@ -2512,6 +2517,61 @@ class Agent: converse = flags["converse"] return speak, converse + def _local_area_route(self, user_message: str, project_id: str): + """(modell, frage, begruendung) wenn dieser Turn bewusst lokal laufen + soll, sonst None. modell "" = das gerade geladene lokale Modell.""" + msg = router_mod._strip_leading_hint_blocks(user_message) + m = _AT_LOCAL_RE.match(msg) + if m: + return (m.group(1) or "").strip(), (m.group(2) or "").strip() or msg, "einmalig" + ov = context_model.get(project_id) + if ov.startswith(LOCAL_MODEL_PREFIX): + return ov[len(LOCAL_MODEL_PREFIX):], msg, context_model.REASON_PER_CHAT + return None + + def _local_area_turn(self, question: str, project_id: str, model: str) -> tuple: + """Ein ganzer Turn auf dem lokalen Modell — ohne Werkzeuge, ohne Claude. + Gibt (antwort, benutztes_modell). Fehler werden NICHT als ARIAs Antwort + in den Verlauf geschrieben (nur angezeigt).""" + sys_prompt = "\n".join([ + IDENTITY_ANCHOR.strip(), "", + "## LOKALES MODELL", + "Du laeufst gerade als lokales Modell auf Stefans eigener Hardware — er hat " + "dieses Modell fuer diese Frage bzw. diesen Chat bewusst gewaehlt. Antworte " + "vollstaendig und sachlich auf Deutsch, als ARIA. Du hast hier KEINE " + "Werkzeuge (kein Internet, kein Gedaechtnis, keine Aktionen): behaupte nie, " + "etwas nachgesehen oder ausgefuehrt zu haben. Weisst du etwas nicht sicher, " + "sag das. Normaler Text, kein , kein [FILE:], keine Tool-Aufrufe.", + ]) + notes = local_compact.summary_block(project_id) + if notes: + sys_prompt += "\n\n" + notes + all_turns = self.conversation.window(project_id=project_id) + ctx = local_compact.model_ctx(model) if model else local_compact.DEFAULT_CTX * 4 + budget = int(ctx * 0.6) - 2500 - local_compact.est_tokens(sys_prompt) + window = local_compact.fit_window(all_turns, max(budget, 512)) + messages = [{"role": "system", "content": sys_prompt}] + messages += [{"role": t.role, "content": t.content} for t in window] + if question and len(messages) > 1 and messages[-1]["role"] == "user": + messages[-1]["content"] = question # ohne "@lokal" davor + res = local_llm_chat(messages, max_tokens=2000, temperature=0.6, + model=model or None, timeout=600) + used = res.get("model") or model or "lokales Modell" + if not res.get("ok"): + logger.warning("[lokal] %s nicht erreichbar: %s", model or "(geladenes)", res.get("error")) + return (f"⚠ Das lokale Modell {model or ''} hat nicht geantwortet: " + f"{res.get('error', 'unbekannt')}. Liegt es auf einer Karte? " + "(Diagnostic → LLM-Karten)").replace(" ", " "), used + try: + metrics.log_local_call(used, messages, res.get("content") or "", res.get("usage")) + except Exception: + pass + reply = re.sub(r"[\s\S]*?", "", res.get("content") or "") + reply = re.sub(r"", "", reply).strip() or "[Das lokale Modell lieferte keine Antwort.]" + self.conversation.add("assistant", reply, project_id=project_id) + logger.info("[lokal] %s beantwortet (%s)", used, project_id or "Hauptchat") + return reply, used + def _try_local_fast_lane(self, user_message: str, active_project_id: str) -> Optional[str]: cfg = router_mod.load_config() @@ -2809,6 +2869,19 @@ class Agent: if active_project_id: projects_mod.touch_project(active_project_id) + # Lokales Modell bewusst gewaehlt — Claude ist fuer diesen Turn raus: + # * "@lokal …" / "@lokal: …" nur fuer diese eine Frage + # * Chat steht (🧠, pro Chat) auf "local:" + # Stefan entscheidet das selbst (z.B. ein Pentest-Projekt auf einem + # unzensierten Modell); nichts wird automatisch umgeleitet. + area = self._local_area_route(user_message, active_project_id) + if area is not None: + model, question, why = area + reply, used = self._local_area_turn(question, active_project_id, model) + converse = not _wants_end + return reply, "local", True, converse, False, { + "model": used, "requested": model or "lokal", "reason": why} + # Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber # /shared/config/local_llm.json (Default aus → alles laeuft wie bisher # ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der @@ -2881,6 +2954,14 @@ class Agent: oauth_callback_host=oauth_host, oauth_callback_port=oauth_port, oauth_callback_tls=oauth_tls) + # Bei einer Ablehnung: Stefan kann es bewusst an ein lokales Modell geben. + system_prompt += ( + "\n\n## LOKALE MODELLE ALS AUSWEG\n" + "Lehnst du eine Anfrage ab (Richtlinien), sag Stefan kurz, dass er sie selbst " + "an ein lokales Modell auf seiner eigenen Hardware geben kann: mit " + "`@lokal ` fuer diese eine Frage, oder indem er den Chat oben (🧠) " + "auf ein Modell unter „Lokal“ stellt. Leite NICHT selbst weiter und gib " + "den Inhalt nicht selbst an ein Werkzeug — die Entscheidung trifft er.") # Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine # echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt- # Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter diff --git a/aria-brain/main.py b/aria-brain/main.py index 616c700..76d0eb4 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -1343,11 +1343,25 @@ def _context_model_rows() -> dict: "reason": reason, "blocked": sorted(blocked), }) - choices = _model_choices() + choices = _model_choices() + _local_model_choices() names = {c["id"]: c["name"] for c in choices} return {"default": default, "rows": rows, "choices": choices, "names": names} +def _local_model_choices() -> list: + """Lokale Modelle, die gerade auf einer Karte liegen (ARIA-Cluster ueber + die Bridge) — als "local:" waehlbar pro Chat. Fail-soft: [].""" + try: + import httpx as _httpx + base = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090").rstrip("/") + data = _httpx.get(f"{base}/internal/llm-models", timeout=4).json() + return [{"id": "local:" + m["model"], "name": "🖥 lokal: " + m["model"], "section": "local"} + for m in (data.get("models") or []) if m.get("model")] + except Exception as exc: + logger.info("[context-model] lokale Modelle nicht abrufbar: %s", exc) + return [] + + @app.get("/context-model/list") def context_model_list(): return _context_model_rows() diff --git a/diagnostic/index.html b/diagnostic/index.html index 2a6696b..7794f08 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -3605,7 +3605,8 @@ const row = ctxModelRow(focusedContextId); if (!row) { bar.innerHTML = ''; return; } const def = prettyModel(diagCtxModels.default) || diagCtxModels.default || '?'; - const groups = { tier: 'Automatisch (neueste Version)', main: 'Aktuelle Versionen', overflow: 'Ältere Versionen' }; + const groups = { tier: 'Automatisch (neueste Version)', main: 'Aktuelle Versionen', overflow: 'Ältere Versionen', + local: 'Lokal (eigene Hardware, ohne Claude)' }; let opts = ``; for (const [sec, label] of Object.entries(groups)) { const inSec = (diagCtxModels.choices || []).filter(c => (c.section || 'tier') === sec);