feat(brain+ui+app): Triggers — passive Aufweck-Quellen fuer ARIA

ARIA hatte bisher nur ein "User fragt → Brain antwortet"-Modell. Neu:
Trigger laufen passiv im Hintergrund (kein LLM-Call) und wecken ARIA
nur dann auf wenn ein Event tatsaechlich passiert.

Drei Typen, zwei aktuell implementiert:
  timer   — einmalig zu festem ISO-Timestamp ("erinner mich in 10min")
  watcher — Polling alle N Sek einer Condition, feuert bei True mit Throttle
            (z.B. "disk_free_gb < 5", max 1x/h)
  cron    — Platzhalter fuer spaeter

aria-brain/triggers.py
  CRUD auf /data/triggers/<name>.json + /data/triggers/logs/<name>.jsonl.
  create_timer, create_watcher, mark_fired, list_logs, etc.

aria-brain/watcher.py
  Built-in Condition-Variablen: disk_free_gb, disk_free_pct, uptime_sec,
  hour_of_day, day_of_week, rvs_connected, memory_count.
  Sicherer Condition-Parser via ast — Whitelist auf Vergleich + BoolOp +
  Name + Const. Kein eval, kein exec, keine Builtins.

aria-brain/background.py
  Async Loop laeuft alle 30s, sammelt einmalig Variables, geht durch
  Trigger-Liste, _should_fire-Check (Timer: fires_at vergangen / Watcher:
  check_interval + throttle respektiert + condition true). Fire ruft
  agent.chat(prompt, source="trigger") — ARIA bekommt das wie eine
  Push-Nachricht und antwortet via Bridge → RVS → App.

aria-brain/main.py
  /triggers/list, /{name}, /{name}/logs, /timer, /watcher, PATCH, DELETE,
  /triggers/conditions (Variablen + aktuelle Werte). Lifespan-Handler
  startet den Background-Loop beim Container-Start, stoppt beim Shutdown.

aria-brain/agent.py
  Meta-Tools fuer ARIA: trigger_timer, trigger_watcher, trigger_cancel,
  trigger_list. ARIA legt Trigger via Tool-Call selbst an wenn Stefan das
  wuenscht. Side-Channel-Event 'trigger_created' wird in chat-Response
  mitgeschickt damit App + Diagnostic eine Bubble zeigen.

aria-brain/prompts.py
  Neue System-Prompt-Section: Liste aktiver Triggers + verfuegbare
  Condition-Variablen mit aktuellen Werten + Operatoren-Erklaerung.
  ARIA weiss damit immer was es schon gibt und welche Vars sie nutzen kann.

bridge/aria_bridge.py + rvs/server.js
  trigger_created als neuer RVS-Message-Type, Bridge forwarded das aus
  data.events analog zu skill_created.

diagnostic/index.html
  Neuer Top-Tab "Trigger". Liste mit Type-Badges (⏱ TIMER / 👁 WATCHER),
  Status, Fire-Count, last_fired. Aktivieren/Deaktivieren + Löschen pro
  Trigger. "+ Neu"-Modal mit Type-Dropdown, Timer-Minuten oder
  Watcher-Condition + Vars-Anzeige + Throttle. Info-Modal-Eintrag mit
  Erklaerung. Live-Bubble im Chat wenn ARIA selbst einen anlegt.

android/src/screens/ChatScreen.tsx
  trigger_created RVS-Handler → eigene Bubble (gelber Border, " ARIA
  hat einen Trigger angelegt", Type/Detail/Message/Zeit). ChatMessage
  bekam triggerCreated-Feld. Lokal-only-Schutz beim Server-Sync analog
  zu skill_created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:38:58 +02:00
parent 87cb687610
commit 31aa86a2a9
10 changed files with 1190 additions and 3 deletions
+283
View File
@@ -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()">&times;</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');
@@ -1760,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)';
@@ -2674,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';
}
}
@@ -3535,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 &lt; 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: `