Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6821eaaa38 | |||
| 31aa86a2a9 | |||
| 87cb687610 | |||
| eb4059a887 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10204
|
||||
versionName "0.1.2.4"
|
||||
versionCode 10205
|
||||
versionName "0.1.2.5"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.2.4",
|
||||
"version": "0.1.2.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -79,6 +79,14 @@ interface ChatMessage {
|
||||
active: boolean;
|
||||
setupError?: string;
|
||||
};
|
||||
/** Trigger-Created-Bubble: ARIA hat einen neuen Trigger angelegt */
|
||||
triggerCreated?: {
|
||||
name: string;
|
||||
type: 'timer' | 'watcher' | string;
|
||||
message: string;
|
||||
fires_at?: string;
|
||||
condition?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// --- Konstanten ---
|
||||
@@ -442,6 +450,7 @@ const ChatScreen: React.FC = () => {
|
||||
// gesetzt UND text leer/Placeholder)
|
||||
const localOnly = prev.filter(m =>
|
||||
m.skillCreated ||
|
||||
m.triggerCreated ||
|
||||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
|
||||
);
|
||||
// Server-Stand + lokal-only (chronologisch sortiert)
|
||||
@@ -476,6 +485,26 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// trigger_created: ARIA hat einen Trigger angelegt → eigene Bubble
|
||||
if (message.type === 'trigger_created') {
|
||||
const p = (message.payload || {}) as any;
|
||||
const triggerMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'aria',
|
||||
text: '',
|
||||
timestamp: Date.now(),
|
||||
triggerCreated: {
|
||||
name: String(p.name || '(unbenannt)'),
|
||||
type: String(p.type || 'timer'),
|
||||
message: String(p.message || ''),
|
||||
fires_at: p.fires_at ? String(p.fires_at) : undefined,
|
||||
condition: p.condition ? String(p.condition) : undefined,
|
||||
},
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, triggerMsg]));
|
||||
return;
|
||||
}
|
||||
|
||||
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
|
||||
if (message.type === 'file_deleted') {
|
||||
const p = (message.payload?.path as string) || '';
|
||||
@@ -920,17 +949,25 @@ const ChatScreen: React.FC = () => {
|
||||
setSearchIndex(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen
|
||||
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
|
||||
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
|
||||
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
|
||||
// damit Layout sicher fertig ist.
|
||||
useEffect(() => {
|
||||
if (!searchMatchIds.length) return;
|
||||
const id = searchMatchIds[searchIndex];
|
||||
if (!id) return;
|
||||
// invertedMessages → index in der angezeigten Liste finden
|
||||
const idx = invertedMessages.findIndex(m => m.id === id);
|
||||
if (idx < 0 || !flatListRef.current) return;
|
||||
try {
|
||||
flatListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.4 });
|
||||
} catch {}
|
||||
const tryScroll = () => {
|
||||
try {
|
||||
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
|
||||
} catch {
|
||||
// wird von onScrollToIndexFailed nochmal versucht
|
||||
}
|
||||
};
|
||||
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
|
||||
requestAnimationFrame(tryScroll);
|
||||
}, [searchIndex, searchMatchIds, invertedMessages]);
|
||||
|
||||
const activeSearchId = searchMatchIds[searchIndex] || '';
|
||||
@@ -1199,6 +1236,28 @@ const ChatScreen: React.FC = () => {
|
||||
? { borderWidth: 2, borderColor: '#FFD60A' }
|
||||
: null;
|
||||
|
||||
// Spezial-Bubble: ARIA hat einen Trigger angelegt
|
||||
if (item.triggerCreated) {
|
||||
const t = item.triggerCreated;
|
||||
const detailLine = t.type === 'timer'
|
||||
? `feuert: ${t.fires_at || '?'}`
|
||||
: `wenn: ${t.condition || '?'}`;
|
||||
return (
|
||||
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
|
||||
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
||||
{'⏰ ARIA hat einen Trigger angelegt'}
|
||||
</Text>
|
||||
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
|
||||
<Text style={{fontWeight: 'bold'}}>{t.name}</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${t.type})`}</Text>
|
||||
</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginTop: 2, fontFamily: 'monospace'}}>{detailLine}</Text>
|
||||
<Text style={{color: '#888', fontSize: 12, marginTop: 2}}>{`"${t.message}"`}</Text>
|
||||
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Trigger · {time}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Spezial-Bubble: ARIA hat einen Skill erstellt
|
||||
if (item.skillCreated) {
|
||||
const s = item.skillCreated;
|
||||
@@ -1440,10 +1499,14 @@ const ChatScreen: React.FC = () => {
|
||||
inverted
|
||||
data={invertedMessages}
|
||||
onScrollToIndexFailed={(info) => {
|
||||
// Bei zu schnellem Aufruf vor Layout: einmal nachfassen
|
||||
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
|
||||
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
|
||||
// praezise nochmal versuchen.
|
||||
const offset = info.averageItemLength * info.index;
|
||||
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
||||
setTimeout(() => {
|
||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.4 }); } catch {}
|
||||
}, 200);
|
||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
|
||||
}, 250);
|
||||
}}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderMessage}
|
||||
|
||||
+141
-1
@@ -25,6 +25,8 @@ from memory import Embedder, VectorStore, MemoryPoint
|
||||
from prompts import build_system_prompt
|
||||
from proxy_client import ProxyClient, Message as ProxyMessage
|
||||
import skills as skills_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -90,6 +92,90 @@ META_TOOLS = [
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "trigger_timer",
|
||||
"description": (
|
||||
"Lege einen Timer-Trigger an — feuert EINMALIG zum angegebenen Zeitpunkt "
|
||||
"und ruft dich selbst auf (Push-Nachricht an Stefan). "
|
||||
"Use-Case: 'erinnere mich in 10min', 'sag mir um 14:30 Bescheid'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "kurzer kebab-case-Name, a-z 0-9 - _"},
|
||||
"fires_at": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Absoluter ISO-Timestamp UTC, z.B. '2026-05-12T14:30:00Z'. "
|
||||
"Berechne aus relativer Angabe ('in 10min') selbst — die "
|
||||
"aktuelle Zeit findest du im System-Prompt nicht, also nutze "
|
||||
"Bash: `date -u -d '+10 minutes' --iso-8601=seconds`."
|
||||
),
|
||||
},
|
||||
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
|
||||
},
|
||||
"required": ["name", "fires_at", "message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "trigger_watcher",
|
||||
"description": (
|
||||
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
|
||||
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
|
||||
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
|
||||
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "kurzer Name"},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Boolescher Ausdruck mit den erlaubten Variablen, z.B. "
|
||||
"'disk_free_gb < 5', 'hour_of_day == 8 and day_of_week == \"mon\"'. "
|
||||
"Operatoren: < > <= >= == != and or not"
|
||||
),
|
||||
},
|
||||
"message": {"type": "string", "description": "Was soll bei Erfuellung gesagt werden"},
|
||||
"check_interval_sec": {
|
||||
"type": "integer",
|
||||
"description": "Wie oft Condition pruefen (Default 300 = alle 5min, min 30)",
|
||||
},
|
||||
"throttle_sec": {
|
||||
"type": "integer",
|
||||
"description": "Mindestabstand zwischen 2 Feuerungen (Default 3600 = max 1x/h)",
|
||||
},
|
||||
},
|
||||
"required": ["name", "condition", "message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "trigger_cancel",
|
||||
"description": "Loescht einen Trigger (Timer abbrechen oder Watcher entfernen).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "trigger_list",
|
||||
"description": "Zeigt alle Trigger (active + inaktiv). Selten noetig — Stefan sieht sie im Diagnostic.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -175,8 +261,14 @@ class Agent:
|
||||
active_skills = [s for s in all_skills if s.get("active", True)]
|
||||
tools = list(META_TOOLS) + [_skill_to_tool(s) for s in active_skills]
|
||||
|
||||
# Trigger-Liste + Variablen-Info fuer den System-Prompt
|
||||
all_triggers = triggers_mod.list_triggers(active_only=False)
|
||||
condition_vars = watcher_mod.describe_variables()
|
||||
|
||||
# 5. System-Prompt + Window-Messages
|
||||
system_prompt = build_system_prompt(hot, cold, skills=all_skills)
|
||||
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
|
||||
triggers=all_triggers,
|
||||
condition_vars=condition_vars)
|
||||
messages = [ProxyMessage(role="system", content=system_prompt)]
|
||||
for t in self.conversation.window():
|
||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||
@@ -273,6 +365,54 @@ class Agent:
|
||||
if err:
|
||||
out += f"\nstderr:\n{err}"
|
||||
return out
|
||||
if name == "trigger_timer":
|
||||
t = triggers_mod.create_timer(
|
||||
name=arguments["name"],
|
||||
fires_at_iso=arguments["fires_at"],
|
||||
message=arguments["message"],
|
||||
author="aria",
|
||||
)
|
||||
self._pending_events.append({
|
||||
"type": "trigger_created",
|
||||
"trigger": {"name": t["name"], "type": "timer",
|
||||
"fires_at": t["fires_at"], "message": t["message"]},
|
||||
})
|
||||
return f"OK — Timer '{t['name']}' angelegt, feuert um {t['fires_at']}."
|
||||
if name == "trigger_watcher":
|
||||
t = triggers_mod.create_watcher(
|
||||
name=arguments["name"],
|
||||
condition=arguments["condition"],
|
||||
message=arguments["message"],
|
||||
check_interval_sec=int(arguments.get("check_interval_sec", 300)),
|
||||
throttle_sec=int(arguments.get("throttle_sec", 3600)),
|
||||
author="aria",
|
||||
)
|
||||
self._pending_events.append({
|
||||
"type": "trigger_created",
|
||||
"trigger": {"name": t["name"], "type": "watcher",
|
||||
"condition": t["condition"], "message": t["message"]},
|
||||
})
|
||||
return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'."
|
||||
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}"
|
||||
if name == "trigger_list":
|
||||
items = triggers_mod.list_triggers(active_only=False)
|
||||
if not items:
|
||||
return "(keine Trigger vorhanden)"
|
||||
lines = []
|
||||
for t in items:
|
||||
state = "aktiv" if t.get("active", True) else "DEAKTIVIERT"
|
||||
if t["type"] == "timer":
|
||||
lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"")
|
||||
elif t["type"] == "watcher":
|
||||
lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
|
||||
else:
|
||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||
return "\n".join(lines)
|
||||
return f"Unbekanntes Tool: {name}"
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Background-Loop fuer Triggers.
|
||||
|
||||
Laeuft alle TICK_SEC Sekunden in einem asyncio Task, geht ueber alle
|
||||
active Triggers und entscheidet ob sie feuern muessen.
|
||||
|
||||
Feuern bedeutet:
|
||||
1. Trigger-Manifest update (fire_count++, last_fired_at, ggf. deaktivieren)
|
||||
2. Log-Eintrag schreiben
|
||||
3. agent.chat() mit einem system-Praefix aufrufen (NICHT als 'user'!)
|
||||
→ ARIA bekommt das wie eine Push-Nachricht und kann antworten
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TICK_SEC = 30
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_iso(s: str) -> Optional[datetime]:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _should_fire(trigger: dict, vars_: dict, now: datetime) -> bool:
|
||||
if not trigger.get("active", True):
|
||||
return False
|
||||
t = trigger.get("type", "")
|
||||
|
||||
if t == "timer":
|
||||
fires_at = _parse_iso(trigger.get("fires_at", ""))
|
||||
if not fires_at:
|
||||
return False
|
||||
if fires_at.tzinfo is None:
|
||||
fires_at = fires_at.replace(tzinfo=timezone.utc)
|
||||
return now >= fires_at
|
||||
|
||||
if t == "watcher":
|
||||
# Check-Interval respektieren (sonst pollen wir zu hektisch)
|
||||
check_interval = int(trigger.get("check_interval_sec", 300))
|
||||
last_checked = _parse_iso(trigger.get("last_checked_at", ""))
|
||||
if last_checked:
|
||||
if last_checked.tzinfo is None:
|
||||
last_checked = last_checked.replace(tzinfo=timezone.utc)
|
||||
if (now - last_checked).total_seconds() < check_interval:
|
||||
return False
|
||||
# Throttle: erst feuern wenn last_fired lange genug her ist
|
||||
last_fired = _parse_iso(trigger.get("last_fired_at", ""))
|
||||
throttle = int(trigger.get("throttle_sec", 3600))
|
||||
if last_fired:
|
||||
if last_fired.tzinfo is None:
|
||||
last_fired = last_fired.replace(tzinfo=timezone.utc)
|
||||
if (now - last_fired).total_seconds() < throttle:
|
||||
return False
|
||||
# Condition pruefen
|
||||
cond = (trigger.get("condition") or "").strip()
|
||||
if not cond:
|
||||
return False
|
||||
try:
|
||||
return watcher_mod.evaluate(cond, vars_)
|
||||
except Exception as e:
|
||||
logger.warning("Trigger %s: Condition '%s' fehlerhaft: %s",
|
||||
trigger.get("name"), cond, e)
|
||||
return False
|
||||
|
||||
if t == "cron":
|
||||
# TODO: später, wenn jemand Bock auf Cron-Parser hat
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _fire(trigger: dict, agent_factory) -> None:
|
||||
"""Ruft ARIA mit einer System-Praefix-Nachricht auf."""
|
||||
name = trigger.get("name", "?")
|
||||
message = trigger.get("message") or "(ohne Nachricht)"
|
||||
ttype = trigger.get("type", "?")
|
||||
|
||||
# Manifest updaten
|
||||
try:
|
||||
triggers_mod.mark_fired(name)
|
||||
except Exception as e:
|
||||
logger.warning("mark_fired %s: %s", name, e)
|
||||
|
||||
# Log
|
||||
triggers_mod.append_log(name, {"event": "fired", "type": ttype, "message": message})
|
||||
|
||||
# System-Nachricht an ARIA: nicht als User, sondern als Hinweis
|
||||
prompt = (
|
||||
f"[Trigger ausgelöst: '{name}', Typ: {ttype}] "
|
||||
f"Geplante Nachricht: \"{message}\". "
|
||||
f"Sage Stefan jetzt diese Information, in deinem Stil. "
|
||||
f"Wenn der Trigger ein Watcher war (Bedingung wurde erfuellt), "
|
||||
f"erwaehne kurz worum es geht. Antworte direkt, keine Rueckfrage."
|
||||
)
|
||||
|
||||
try:
|
||||
agent = agent_factory()
|
||||
reply = agent.chat(prompt, source="trigger")
|
||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||
except Exception as e:
|
||||
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
||||
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
|
||||
|
||||
|
||||
async def _tick(agent_factory) -> None:
|
||||
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
|
||||
try:
|
||||
all_triggers = triggers_mod.list_triggers(active_only=True)
|
||||
except Exception as e:
|
||||
logger.warning("triggers.list: %s", e)
|
||||
return
|
||||
if not all_triggers:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
|
||||
try:
|
||||
vars_ = watcher_mod.collect_variables()
|
||||
except Exception as e:
|
||||
logger.warning("collect_variables: %s", e)
|
||||
vars_ = {}
|
||||
|
||||
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
|
||||
# damit der Check-Interval respektiert wird)
|
||||
for t in all_triggers:
|
||||
if t.get("type") == "watcher":
|
||||
try:
|
||||
t["last_checked_at"] = _now_iso()
|
||||
triggers_mod.write(t["name"], t)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for trigger in all_triggers:
|
||||
try:
|
||||
if _should_fire(trigger, vars_, now):
|
||||
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
||||
# darf der naechste Tick nicht blockieren
|
||||
asyncio.create_task(_fire(trigger, agent_factory))
|
||||
except Exception as e:
|
||||
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
||||
|
||||
|
||||
async def run_loop(agent_factory) -> None:
|
||||
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
||||
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
||||
while True:
|
||||
try:
|
||||
await _tick(agent_factory)
|
||||
except Exception as e:
|
||||
logger.exception("Tick-Fehler: %s", e)
|
||||
await asyncio.sleep(TICK_SEC)
|
||||
+120
-1
@@ -20,6 +20,9 @@ import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -30,6 +33,9 @@ from proxy_client import ProxyClient
|
||||
from agent import Agent
|
||||
import skills as skills_mod
|
||||
import metrics as metrics_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import background as background_mod
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("aria-brain")
|
||||
@@ -37,7 +43,23 @@ logger = logging.getLogger("aria-brain")
|
||||
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
|
||||
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||
|
||||
app = FastAPI(title="ARIA Brain", version="0.1.0")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Beim Brain-Start: Trigger-Background-Loop anwerfen. Beim Shutdown: stoppen."""
|
||||
task = asyncio.create_task(background_mod.run_loop(agent))
|
||||
logger.info("Lifespan: Trigger-Loop gestartet")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Lifespan: Trigger-Loop gestoppt")
|
||||
|
||||
|
||||
app = FastAPI(title="ARIA Brain", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
_embedder: Optional[Embedder] = None
|
||||
_store: Optional[VectorStore] = None
|
||||
@@ -414,6 +436,103 @@ def metrics_calls():
|
||||
return metrics_mod.stats()
|
||||
|
||||
|
||||
# ─── Triggers (passive Aufweck-Quellen) ─────────────────────────────
|
||||
|
||||
class TriggerTimerBody(BaseModel):
|
||||
name: str
|
||||
fires_at: str # ISO timestamp
|
||||
message: str
|
||||
author: str = "stefan"
|
||||
|
||||
|
||||
class TriggerWatcherBody(BaseModel):
|
||||
name: str
|
||||
condition: str
|
||||
message: str
|
||||
check_interval_sec: int = 300
|
||||
throttle_sec: int = 3600
|
||||
author: str = "stefan"
|
||||
|
||||
|
||||
class TriggerPatch(BaseModel):
|
||||
active: bool | None = None
|
||||
message: str | None = None
|
||||
condition: str | None = None
|
||||
throttle_sec: int | None = None
|
||||
check_interval_sec: int | None = None
|
||||
fires_at: str | None = None
|
||||
|
||||
|
||||
@app.get("/triggers/list")
|
||||
def triggers_list(active_only: bool = False):
|
||||
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
|
||||
|
||||
|
||||
@app.get("/triggers/conditions")
|
||||
def triggers_conditions():
|
||||
"""Verfuegbare Variablen fuer Watcher-Conditions (mit aktuellen Werten)."""
|
||||
return {
|
||||
"variables": watcher_mod.describe_variables(),
|
||||
"current": watcher_mod.collect_variables(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/triggers/{name}")
|
||||
def triggers_get(name: str):
|
||||
t = triggers_mod.read(name)
|
||||
if t is None:
|
||||
raise HTTPException(404, f"Trigger '{name}' nicht gefunden")
|
||||
return t
|
||||
|
||||
|
||||
@app.get("/triggers/{name}/logs")
|
||||
def triggers_get_logs(name: str, limit: int = 50):
|
||||
return {"logs": triggers_mod.list_logs(name, limit=limit)}
|
||||
|
||||
|
||||
@app.post("/triggers/timer")
|
||||
def triggers_create_timer(body: TriggerTimerBody):
|
||||
try:
|
||||
return triggers_mod.create_timer(
|
||||
name=body.name, fires_at_iso=body.fires_at,
|
||||
message=body.message, author=body.author,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@app.post("/triggers/watcher")
|
||||
def triggers_create_watcher(body: TriggerWatcherBody):
|
||||
try:
|
||||
return triggers_mod.create_watcher(
|
||||
name=body.name, condition=body.condition,
|
||||
message=body.message,
|
||||
check_interval_sec=body.check_interval_sec,
|
||||
throttle_sec=body.throttle_sec,
|
||||
author=body.author,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@app.patch("/triggers/{name}")
|
||||
def triggers_patch(name: str, body: TriggerPatch):
|
||||
patch = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
try:
|
||||
return triggers_mod.update(name, patch)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
|
||||
|
||||
@app.delete("/triggers/{name}")
|
||||
def triggers_delete(name: str):
|
||||
try:
|
||||
triggers_mod.delete(name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
return {"deleted": name}
|
||||
|
||||
|
||||
# ─── Skills ─────────────────────────────────────────────────────────
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
|
||||
+38
-1
@@ -115,16 +115,53 @@ def build_skills_section(skills: List[dict]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_triggers_section(triggers: List[dict], condition_vars: List[dict]) -> str:
|
||||
"""Triggers (passive Aufweck-Quellen) + verfuegbare Condition-Variablen."""
|
||||
lines = ["## Trigger (passive Aufweck-Quellen)"]
|
||||
lines.append("")
|
||||
lines.append("Trigger sind ANDERS als Skills: das System ruft DICH wenn ein Event passiert. "
|
||||
"Du legst sie an wenn Stefan sagt 'erinner mich an X' oder 'sag bescheid wenn Y'.")
|
||||
lines.append("")
|
||||
if triggers:
|
||||
lines.append("### Aktuelle Trigger")
|
||||
for t in triggers:
|
||||
active = t.get("active", True)
|
||||
mark = "" if active else " [INAKTIV]"
|
||||
if t["type"] == "timer":
|
||||
lines.append(f"- **{t['name']}**{mark} (timer) feuert {t.get('fires_at')}: \"{t.get('message','')[:80]}\"")
|
||||
elif t["type"] == "watcher":
|
||||
lines.append(f"- **{t['name']}**{mark} (watcher) cond=`{t.get('condition')}`: \"{t.get('message','')[:80]}\"")
|
||||
lines.append("")
|
||||
lines.append("### Verfuegbare Condition-Variablen (fuer Watcher)")
|
||||
for v in condition_vars:
|
||||
lines.append(f"- `{v['name']}` ({v['type']}) — {v['desc']}")
|
||||
lines.append("")
|
||||
lines.append("Operatoren in Conditions: `<` `>` `<=` `>=` `==` `!=` `and` `or` `not`. "
|
||||
"Beispiel: `disk_free_gb < 5 and hour_of_day >= 8`. "
|
||||
"String-Werte in Quotes: `day_of_week == \"mon\"`.")
|
||||
lines.append("")
|
||||
lines.append("### Wann welcher Typ?")
|
||||
lines.append("- **Timer** fuer einmalige Erinnerungen mit konkreter Zeit ('in 10min', 'um 14:30').")
|
||||
lines.append("- **Watcher** fuer 'wenn X passiert' (Disk voll, bestimmte Tageszeit).")
|
||||
lines.append("- ARIA legt Trigger NUR auf Stefan-Wunsch an, nicht eigenmaechtig.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_system_prompt(
|
||||
pinned: List[MemoryPoint],
|
||||
cold: List[MemoryPoint] | None = None,
|
||||
skills: List[dict] | None = None,
|
||||
triggers: List[dict] | None = None,
|
||||
condition_vars: List[dict] | None = None,
|
||||
) -> str:
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills."""
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
|
||||
parts = [build_hot_memory_section(pinned)]
|
||||
if skills:
|
||||
parts.append("")
|
||||
parts.append(build_skills_section(skills))
|
||||
if condition_vars:
|
||||
parts.append("")
|
||||
parts.append(build_triggers_section(triggers or [], condition_vars))
|
||||
if cold:
|
||||
parts.append("")
|
||||
parts.append(build_cold_memory_section(cold))
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Triggers — passive Aufweck-Quellen fuer ARIA.
|
||||
|
||||
Skills sind aktiv (ARIA ruft sie). Triggers sind passiv — das System ruft
|
||||
ARIA wenn ein Event passiert. Drei Typen:
|
||||
|
||||
timer Einmalig zu einem festen Zeitpunkt
|
||||
watcher Recurring: Condition pruefen, bei True → feuern (mit Throttle)
|
||||
cron Cron-Expression (vorerst nicht implementiert, Platzhalter)
|
||||
|
||||
Layout:
|
||||
/data/triggers/<name>.json Manifest pro Trigger
|
||||
/data/triggers/logs/<name>.jsonl Append-only Log pro Feuerung
|
||||
|
||||
Polling-Kosten: Brain-internes Background-Polling (kein LLM-Call).
|
||||
ARIA wird nur aufgeweckt wenn ein Trigger tatsaechlich feuert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRIGGERS_DIR = Path(os.environ.get("TRIGGERS_DIR", "/data/triggers"))
|
||||
LOGS_DIR = TRIGGERS_DIR / "logs"
|
||||
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
|
||||
VALID_TYPES = {"timer", "watcher", "cron"}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
if not isinstance(name, str) or not NAME_RE.match(name):
|
||||
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def _path(name: str) -> Path:
|
||||
return TRIGGERS_DIR / f"{_safe_name(name)}.json"
|
||||
|
||||
|
||||
def _ensure_dirs():
|
||||
TRIGGERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ─── CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
def list_triggers(active_only: bool = False) -> list[dict]:
|
||||
if not TRIGGERS_DIR.exists():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for f in sorted(TRIGGERS_DIR.glob("*.json")):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if active_only and not data.get("active", True):
|
||||
continue
|
||||
out.append(data)
|
||||
except Exception as e:
|
||||
logger.warning("Trigger lesen %s: %s", f, e)
|
||||
return out
|
||||
|
||||
|
||||
def read(name: str) -> Optional[dict]:
|
||||
p = _path(name)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("Trigger %s lesen: %s", name, e)
|
||||
return None
|
||||
|
||||
|
||||
def write(name: str, data: dict) -> None:
|
||||
_ensure_dirs()
|
||||
data["updated_at"] = _now_iso()
|
||||
p = _path(name)
|
||||
tmp = p.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def delete(name: str) -> None:
|
||||
p = _path(name)
|
||||
if not p.exists():
|
||||
raise ValueError(f"Trigger '{name}' nicht gefunden")
|
||||
p.unlink()
|
||||
# Logs auch wegraeumen
|
||||
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
|
||||
if log_file.exists():
|
||||
log_file.unlink()
|
||||
|
||||
|
||||
def update(name: str, patch: dict) -> dict:
|
||||
data = read(name)
|
||||
if data is None:
|
||||
raise ValueError(f"Trigger '{name}' nicht gefunden")
|
||||
allowed = {"active", "message", "condition", "throttle_sec",
|
||||
"check_interval_sec", "fires_at"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
data[k] = v
|
||||
write(name, data)
|
||||
return data
|
||||
|
||||
|
||||
# ─── Create-Helpers (typ-spezifisch) ────────────────────────────────
|
||||
|
||||
def create_timer(
|
||||
name: str,
|
||||
fires_at_iso: str,
|
||||
message: str,
|
||||
author: str = "aria",
|
||||
) -> dict:
|
||||
_safe_name(name)
|
||||
if _path(name).exists():
|
||||
raise ValueError(f"Trigger '{name}' existiert schon")
|
||||
# ISO validieren
|
||||
try:
|
||||
datetime.fromisoformat(fires_at_iso.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
|
||||
data = {
|
||||
"name": name,
|
||||
"type": "timer",
|
||||
"active": True,
|
||||
"author": author,
|
||||
"created_at": _now_iso(),
|
||||
"fires_at": fires_at_iso,
|
||||
"message": message,
|
||||
"fire_count": 0,
|
||||
"last_fired_at": None,
|
||||
}
|
||||
write(name, data)
|
||||
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso)
|
||||
return data
|
||||
|
||||
|
||||
def create_watcher(
|
||||
name: str,
|
||||
condition: str,
|
||||
message: str,
|
||||
check_interval_sec: int = 300,
|
||||
throttle_sec: int = 3600,
|
||||
author: str = "aria",
|
||||
) -> dict:
|
||||
_safe_name(name)
|
||||
if _path(name).exists():
|
||||
raise ValueError(f"Trigger '{name}' existiert schon")
|
||||
# Condition parsen-pruefen (wirft bei Syntax-Fehler)
|
||||
from watcher import parse_condition
|
||||
parse_condition(condition) # nur Validate
|
||||
if check_interval_sec < 30:
|
||||
check_interval_sec = 30 # nicht oefter als alle 30s pruefen
|
||||
if throttle_sec < 0:
|
||||
throttle_sec = 0
|
||||
data = {
|
||||
"name": name,
|
||||
"type": "watcher",
|
||||
"active": True,
|
||||
"author": author,
|
||||
"created_at": _now_iso(),
|
||||
"condition": condition,
|
||||
"check_interval_sec": int(check_interval_sec),
|
||||
"throttle_sec": int(throttle_sec),
|
||||
"message": message,
|
||||
"fire_count": 0,
|
||||
"last_fired_at": None,
|
||||
"last_checked_at": None,
|
||||
}
|
||||
write(name, data)
|
||||
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition)
|
||||
return data
|
||||
|
||||
|
||||
# ─── Feuern + Log ───────────────────────────────────────────────────
|
||||
|
||||
def mark_fired(name: str) -> dict:
|
||||
data = read(name)
|
||||
if data is None:
|
||||
raise ValueError(f"Trigger '{name}' nicht gefunden")
|
||||
data["fire_count"] = int(data.get("fire_count", 0)) + 1
|
||||
data["last_fired_at"] = _now_iso()
|
||||
# Timer: nach Feuern auto-deaktivieren (one-shot)
|
||||
if data.get("type") == "timer":
|
||||
data["active"] = False
|
||||
write(name, data)
|
||||
return data
|
||||
|
||||
|
||||
def append_log(name: str, entry: dict) -> None:
|
||||
_ensure_dirs()
|
||||
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
|
||||
record = {"ts": _now_iso()}
|
||||
record.update(entry)
|
||||
try:
|
||||
with log_file.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
except Exception as e:
|
||||
logger.warning("Trigger-Log append %s: %s", name, e)
|
||||
|
||||
|
||||
def list_logs(name: str, limit: int = 50) -> list[dict]:
|
||||
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
try:
|
||||
lines = log_file.read_text(encoding="utf-8").splitlines()
|
||||
out: list[dict] = []
|
||||
for line in lines[-limit:]:
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Built-in Condition-Variablen + sicherer Mini-Parser fuer Watcher-Triggers.
|
||||
|
||||
Erlaubte Variablen kommen aus diesem Modul. Condition-Ausdruck ist ein
|
||||
sicheres Subset von Python (kein eval, kein exec): nur Vergleiche und
|
||||
Boolean-Operatoren, nur die hier deklarierten Variablen, nur Zahlen +
|
||||
String-Literale als rechte Seite.
|
||||
|
||||
Beispiele:
|
||||
disk_free_gb < 5
|
||||
hour_of_day == 8 and day_of_week == "mon"
|
||||
rvs_connected == False
|
||||
(disk_free_pct < 10 and uptime_sec > 3600)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Variablen-Quellen ──────────────────────────────────────────────
|
||||
|
||||
def _disk_stats() -> tuple[float, float]:
|
||||
"""Returns (free_gb, free_pct). Schaut /shared (geteiltes Volume) — sonst /."""
|
||||
target = "/shared" if os.path.exists("/shared") else "/"
|
||||
try:
|
||||
st = shutil.disk_usage(target)
|
||||
free_gb = st.free / (1024 ** 3)
|
||||
free_pct = 100.0 * st.free / st.total if st.total else 0.0
|
||||
return free_gb, free_pct
|
||||
except Exception as e:
|
||||
logger.warning("disk_usage: %s", e)
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def _uptime_sec() -> int:
|
||||
try:
|
||||
with open("/proc/uptime", "r") as f:
|
||||
return int(float(f.read().split()[0]))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _rvs_connected() -> bool:
|
||||
"""Liest /shared/config/runtime.json oder ein Bridge-State-File.
|
||||
Aktuell: wir koennen das nicht zuverlaessig aus dem Brain-Container
|
||||
bestimmen — gibt False als sicheren Default zurueck.
|
||||
Spaeter: Bridge schreibt einen Heartbeat-File den wir hier lesen."""
|
||||
return False
|
||||
|
||||
|
||||
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
|
||||
def collect_variables() -> dict[str, Any]:
|
||||
"""Liefert aktuellen Snapshot aller Built-in-Variablen."""
|
||||
free_gb, free_pct = _disk_stats()
|
||||
now = datetime.now()
|
||||
# Memory-Count aus der Vector-DB (importiert lazy um zirkulaere Imports
|
||||
# zu vermeiden — beim Modul-Load gibt's noch keinen Store)
|
||||
memory_count = 0
|
||||
try:
|
||||
from main import store # type: ignore
|
||||
s = store()
|
||||
memory_count = s.count()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"disk_free_gb": round(free_gb, 2),
|
||||
"disk_free_pct": round(free_pct, 1),
|
||||
"uptime_sec": _uptime_sec(),
|
||||
"hour_of_day": now.hour,
|
||||
"day_of_week": _DAYS[now.weekday()],
|
||||
"rvs_connected": _rvs_connected(),
|
||||
"memory_count": memory_count,
|
||||
}
|
||||
|
||||
|
||||
def describe_variables() -> list[dict]:
|
||||
"""Liste der verfuegbaren Variablen + Beschreibung — fuer System-Prompt + UI."""
|
||||
return [
|
||||
{"name": "disk_free_gb", "type": "number", "desc": "freier Plattenplatz in GB (auf /shared)"},
|
||||
{"name": "disk_free_pct", "type": "number", "desc": "freier Plattenplatz in Prozent"},
|
||||
{"name": "uptime_sec", "type": "number", "desc": "Sekunden seit Brain-Start"},
|
||||
{"name": "hour_of_day", "type": "number", "desc": "0..23, lokale Zeit"},
|
||||
{"name": "day_of_week", "type": "string", "desc": "mon|tue|wed|thu|fri|sat|sun"},
|
||||
{"name": "rvs_connected", "type": "bool", "desc": "True wenn RVS-Verbindung steht"},
|
||||
{"name": "memory_count", "type": "number", "desc": "Anzahl Memories in der Vector-DB"},
|
||||
]
|
||||
|
||||
|
||||
# ─── Sicherer Condition-Parser ──────────────────────────────────────
|
||||
|
||||
_ALLOWED_NODES = (
|
||||
ast.Expression, ast.BoolOp, ast.UnaryOp, ast.Compare,
|
||||
ast.Name, ast.Constant, ast.Load,
|
||||
ast.And, ast.Or, ast.Not,
|
||||
ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
|
||||
)
|
||||
|
||||
|
||||
def parse_condition(expr: str) -> ast.Expression:
|
||||
"""Parst einen Condition-Ausdruck und validiert ihn gegen das Safe-Subset.
|
||||
Wirft ValueError bei verbotenen Konstrukten."""
|
||||
expr = (expr or "").strip()
|
||||
if not expr:
|
||||
raise ValueError("Leere Condition")
|
||||
if len(expr) > 500:
|
||||
raise ValueError("Condition zu lang (>500 Zeichen)")
|
||||
try:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
except SyntaxError as e:
|
||||
raise ValueError(f"Condition Syntax-Fehler: {e}")
|
||||
# Whitelist-Walk
|
||||
allowed_names = {v["name"] for v in describe_variables()}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, _ALLOWED_NODES):
|
||||
raise ValueError(f"Verbotener Ausdruck: {type(node).__name__}")
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id not in allowed_names and node.id not in ("True", "False"):
|
||||
raise ValueError(f"Unbekannte Variable: {node.id}")
|
||||
if isinstance(node, ast.Constant):
|
||||
if not isinstance(node.value, (int, float, str, bool)) and node.value is not None:
|
||||
raise ValueError(f"Verbotener Konstant-Typ: {type(node.value).__name__}")
|
||||
return tree
|
||||
|
||||
|
||||
def evaluate(expr: str, variables: dict[str, Any] | None = None) -> bool:
|
||||
"""Evaluiert die Condition gegen die aktuellen Variablen.
|
||||
Returns bool. Bei Fehler in Variablen → False (defensiv)."""
|
||||
tree = parse_condition(expr)
|
||||
vars_ = variables if variables is not None else collect_variables()
|
||||
code = compile(tree, "<condition>", "eval")
|
||||
# Globals leer, locals nur die erlaubten Variablen → kein Builtin-Zugriff
|
||||
try:
|
||||
result = eval(code, {"__builtins__": {}}, vars_)
|
||||
except Exception as e:
|
||||
logger.warning("Condition '%s' eval-Fehler: %s", expr, e)
|
||||
return False
|
||||
return bool(result)
|
||||
+31
-28
@@ -1086,6 +1086,12 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
|
||||
|
||||
# ARIA ist fertig — App's "ARIA denkt..." Indicator zurueck auf idle.
|
||||
# _last_chat_final_at bewusst NICHT setzen: die 3s-Cooldown war fuer
|
||||
# trailing OpenClaw-Activity-Events; bei Voice-Chat wuerde sie die
|
||||
# naechste thinking-Welle unterdruecken.
|
||||
await self._emit_activity("idle", "")
|
||||
|
||||
# ── Mode Persistence (global, nicht pro Geraet) ──────
|
||||
_MODE_FILE = "/shared/config/mode.json"
|
||||
|
||||
@@ -1250,12 +1256,9 @@ class ARIABridge:
|
||||
# / Diagnostic-Reload als History-Quelle gelesen.
|
||||
self._append_chat_backup({"role": "user", "text": text, "source": source})
|
||||
|
||||
# agent_activity broadcasten (App + Diagnostic "ARIA denkt..." Indicator)
|
||||
await self._send_to_rvs({
|
||||
"type": "agent_activity",
|
||||
"payload": {"activity": "thinking"},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
||||
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
|
||||
await self._emit_activity("thinking", "")
|
||||
|
||||
def _do_call():
|
||||
try:
|
||||
@@ -1272,11 +1275,7 @@ class ARIABridge:
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_call)
|
||||
if status != 200:
|
||||
logger.error("[brain] /chat fehlgeschlagen: status=%s body=%s", status, body[:200])
|
||||
await self._send_to_rvs({
|
||||
"type": "agent_activity",
|
||||
"payload": {"activity": "idle"},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
await self._emit_activity("idle", "")
|
||||
await self._send_to_rvs({
|
||||
"type": "chat",
|
||||
"payload": {
|
||||
@@ -1291,21 +1290,13 @@ class ARIABridge:
|
||||
data = json.loads(body)
|
||||
except Exception:
|
||||
logger.error("[brain] /chat lieferte ungueltiges JSON: %s", body[:200])
|
||||
await self._send_to_rvs({
|
||||
"type": "agent_activity",
|
||||
"payload": {"activity": "idle"},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
await self._emit_activity("idle", "")
|
||||
return
|
||||
|
||||
reply = (data.get("reply") or "").strip()
|
||||
if not reply:
|
||||
logger.warning("[brain] /chat: leerer Reply")
|
||||
await self._send_to_rvs({
|
||||
"type": "agent_activity",
|
||||
"payload": {"activity": "idle"},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
await self._emit_activity("idle", "")
|
||||
return
|
||||
|
||||
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
||||
@@ -1320,6 +1311,14 @@ class ARIABridge:
|
||||
})
|
||||
logger.info("[brain] ARIA hat einen Skill erstellt: %s",
|
||||
event.get("skill", {}).get("name"))
|
||||
elif etype == "trigger_created":
|
||||
await self._send_to_rvs({
|
||||
"type": "trigger_created",
|
||||
"payload": event.get("trigger", {}),
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
logger.info("[brain] ARIA hat einen Trigger angelegt: %s",
|
||||
event.get("trigger", {}).get("name"))
|
||||
|
||||
# _process_core_response uebernimmt alles weitere:
|
||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||
@@ -1331,6 +1330,8 @@ class ARIABridge:
|
||||
await self._process_core_response(reply, {})
|
||||
except Exception:
|
||||
logger.exception("[brain] _process_core_response Fehler")
|
||||
await self._emit_activity("idle", "")
|
||||
# Originaler Fallback-Send (toter Code, _emit_activity uebernimmt jetzt)
|
||||
await self._send_to_rvs({
|
||||
"type": "agent_activity",
|
||||
"payload": {"activity": "idle"},
|
||||
@@ -2050,13 +2051,11 @@ class ARIABridge:
|
||||
|
||||
if text.strip():
|
||||
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
|
||||
# Hints (Barge-In, GPS) als Praefix vorschalten — gemeinsamer Helper
|
||||
# mit dem chat-Pfad damit das Verhalten konsistent ist.
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
# ERST an aria-core senden (wichtigster Schritt)
|
||||
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
|
||||
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
|
||||
# sender="stt" damit Bridge es ignoriert (kein Loop)
|
||||
|
||||
# Reihenfolge wichtig: STT-Text ZUERST broadcasten damit die App
|
||||
# die Voice-Bubble sofort mit dem erkannten Text aktualisieren
|
||||
# kann — send_to_core blockt danach synchron auf Brain (kann
|
||||
# dauern), wuerde sonst die Anzeige verzoegern.
|
||||
try:
|
||||
stt_payload = {
|
||||
"text": text,
|
||||
@@ -2080,6 +2079,10 @@ class ARIABridge:
|
||||
logger.warning("[rvs] STT-Text NICHT broadcastet — _send_to_rvs lieferte False")
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
|
||||
|
||||
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
|
||||
else:
|
||||
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
||||
|
||||
|
||||
+297
-4
@@ -221,6 +221,7 @@
|
||||
<button class="main-nav-btn active" onclick="switchMainTab('main')">Main</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('brain')">Gehirn</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
|
||||
</div>
|
||||
@@ -899,6 +900,74 @@
|
||||
</div>
|
||||
</div><!-- /tab-skills -->
|
||||
|
||||
<!-- ══════ TAB: Trigger ══════ -->
|
||||
<div id="tab-triggers" class="main-tab">
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<h2 style="margin:0;">Trigger <button class="info-btn" onclick="showInfo('triggers')" title="Was sind Trigger?">ℹ</button></h2>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn secondary" onclick="loadTriggers()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
||||
<button class="btn" onclick="openTriggerCreate()" style="padding:4px 10px;font-size:11px;">+ Neu</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:8px;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0;">
|
||||
Trigger sind passive Aufweck-Quellen. Skills sind aktiv (ARIA ruft sie),
|
||||
Trigger sind passiv (System ruft ARIA wenn ein Event passiert). Polling
|
||||
kostet keine Tokens — nur das Feuern verbraucht eine Anfrage.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div id="triggers-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-triggers -->
|
||||
|
||||
<!-- Trigger-Create Modal -->
|
||||
<div class="modal-overlay" id="trigger-modal">
|
||||
<div class="modal-box" style="max-width:600px;">
|
||||
<div class="modal-header">
|
||||
<h3>Neuer Trigger</h3>
|
||||
<button class="modal-close" onclick="closeTriggerModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px;">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Typ</label>
|
||||
<select id="trigger-type" onchange="onTriggerTypeChange()" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
<option value="timer">Timer — einmalig zu festem Zeitpunkt</option>
|
||||
<option value="watcher">Watcher — wenn Bedingung wahr</option>
|
||||
</select>
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Name</label>
|
||||
<input type="text" id="trigger-name" placeholder="z.B. pasta, disk-warn" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
|
||||
<!-- Timer-spezifisch -->
|
||||
<div id="trigger-timer-fields">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
|
||||
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
</div>
|
||||
|
||||
<!-- Watcher-spezifisch -->
|
||||
<div id="trigger-watcher-fields" style="display:none;">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Condition (siehe Variablen unten)</label>
|
||||
<input type="text" id="trigger-condition" placeholder="z.B. disk_free_gb < 5" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:monospace;margin-bottom:10px;">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Check-Intervall (Sek, min 30)</label>
|
||||
<input type="number" id="trigger-check-interval" min="30" max="86400" value="300" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Throttle zwischen Feuerungen (Sek)</label>
|
||||
<input type="number" id="trigger-throttle" min="0" max="86400" value="3600" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
<div id="trigger-vars-info" style="font-size:10px;color:#555570;line-height:1.6;margin-bottom:10px;"></div>
|
||||
</div>
|
||||
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Nachricht</label>
|
||||
<textarea id="trigger-message" rows="3" placeholder="Was soll ARIA sagen wenn der Trigger feuert?" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;resize:vertical;margin-bottom:10px;"></textarea>
|
||||
|
||||
<div id="trigger-modal-error" style="color:#FF6B6B;font-size:11px;margin-top:4px;display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
|
||||
<button class="btn secondary" onclick="closeTriggerModal()">Abbrechen</button>
|
||||
<button class="btn" onclick="saveTrigger()">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
|
||||
<div class="modal-overlay" id="info-modal">
|
||||
<div class="modal-box" style="max-width:640px;">
|
||||
@@ -1274,6 +1343,14 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'trigger_created') {
|
||||
addTriggerCreatedBubble(msg.payload || {});
|
||||
// Falls Triggers-Tab offen: refreshen
|
||||
if (document.getElementById('tab-triggers') && document.getElementById('tab-triggers').classList.contains('visible')) {
|
||||
loadTriggers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'chat_delta') { return; }
|
||||
if (msg.type === 'chat_error') {
|
||||
addChat('error', msg.error, 'chat:error');
|
||||
@@ -1282,10 +1359,20 @@
|
||||
if (msg.type === 'rvs_chat') {
|
||||
const p = msg.msg.payload || {};
|
||||
const sender = p.sender || '?';
|
||||
// ARIA-Antworten kommen schon via Gateway (chat:final) — nicht nochmal via RVS anzeigen
|
||||
if (sender === 'aria') return;
|
||||
const chatType = 'sent';
|
||||
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
|
||||
// Frueher: 'aria' kam parallel via OpenClaw-Gateway (chat:final) UND via RVS,
|
||||
// RVS wurde dedupliziert. Gateway ist raus — ARIA-Antworten kommen jetzt
|
||||
// ausschliesslich via RVS, also nicht mehr blocken.
|
||||
let chatType, label;
|
||||
if (sender === 'aria') {
|
||||
chatType = 'received';
|
||||
label = 'ARIA';
|
||||
} else if (sender === 'stt') {
|
||||
chatType = 'sent';
|
||||
label = '\uD83C\uDFA4 Spracheingabe';
|
||||
} else {
|
||||
chatType = 'sent';
|
||||
label = `via RVS (${sender})`;
|
||||
}
|
||||
addChat(chatType, p.text || '?', label, { location: p.location });
|
||||
return;
|
||||
}
|
||||
@@ -1750,6 +1837,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** ARIA hat einen Trigger angelegt — Bubble mit Details. */
|
||||
function addTriggerCreatedBubble(trigger) {
|
||||
const name = trigger.name || '(unbenannt)';
|
||||
const ttype = trigger.type || 'timer';
|
||||
const msg = trigger.message || '';
|
||||
const detail = ttype === 'timer'
|
||||
? `feuert: <code>${escapeHtml(trigger.fires_at || '?')}</code>`
|
||||
: `wenn: <code>${escapeHtml(trigger.condition || '?')}</code>`;
|
||||
const html = `
|
||||
<div style="font-weight:bold;color:#FFD60A;">⏰ ARIA hat einen Trigger angelegt</div>
|
||||
<div style="margin-top:4px;color:#E0E0F0;">
|
||||
<strong>${escapeHtml(name)}</strong>
|
||||
<span style="color:#8888AA;font-size:11px;margin-left:6px;">(${escapeHtml(ttype)})</span>
|
||||
</div>
|
||||
<div style="color:#8888AA;font-size:11px;margin-top:2px;">${detail}</div>
|
||||
<div style="color:#8888AA;font-size:12px;margin-top:2px;">"${escapeHtml(msg)}"</div>
|
||||
<div class="meta">
|
||||
ARIA-Trigger — ${new Date().toLocaleTimeString('de-DE')} ·
|
||||
<a href="#" onclick="event.preventDefault();switchMainTab('triggers');" style="color:#FFD60A;">im Trigger-Tab ansehen</a>
|
||||
</div>`;
|
||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||
if (!box) continue;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-msg received';
|
||||
el.style.borderLeft = '3px solid #FFD60A';
|
||||
el.innerHTML = html;
|
||||
box.appendChild(el);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/** ARIA hat einen Skill erstellt — als auffaellige Bubble anzeigen. */
|
||||
function addSkillCreatedBubble(skill) {
|
||||
const name = skill.name || '(unbenannt)';
|
||||
@@ -2664,6 +2782,167 @@
|
||||
loadFiles();
|
||||
} else if (tab === 'skills') {
|
||||
loadSkills();
|
||||
} else if (tab === 'triggers') {
|
||||
loadTriggers();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Triggers-Verwaltung ────────────────────────────────
|
||||
let triggersCache = [];
|
||||
|
||||
async function loadTriggers() {
|
||||
const el = document.getElementById('triggers-list');
|
||||
if (!el) return;
|
||||
try {
|
||||
const r = await fetch('/api/brain/triggers/list');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
triggersCache = d.triggers || [];
|
||||
renderTriggersList();
|
||||
} catch (e) {
|
||||
el.innerHTML = `🔴 Brain nicht erreichbar (${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTriggersList() {
|
||||
const el = document.getElementById('triggers-list');
|
||||
if (!el) return;
|
||||
if (!triggersCache.length) {
|
||||
el.innerHTML = '<div style="padding:8px;color:#555570;">Keine Trigger vorhanden. Sag ARIA "erinner mich in 5 Minuten" oder leg manuell einen an.</div>';
|
||||
return;
|
||||
}
|
||||
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('de-DE') : '–';
|
||||
el.innerHTML = triggersCache.map(t => {
|
||||
const active = t.active !== false;
|
||||
const statusBadge = active
|
||||
? '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;">aktiv</span>'
|
||||
: '<span style="background:#55557022;color:#888;padding:1px 6px;border-radius:3px;font-size:10px;">INAKTIV</span>';
|
||||
const typeBadge = t.type === 'timer'
|
||||
? '<span style="background:#FFD60A22;color:#FFD60A;padding:1px 6px;border-radius:3px;font-size:10px;">⏱ TIMER</span>'
|
||||
: '<span style="background:#0096FF22;color:#0096FF;padding:1px 6px;border-radius:3px;font-size:10px;">👁 WATCHER</span>';
|
||||
const authorBadge = t.author === 'aria'
|
||||
? '<span style="background:#FFD60A22;color:#FFD60A;padding:1px 6px;border-radius:3px;font-size:10px;">von ARIA</span>'
|
||||
: '';
|
||||
let detailLine = '';
|
||||
if (t.type === 'timer') {
|
||||
detailLine = `feuert: <code>${escapeHtml(t.fires_at || '?')}</code>`;
|
||||
} else if (t.type === 'watcher') {
|
||||
detailLine = `wenn: <code>${escapeHtml(t.condition || '?')}</code> · check alle ${t.check_interval_sec}s · throttle ${t.throttle_sec}s`;
|
||||
}
|
||||
return `
|
||||
<div style="border-bottom:1px solid #1E1E2E;padding:8px 0;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="flex:1;color:#E0E0F0;font-weight:bold;">${escapeHtml(t.name)}</span>
|
||||
${statusBadge} ${typeBadge} ${authorBadge}
|
||||
<span style="color:#555570;font-size:10px;">${t.fire_count || 0}× · zuletzt ${fmtDate(t.last_fired_at)}</span>
|
||||
</div>
|
||||
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
|
||||
<div style="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
||||
<button class="btn secondary" onclick="toggleTriggerActive('${escapeHtml(t.name)}', ${!active})" style="padding:2px 10px;font-size:10px;color:#FF9500;border-color:#FF9500;">${active ? '⏸ Deaktivieren' : '▶ Aktivieren'}</button>
|
||||
<button class="btn secondary" onclick="deleteTrigger('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleTriggerActive(name, newActive) {
|
||||
try {
|
||||
await fetch('/api/brain/triggers/' + encodeURIComponent(name), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active: newActive }),
|
||||
});
|
||||
loadTriggers();
|
||||
} catch (e) {
|
||||
alert('Toggle fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrigger(name) {
|
||||
if (!confirm(`Trigger "${name}" wirklich löschen?`)) return;
|
||||
try {
|
||||
await fetch('/api/brain/triggers/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
loadTriggers();
|
||||
} catch (e) {
|
||||
alert('Löschen fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function onTriggerTypeChange() {
|
||||
const t = document.getElementById('trigger-type').value;
|
||||
document.getElementById('trigger-timer-fields').style.display = t === 'timer' ? '' : 'none';
|
||||
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function openTriggerCreate() {
|
||||
document.getElementById('trigger-type').value = 'timer';
|
||||
document.getElementById('trigger-name').value = '';
|
||||
document.getElementById('trigger-timer-minutes').value = '10';
|
||||
document.getElementById('trigger-condition').value = '';
|
||||
document.getElementById('trigger-check-interval').value = '300';
|
||||
document.getElementById('trigger-throttle').value = '3600';
|
||||
document.getElementById('trigger-message').value = '';
|
||||
document.getElementById('trigger-modal-error').style.display = 'none';
|
||||
onTriggerTypeChange();
|
||||
// Variablen-Hinweis laden
|
||||
try {
|
||||
const r = await fetch('/api/brain/triggers/conditions');
|
||||
const d = await r.json();
|
||||
const info = document.getElementById('trigger-vars-info');
|
||||
if (info) {
|
||||
info.innerHTML = '<strong>Variablen:</strong> ' + (d.variables || []).map(v =>
|
||||
`<code>${escapeHtml(v.name)}</code> = ${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
|
||||
).join(' · ');
|
||||
}
|
||||
} catch {}
|
||||
document.getElementById('trigger-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeTriggerModal() {
|
||||
document.getElementById('trigger-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function saveTrigger() {
|
||||
const errEl = document.getElementById('trigger-modal-error');
|
||||
errEl.style.display = 'none';
|
||||
const ttype = document.getElementById('trigger-type').value;
|
||||
const name = document.getElementById('trigger-name').value.trim();
|
||||
const message = document.getElementById('trigger-message').value.trim();
|
||||
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
|
||||
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
|
||||
try {
|
||||
let url, body;
|
||||
if (ttype === 'timer') {
|
||||
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
|
||||
const firesAt = new Date(Date.now() + mins * 60 * 1000).toISOString();
|
||||
url = '/api/brain/triggers/timer';
|
||||
body = { name, fires_at: firesAt, message, author: 'stefan' };
|
||||
} else {
|
||||
const condition = document.getElementById('trigger-condition').value.trim();
|
||||
if (!condition) { errEl.textContent = 'Condition fehlt.'; errEl.style.display = 'block'; return; }
|
||||
url = '/api/brain/triggers/watcher';
|
||||
body = {
|
||||
name, condition, message, author: 'stefan',
|
||||
check_interval_sec: parseInt(document.getElementById('trigger-check-interval').value, 10) || 300,
|
||||
throttle_sec: parseInt(document.getElementById('trigger-throttle').value, 10) || 3600,
|
||||
};
|
||||
}
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
throw new Error('HTTP ' + r.status + ': ' + t.slice(0, 200));
|
||||
}
|
||||
closeTriggerModal();
|
||||
loadTriggers();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3525,6 +3804,20 @@
|
||||
<p><strong>Warn-Schwellen:</strong> 5h-Counter wird gelb bei 80%, rot bei 90% des Plan-Limits.</p>
|
||||
`,
|
||||
},
|
||||
'triggers': {
|
||||
title: 'Trigger — passive Aufweck-Quellen',
|
||||
html: `
|
||||
<p><strong>Skills</strong> sind aktiv (ARIA ruft sie auf).
|
||||
<strong>Trigger</strong> sind passiv: das System ruft ARIA wenn ein Event passiert.</p>
|
||||
<p><strong>Timer:</strong> einmalig zu festem Zeitpunkt. "Erinner mich in 10min" → ARIA legt einen Timer an.</p>
|
||||
<p><strong>Watcher:</strong> pruefen alle paar Minuten eine Bedingung (z.B. <code>disk_free_gb < 5</code>),
|
||||
feuern wenn wahr. Throttle verhindert Spam (Default: max 1× pro Stunde).</p>
|
||||
<p><strong>Token-Effizienz:</strong> das Polling laeuft lokal im Brain-Container ohne Claude-Calls.
|
||||
Erst wenn ein Trigger tatsaechlich feuert, wird ARIA aufgeweckt und antwortet.</p>
|
||||
<p><strong>Wer legt sie an:</strong> entweder du (Diagnostic-Tab + Neu) oder ARIA selbst auf deinen Wunsch
|
||||
im Chat ("sag bescheid wenn Disk unter 5GB").</p>
|
||||
`,
|
||||
},
|
||||
'bootstrap': {
|
||||
title: 'Bootstrap & Migration — die drei Wege',
|
||||
html: `
|
||||
|
||||
@@ -25,6 +25,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"xtts_export_voice", "xtts_voice_exported",
|
||||
"xtts_import_voice", "xtts_voice_imported",
|
||||
"skill_created",
|
||||
"trigger_created",
|
||||
"chat_history_request", "chat_history_response", "chat_cleared",
|
||||
"file_delete_batch_request", "file_delete_batch_response",
|
||||
"file_zip_request", "file_zip_response",
|
||||
|
||||
Reference in New Issue
Block a user