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:
@@ -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;
|
||||||
@@ -145,6 +145,17 @@ class GpsTrackingService {
|
|||||||
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
|
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
|
||||||
const bgEnabled = await isBackgroundGpsEnabled();
|
const bgEnabled = await isBackgroundGpsEnabled();
|
||||||
if (bgEnabled) {
|
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 { await acquireBackgroundAudio('location'); } catch {}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
+104
-6
@@ -627,10 +627,11 @@ META_TOOLS = [
|
|||||||
"name": "request_location_tracking",
|
"name": "request_location_tracking",
|
||||||
"description": (
|
"description": (
|
||||||
"Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu "
|
"Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu "
|
||||||
"deaktivieren. Default ist AUS (Akku-Schutz). Nutze das wenn du einen "
|
"deaktivieren. Default ist AUS (Akku-Schutz). HINWEIS: Beim Anlegen/Loeschen "
|
||||||
"GPS-basierten Watcher anlegst (z.B. `near(...)`), sonst hat die App "
|
"eines Standort-Watchers (`near/entered_near/left_near`) schaltet das System "
|
||||||
"veraltete Position und der Watcher feuert nie. Auch wieder ausschalten "
|
"das Tracking bereits AUTOMATISCH mit an bzw. aus — dieses Tool brauchst du "
|
||||||
"wenn der letzte GPS-Watcher geloescht wurde."
|
"dafuer nicht mehr. Nutze es nur fuer manuelle/explizite Faelle (z.B. Tracking "
|
||||||
|
"kurz einschalten ohne Watcher)."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"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",
|
"type": "function",
|
||||||
"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:
|
class Agent:
|
||||||
# Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
|
# Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
|
||||||
# System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
|
# System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
|
||||||
@@ -2372,13 +2426,41 @@ class Agent:
|
|||||||
"trigger": {"name": t["name"], "type": "watcher",
|
"trigger": {"name": t["name"], "type": "watcher",
|
||||||
"condition": t["condition"], "message": t["message"]},
|
"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":
|
if name == "trigger_cancel":
|
||||||
try:
|
try:
|
||||||
triggers_mod.delete(arguments["name"])
|
triggers_mod.delete(arguments["name"])
|
||||||
return f"OK — Trigger '{arguments['name']}' geloescht."
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return f"FEHLER: {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":
|
if name == "request_location_tracking":
|
||||||
on = bool(arguments.get("on", False))
|
on = bool(arguments.get("on", False))
|
||||||
reason = (arguments.get("reason") or "").strip()
|
reason = (arguments.get("reason") or "").strip()
|
||||||
@@ -2388,6 +2470,22 @@ class Agent:
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
})
|
})
|
||||||
return f"OK — Tracking-Request gesendet (on={on}). App wird in Kuerze umschalten."
|
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":
|
if name == "trigger_list":
|
||||||
items = triggers_mod.list_triggers(active_only=False)
|
items = triggers_mod.list_triggers(active_only=False)
|
||||||
if not items:
|
if not items:
|
||||||
|
|||||||
@@ -2080,6 +2080,22 @@ class ARIABridge:
|
|||||||
proj = event.get("project") or {}
|
proj = event.get("project") or {}
|
||||||
logger.info("[brain] Projekt %s: %s (id=%s)",
|
logger.info("[brain] Projekt %s: %s (id=%s)",
|
||||||
event.get("action") or "?", proj.get("name"), proj.get("id"))
|
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:
|
# _process_core_response uebernimmt alles weitere:
|
||||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS
|
// spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS
|
||||||
// getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig).
|
// getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig).
|
||||||
"code_file", "code_file_edit",
|
"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",
|
"check_desktop", "desktop_status",
|
||||||
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
|
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
|
||||||
// Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit
|
// Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit
|
||||||
|
|||||||
Reference in New Issue
Block a user