feat: GPS-Trigger-Kopplung + generatives Cockpit (aria_view)

GPS: near()/entered_near()/left_near()-Watcher schalten das Tracking automatisch an bzw. beim Loeschen des letzten wieder aus; gpsTracking.start() sichert jetzt die Background-Permission. Cockpit-Fundament: neues Brain-Tool present_view emittiert aria_view (Orb + Karten), Bridge/RVS leiten weiter, ariaView.ts haelt die Spec App-seitig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 01:21:43 +02:00
co-authored by Claude Opus 4.8
parent f5b22253b2
commit fd6ba73f59
5 changed files with 239 additions and 6 deletions
+104
View File
@@ -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<string, AriaView>();
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;
+11
View File
@@ -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 {
+104 -6
View File
@@ -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:
+16
View File
@@ -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-
+4
View File
@@ -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