diff --git a/android/src/services/ariaView.ts b/android/src/services/ariaView.ts new file mode 100644 index 0000000..724eae7 --- /dev/null +++ b/android/src/services/ariaView.ts @@ -0,0 +1,104 @@ +/** + * ariaView — Empfaenger der von ARIA komponierten RAEUMLICHEN Ansichten (M1). + * + * Fluss: ARIA ruft im Brain `present_view` → Brain-Event `aria_view` → Bridge → + * RVS `aria_view` → hier gepuffert → WorkspaceCanvas rendert Orb + Karten, die + * auf der Flaeche materialisieren. + * + * Der Service haelt pro Projekt die AKTUELLE View-Spec, damit eine spaet + * gemountete Canvas-Kachel sofort den Ist-Stand bekommt. Muster wie + * services/codeFile.ts (Singleton, rvs.onMessage). + * + * Die Karten-Typen sind bewusst offen (string), damit spaetere Renderer (vnc, + * chart, file …) ohne Service-Aenderung dazukommen. Der jeweilige Client-Renderer + * entscheidet, was er mit einem unbekannten Typ macht (i.d.R. ignorieren). + */ + +import rvs, { RVSMessage } from './rvs'; + +export type OrbState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'working'; + +export interface ViewMarker { + lat: number; + lon: number; + label?: string; +} + +export interface ViewCard { + type: 'text' | 'image' | 'map' | 'code' | 'list' | string; + title?: string; + md?: string; // text/list + src?: string; // image + markers?: ViewMarker[]; // map + path?: string; // code + lang?: string; // code + // Zukuenftige Kartenfelder ohne Service-Aenderung: + [k: string]: any; +} + +export interface ViewSpec { + cards: ViewCard[]; + orb?: OrbState; + title?: string; +} + +export interface AriaView { + projectId: string; + view: ViewSpec; + clientMsgId?: string; + ts: number; +} + +type ViewSub = (v: AriaView) => void; + +class AriaViewService { + private views = new Map(); + private subs: ViewSub[] = []; + + constructor() { + rvs.onMessage((m) => this.onMessage(m)); + } + + private onMessage(m: RVSMessage): void { + if (m.type !== 'aria_view') return; + const p = (m.payload || {}) as any; + const raw = (p.view || {}) as any; + const cards: ViewCard[] = Array.isArray(raw.cards) ? raw.cards : []; + if (cards.length === 0) return; // leere Ansicht ignorieren + const view: ViewSpec = { + cards, + orb: raw.orb || 'speaking', + title: raw.title || '', + }; + const projectId: string = p.projectId || ''; + const entry: AriaView = { + projectId, + view, + clientMsgId: p.clientMsgId || '', + ts: Date.now(), + }; + this.views.set(projectId, entry); + this.subs.forEach((cb) => { + try { cb(entry); } catch {} + }); + } + + /** Aktuelle Ansicht eines Projekts (leer = Hauptchat). */ + getView(projectId: string): AriaView | undefined { + return this.views.get(projectId || ''); + } + + /** Registriert einen Listener fuer neue Ansichten. */ + subscribe(cb: ViewSub): () => void { + this.subs.push(cb); + return () => { this.subs = this.subs.filter((s) => s !== cb); }; + } + + /** Ansicht eines Projekts verwerfen (z.B. wenn der User sie wegwischt). */ + clear(projectId: string): void { + this.views.delete(projectId || ''); + } +} + +const ariaView = new AriaViewService(); +export default ariaView; diff --git a/android/src/services/gpsTracking.ts b/android/src/services/gpsTracking.ts index 8707510..923c702 100644 --- a/android/src/services/gpsTracking.ts +++ b/android/src/services/gpsTracking.ts @@ -145,6 +145,17 @@ class GpsTrackingService { // liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte). const bgEnabled = await isBackgroundGpsEnabled(); if (bgEnabled) { + // Ohne ACCESS_BACKGROUND_LOCATION liefert watchPosition im Hintergrund + // NICHTS (Android 10+) → der Foreground-Service allein bringt nichts, und + // genau der Fall "Ankunft waehrend der Fahrt, Screen aus" faellt durch. + // Deshalb erst die Permission sicherstellen (oeffnet ggf. die Android- + // Settings fuer "Immer erlauben"), DANN den Location-Foreground-Service + // hochziehen — der haelt den Prozess wach, sodass watchPosition + der + // 60s-Heartbeat auch unter Doze weiterlaufen. + const bgOk = await ensureBackgroundLocationPermission(); + if (!bgOk) { + console.warn('[gps-track] Background-Permission fehlt — Tracking nur im Vordergrund zuverlaessig'); + } try { await acquireBackgroundAudio('location'); } catch {} } try { diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 8c1266f..98e0e8a 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -627,10 +627,11 @@ META_TOOLS = [ "name": "request_location_tracking", "description": ( "Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu " - "deaktivieren. Default ist AUS (Akku-Schutz). Nutze das wenn du einen " - "GPS-basierten Watcher anlegst (z.B. `near(...)`), sonst hat die App " - "veraltete Position und der Watcher feuert nie. Auch wieder ausschalten " - "wenn der letzte GPS-Watcher geloescht wurde." + "deaktivieren. Default ist AUS (Akku-Schutz). HINWEIS: Beim Anlegen/Loeschen " + "eines Standort-Watchers (`near/entered_near/left_near`) schaltet das System " + "das Tracking bereits AUTOMATISCH mit an bzw. aus — dieses Tool brauchst du " + "dafuer nicht mehr. Nutze es nur fuer manuelle/explizite Faelle (z.B. Tracking " + "kurz einschalten ohne Watcher)." ), "parameters": { "type": "object", @@ -642,6 +643,48 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "present_view", + "description": ( + "Komponiere eine RAEUMLICHE Ansicht, die auf ARIAs Flaeche materialisiert " + "(Orb + Karten). Nutze das, wenn eine visuelle Anordnung besser ist als reiner " + "Text — z.B. eine Akte/Datei zeigen (Bild links, Text rechts, Karte unten), " + "einen Vergleich, eine Liste, eine Karte mit Orten, oder Code. NICHT fuer " + "normale Gespraechsantworten. Die Karten erscheinen ZUSAETZLICH zu deiner " + "(kurzen) Sprachantwort — halte die Antwort dann knapp, das Visuelle traegt." + ), + "parameters": { + "type": "object", + "properties": { + "cards": { + "type": "array", + "description": "Die Karten der Ansicht, in sinnvoller Anordnung.", + "items": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["text", "image", "map", "code", "list"], + "description": "Kartentyp"}, + "title": {"type": "string", "description": "Optionaler Kartentitel"}, + "md": {"type": "string", "description": "text/list: Markdown-Inhalt"}, + "src": {"type": "string", "description": "image: Bild-URL oder /shared-Pfad"}, + "markers": {"type": "array", "items": {"type": "object"}, + "description": "map: Liste [{lat, lon, label}]"}, + "path": {"type": "string", "description": "code: Datei-Pfad im Projekt"}, + "lang": {"type": "string", "description": "code: Sprache fuers Highlighting"}, + }, + "required": ["type"], + }, + }, + "orb": {"type": "string", "enum": ["idle", "speaking", "working"], + "description": "Orb-Zustand nach dem Rendern (default: speaking)"}, + "title": {"type": "string", "description": "Optionaler Titel der ganzen Ansicht"}, + }, + "required": ["cards"], + }, + }, + }, { "type": "function", "function": { @@ -1355,6 +1398,17 @@ def _skill_to_tool(s: dict) -> dict: } +# Standort-Funktionen in Watcher-Conditions — brauchen aktives GPS-Tracking. +_LOCATION_FUNCS = ("near(", "entered_near(", "left_near(") + + +def _condition_needs_location(condition: str) -> bool: + """True, wenn die Watcher-Condition eine Standort-Funktion nutzt und damit + laufende GPS-Updates der App braucht.""" + c = condition or "" + return any(fn in c for fn in _LOCATION_FUNCS) + + class Agent: # Mindest-Score den ein Cold-Memory-Treffer haben muss um in den # System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's @@ -2372,13 +2426,41 @@ class Agent: "trigger": {"name": t["name"], "type": "watcher", "condition": t["condition"], "message": t["message"]}, }) - return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'." + # GPS-Kopplung: ein Standort-Watcher (near/entered_near/left_near) + # ist tot, wenn die App kein Tracking sendet. Frueher musste ARIA + # separat request_location_tracking aufrufen — wurde oft vergessen, + # dann feuerte der Trigger nie. Jetzt automatisch mit-anschalten. + extra = "" + if _condition_needs_location(t["condition"]): + self._pending_events.append({ + "type": "location_tracking", + "on": True, + "reason": f"watcher:{t['name']}", + }) + extra = " GPS-Tracking automatisch aktiviert." + return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'.{extra}" if name == "trigger_cancel": try: triggers_mod.delete(arguments["name"]) - return f"OK — Trigger '{arguments['name']}' geloescht." except ValueError as e: return f"FEHLER: {e}" + # GPS-Kopplung (Gegenstueck): existiert kein Standort-Watcher mehr, + # Tracking wieder ausschalten (Akku schonen). + extra = "" + remaining = triggers_mod.list_triggers(active_only=False) + still_needs_gps = any( + r.get("type") == "watcher" + and _condition_needs_location(r.get("condition") or "") + for r in remaining + ) + if not still_needs_gps: + self._pending_events.append({ + "type": "location_tracking", + "on": False, + "reason": "no-location-watchers", + }) + extra = " GPS-Tracking deaktiviert (kein Standort-Watcher mehr)." + return f"OK — Trigger '{arguments['name']}' geloescht.{extra}" if name == "request_location_tracking": on = bool(arguments.get("on", False)) reason = (arguments.get("reason") or "").strip() @@ -2388,6 +2470,22 @@ class Agent: "reason": reason, }) return f"OK — Tracking-Request gesendet (on={on}). App wird in Kuerze umschalten." + if name == "present_view": + cards = arguments.get("cards") or [] + if not isinstance(cards, list) or not cards: + return "FEHLER: present_view braucht mindestens eine Karte in 'cards'." + view = { + "cards": cards, + "orb": arguments.get("orb") or "speaking", + "title": arguments.get("title") or "", + } + self._pending_events.append({ + "type": "aria_view", + "view": view, + "project_id": project_id or "", + }) + return (f"OK — Ansicht mit {len(cards)} Karte(n) an die Flaeche geschickt " + f"(Kontext: {project_id or 'Hauptchat'}).") if name == "trigger_list": items = triggers_mod.list_triggers(active_only=False) if not items: diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 153c82b..5e4122a 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -2080,6 +2080,22 @@ class ARIABridge: proj = event.get("project") or {} logger.info("[brain] Projekt %s: %s (id=%s)", event.get("action") or "?", proj.get("name"), proj.get("id")) + elif etype == "aria_view": + # M1: ARIA hat via present_view eine raeumliche Ansicht komponiert. + # View-Spec (Orb + Karten) + Projekt-Kontext an App/Web/Diagnostic; + # deren Renderer materialisieren die Karten auf der Flaeche. + view = event.get("view") or {} + await self._send_to_rvs({ + "type": "aria_view", + "payload": { + "view": view, + "projectId": event.get("project_id") or "", + "clientMsgId": client_msg_id or "", + }, + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + logger.info("[brain] ARIA hat eine Ansicht geschickt: %d Karte(n), orb=%s", + len(view.get("cards") or []), view.get("orb")) # _process_core_response uebernimmt alles weitere: # File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat- diff --git a/rvs/server.js b/rvs/server.js index d42023d..e01ecad 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -70,6 +70,10 @@ const ALLOWED_TYPES = new Set([ // spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS // getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig). "code_file", "code_file_edit", + // M1 Generatives Cockpit: ARIA komponiert via present_view eine View-Spec + // (Orb + Karten), die App/Web/Diagnostic mit ihrem jeweiligen Renderer + // materialisieren. Brain → Bridge → RVS → Clients. + "aria_view", "check_desktop", "desktop_status", "vnc_open", "vnc_close", "vnc_data", "vnc_input", // Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit