Compare commits

...

8 Commits

Author SHA1 Message Date
duffyduck 5e1cb2d26a release: bump version to 0.1.6.3 2026-05-28 23:58:26 +02:00
duffyduck 8359500476 feat(skills): P3 config_schema + P4 Versionierung mit Rollback
P3 — Skill-Configuration
- aria-brain/skills.py: SKILL_CONFIGS_FILE (/shared/config/skill_configs.json)
  als zentrale Werte-Persistenz. _normalize_config_schema validiert die
  Schema-Felder (name/type/label/secret/description/default), CFG_<UPPER_NAME>
  ENV beim run_skill. create_skill + update_skill akzeptieren config_schema.
- agent.py: skill_set_config Brain-Tool fuer ARIA. skill_create/update um
  config_schema-Property erweitert.
- main.py: GET/POST /skills/{name}/config — secret-Werte in Antwort gemaskt.

P4 — Versionierung mit Rollback
- aria-brain/skills.py: archive_current_version archiviert nach
  versions/v_<ts>/ (ohne venv/logs). update_skill ruft das automatisch auf
  bevor strukturelle Aenderungen passieren. list_skill_versions,
  rollback_skill (mit Safety-Snapshot + automatischem venv-Rebuild),
  delete_skill_version.
- agent.py: skill_list_versions, skill_rollback Brain-Tools.
- main.py: GET /skills/{name}/versions, POST /skills/{name}/rollback,
  DELETE /skills/{name}/versions/{version_id}.

UI
- diagnostic/index.html: Skill-Detail um Config-Form (typ-spezifisch,
  Secrets als password-Input mit ***SET***-Hinweis) und Versions-Liste
  mit Rollback-/Delete-Button.
- android SkillBrowser: SkillDetailModal laedt config_schema + versions
  on-mount. Config-Form (TextInput + Switch fuer boolean), Versionen mit
  Rollback-Confirm. brainApi um SkillConfigField/SkillVersion +
  getSkillConfig/setSkillConfig/listSkillVersions/rollbackSkill/
  deleteSkillVersion erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:52:46 +02:00
duffyduck 1a72f27861 feat(brain): seed_rules erweitert — BRAIN_INTERNAL_URL + Auth-Strategie
ARIA wusste bisher nichts von BRAIN_INTERNAL_URL — sie hatte den Endpoint
zwar, aber keinen Grund ihn zu nutzen. Zwei neue rule-Memories:

- "BRAIN_INTERNAL_URL ist deine Brain-Schnittstelle" — listet die
  wichtigsten Endpoints (oauth/<service>/token, memory/search,
  memory/pinned, skills/list) und macht klar dass auch Daten wie
  Stefans Standort, Memories oder andere Skills aus dem Skill heraus
  abrufbar sind.
- "Auth-Strategie fuer externe APIs" — zwingt ARIA bei jedem API-Skill
  in eine Checkliste: erst OAuth2 pruefen (Spotify, Google, GitHub,
  Reddit, …), sonst statischer Key per config_schema, NIEMALS hardcoden.

Damit kommt sie eigenstaendig auf "Spotify = OAuth2 = Brain-Endpoint"
ohne dass Stefan das jedes Mal sagen muss. Insgesamt jetzt 7 seed_rules
statt 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:11:02 +02:00
duffyduck 32302a841e feat(brain): Skills holen OAuth-Tokens vom Brain + Anti-Friedhof-Check
P1+P2-Infrastruktur:

- Neuer Endpoint GET /oauth/{service}/token liefert aktuelles access_token
  mit Auto-Refresh (< 60s Restzeit). Skills rufen das ueber
  BRAIN_INTERNAL_URL ab statt client_secret hardzucoden.
- run_skill setzt BRAIN_INTERNAL_URL als ENV (Default http://localhost:8080,
  override via Brain-Env). Skills laufen im Brain-Container, localhost passt.
- skills.create_skill: _check_anti_graveyard rejected Versions-Suffixe
  (-v2, _v3, -new, -fixed, -old, -alt, -copy, -final, -clean) und
  Prefix-Kollisionen (z.B. spotify-aria wenn spotify schon existiert) — die
  zwei Patterns hinter dem alten Skill-Friedhof.

Tool-Description fuer skill_create um PFLICHT-VORHER-Block ergaenzt
(skill_list, kein Versionssuffix, oauth_get_token, config_schema) damit
ARIA die Regeln direkt im Schema sieht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:04:22 +02:00
duffyduck 474e2c6c50 feat(brain): Skill-Regeln als seed_rules — idempotent auf Brain-Boot in DB
Stefans Skill-Friedhof (9 Spotify-Skills, hardcoded Credentials) hatte
keine systemische Ursache im Code, sondern im fehlenden Leitplanken-
Memory. Lösung: System-Seed-Regeln als pinned Hot Memory, mit jedem
Deploy ausgerollt.

- aria-brain/seed_rules.py: 5 rule-type Memories (skill_list-vor-create,
  no-version-suffix, update-not-recreate, no-hardcoded-credentials,
  config-schema-for-settings), source="seed", pinned=true
- Lifespan ruft seed_rules.apply() beim Brain-Start — idempotent via
  migration_key (alte Versionen werden vor dem Schreiben gelöscht)
- skill_create Tool-Description um PFLICHT-VORHER-Block ergänzt:
  skill_list-check, kein Versionssuffix, oauth_get_token bei OAuth,
  config_schema statt hardcoded Werte

Editieren = SEED_RULES-Liste anpassen, Brain neu starten. Im Gegensatz
zu brain-import/ (User-Saatgut, gitignored, manueller Diagnostic-Klick)
gehört das hier zum Brain-Code und rollt mit jedem Deploy aus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:55:05 +02:00
duffyduck 3e0cfef63c changed docker compose rvs to 444 2026-05-25 10:31:27 +02:00
duffyduck b94626787b fix(diagnostic): chat_history-Render verträgt kaputte Bubbles + EHOSTUNREACH skipped TLS-Fallback
Zwei kleine Robustness-Verbesserungen:

1) chat_history-Handler im Frontend: jede Bubble jetzt in try/catch. Wenn
   eine Bubble bei der Render-Pipeline (escape/linkify/regex-replace) eine
   Exception wirft, brach die ganze for-Schleife ab und alle nachfolgenden
   Bubbles wurden nicht mehr in den DOM geschrieben — beim Reload sah man
   dann nur die ersten N Eintraege und Stefan dachte die letzten Antworten
   waeren weg. Jetzt: Fehler-Bubble mit "⚠ Render-Fehler" + console.error,
   restliche Bubbles laufen weiter durch.

2) Diagnostic-Server RVS-Reconnect: TLS-Fallback war auch bei reinen
   Netz-Fehlern (EHOSTUNREACH, ECONNREFUSED, ENETUNREACH, ETIMEDOUT,
   ENOTFOUND, EAI_AGAIN) gefeuert — bringt nichts weil der Server eh tot
   ist, generiert aber doppelte Reconnect-Versuche + Log-Spam. Jetzt nur
   noch bei wirklichen TLS/Handshake-Fehlern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:28:57 +02:00
duffyduck ad87c807de fix(app): App-Reconnect nach Hintergrund — Sticky-Fallback, Zombie-WS, AppState-Hook
Stefan musste seit der HTTPS-Umstellung nach jedem Hintergrund-Rueckkehr
manuell auf "Verbinden" tippen, meist 3x bis es ging. Gleiche Bug-Klasse
wie auf der Bridge davor (Sticky-Fallback), plus zwei App-spezifische
Symptome.

Drei Ursachen:

1. usingTLSFallback klebt: einmal nach onerror auf true gesetzt, blieb
   es bei allen folgenden Reconnects → App versuchte ws://...:443 gegen
   den TLS-only Caddy → HTTP 400 → endlos. Reset war NUR im manuellen
   connect(), nicht in onclose oder scheduleReconnect.
   Fix: in onclose `usingTLSFallback = false` damit der naechste
   Reconnect wieder primary (wss://) probiert.

2. Zombie-WebSocket: Android kann den TCP-Socket im Background still
   killen, der JS-State zeigt aber noch readyState === OPEN. Stefans
   manueller "Verbinden"-Klick rief connect() → "Bereits verbunden"
   No-Op statt sich neu aufzubauen.
   Fix: connect(force=true) optional, bestehendes WS-Objekt wird hart
   geschlossen (mit onclose=null gegen Doppel-Reconnect) bevor neuer
   Aufbau startet.

3. Keine aktive Reconnect-Sequence bei Foreground-Resume: App war
   abhaengig von onclose-Events die bei Zombie-WS nicht zwingend
   feuern.
   Fix: AppState-Listener in App.tsx, bei background → active
   automatischer rvs.connect(true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:09:30 +02:00
13 changed files with 1271 additions and 38 deletions
+19 -1
View File
@@ -6,7 +6,7 @@
*/ */
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native'; import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
@@ -107,8 +107,26 @@ const App: React.FC = () => {
console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err); console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err);
}); });
// AppState-Listener: nach Hintergrund-Rueckkehr aktiv die WS-
// Verbindung neu aufbauen. Hintergrund: Android kann den TCP-Socket
// im Background killen, JS-State zeigt aber noch OPEN → Stefan musste
// manuell in Settings auf "Verbinden" tippen, oft mehrfach. Mit dem
// force-Reconnect bei "active" greift das automatisch.
let lastAppState: AppStateStatus = AppState.currentState;
const appStateSub = AppState.addEventListener('change', (next) => {
const wasBg = lastAppState !== 'active';
lastAppState = next;
if (next === 'active' && wasBg) {
console.log('[App] Foreground-Resume — force-reconnect zum RVS');
try { rvs.connect(true); } catch (e: any) {
console.warn('[App] force-reconnect fehlgeschlagen:', e?.message || e);
}
}
});
// Beim Beenden: Verbindung sauber trennen // Beim Beenden: Verbindung sauber trennen
return () => { return () => {
appStateSub.remove();
rvs.disconnect(); rvs.disconnect();
}; };
}, []); }, []);
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10602 versionCode 10603
versionName "0.1.6.2" versionName "0.1.6.3"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.6.2", "version": "0.1.6.3",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+188 -1
View File
@@ -24,7 +24,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import brainApi, { Skill } from '../services/brainApi'; import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi';
const COL_ACTIVE = '#34C759'; const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570'; const COL_INACTIVE = '#555570';
@@ -177,8 +177,30 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
const [logs, setLogs] = useState<any[] | null>(null); const [logs, setLogs] = useState<any[] | null>(null);
const [loadingLogs, setLoadingLogs] = useState(false); const [loadingLogs, setLoadingLogs] = useState(false);
// P3: Skill-Config (statische Werte je Skill, z.B. API-Keys)
const [cfgSchema, setCfgSchema] = useState<SkillConfigField[]>([]);
const [cfgValues, setCfgValues] = useState<Record<string, any>>({});
const [cfgDraft, setCfgDraft] = useState<Record<string, string>>({});
const [cfgSaving, setCfgSaving] = useState(false);
// P4: Versionen + Rollback
const [versions, setVersions] = useState<SkillVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const args = Array.isArray(skill.args) ? skill.args : []; const args = Array.isArray(skill.args) ? skill.args : [];
// Config + Versionen beim Mount laden
useEffect(() => {
brainApi.getSkillConfig(skill.name)
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); })
.catch(() => {});
setVersionsLoading(true);
brainApi.listSkillVersions(skill.name)
.then(setVersions)
.catch(() => setVersions([]))
.finally(() => setVersionsLoading(false));
}, [skill.name]);
const setArg = (name: string, value: string) => const setArg = (name: string, value: string) =>
setArgValues(prev => ({ ...prev, [name]: value })); setArgValues(prev => ({ ...prev, [name]: value }));
@@ -225,6 +247,85 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
); );
}; };
const saveConfig = () => {
// secret-Felder die als '***SET***' angezeigt sind und vom User NICHT
// angefasst wurden, bleiben auf dem alten Wert. cfgDraft enthaelt nur
// explizit getippte Werte; alles andere uebernehmen wir aus cfgValues.
const next: Record<string, any> = { ...cfgValues };
for (const f of cfgSchema) {
const draft = cfgDraft[f.name];
const isSecret = f.secret || f.type === 'password';
if (draft === undefined) continue;
if (isSecret && draft === '') continue; // leer = unveraendert
if (draft === '') { delete next[f.name]; continue; }
if (f.type === 'number') {
const n = Number(draft); next[f.name] = isNaN(n) ? draft : n;
} else if (f.type === 'boolean') {
next[f.name] = draft === 'true' || draft === '1';
} else {
next[f.name] = draft;
}
}
// Maskierte Werte (***SET***) niemals zurueckschreiben
for (const k of Object.keys(next)) if (next[k] === '***SET***') delete next[k];
setCfgSaving(true);
brainApi.setSkillConfig(skill.name, next)
.then(() => {
// frisch laden um neuen masked-State zu zeigen
return brainApi.getSkillConfig(skill.name);
})
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); setCfgDraft({}); })
.catch(e => Alert.alert('Speichern fehlgeschlagen', String(e?.message || e)))
.finally(() => setCfgSaving(false));
};
const reloadVersions = () => {
setVersionsLoading(true);
brainApi.listSkillVersions(skill.name)
.then(setVersions)
.catch(() => {})
.finally(() => setVersionsLoading(false));
};
const doRollback = (versionId: string) => {
Alert.alert(
'Rollback?',
`Skill "${skill.name}" auf ${versionId} zuruecksetzen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Rollback', style: 'destructive',
onPress: () => {
brainApi.rollbackSkill(skill.name, versionId)
.then(r => {
Alert.alert('Rollback OK', `Safety-Snapshot: ${r.safety_snapshot}`);
reloadVersions(); onReload();
})
.catch(e => Alert.alert('Rollback fehlgeschlagen', String(e?.message || e)));
},
},
],
);
};
const removeVersion = (versionId: string) => {
Alert.alert(
'Version loeschen?',
`${versionId} dauerhaft entfernen?`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen', style: 'destructive',
onPress: () => {
brainApi.deleteSkillVersion(skill.name, versionId)
.then(reloadVersions)
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
return ( return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}> <Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
<View style={s.modal}> <View style={s.modal}>
@@ -274,6 +375,92 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
</> </>
) : null} ) : null}
{/* Config-Schema-Form (P3) */}
{cfgSchema.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}> Konfiguration</Text>
{cfgSchema.map((f) => {
const isSecret = f.secret || f.type === 'password';
const cur = cfgValues[f.name];
const isSet = isSecret && cur === '***SET***';
const placeholder = isSet ? '••• gesetzt — leer lassen = unverändert'
: (f.default !== undefined && f.default !== null ? `Default: ${String(f.default)}` : (f.type || 'string'));
const valStr = cfgDraft[f.name] !== undefined
? cfgDraft[f.name]
: (isSecret ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? String(cur) : ''));
if (f.type === 'boolean') {
const bv = cfgDraft[f.name] !== undefined
? (cfgDraft[f.name] === 'true')
: (cur === true || cur === 'true');
return (
<View key={f.name} style={{marginBottom: 10, flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Switch value={bv} onValueChange={(v) => setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))}
trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" />
<View style={{flex: 1}}>
<Text style={{color: '#E0E0F0', fontSize: 13}}>{f.label || f.name}</Text>
{f.description ? <Text style={{color: '#555570', fontSize: 11}}>{f.description}</Text> : null}
</View>
</View>
);
}
return (
<View key={f.name} style={{marginBottom: 10}}>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
{f.label || f.name}{isSecret ? ' 🔒' : ''}
{f.description ? <Text style={{color: '#555570'}}> {f.description}</Text> : null}
</Text>
<TextInput
style={s.input}
value={valStr}
onChangeText={(v) => setCfgDraft(p => ({...p, [f.name]: v}))}
placeholder={placeholder}
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={isSecret}
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
/>
</View>
);
})}
<TouchableOpacity
style={[s.btn, {backgroundColor: '#1A1A2E', borderColor: COL_ACTIVE, marginTop: 4}]}
onPress={saveConfig}
disabled={cfgSaving}
>
<Text style={{color: COL_ACTIVE, textAlign: 'center', fontWeight: '700'}}>
{cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'}
</Text>
</TouchableOpacity>
</>
) : null}
{/* Versionen (P4) */}
{versions.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}>📦 Versionen ({versions.length})</Text>
{versions.map(v => (
<View key={v.version_id} style={[s.metaBox, {marginTop: 6, flexDirection: 'row', alignItems: 'center', gap: 6}]}>
<View style={{flex: 1}}>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#E0E0F0'}]}>{v.version_id}</Text>
<Text style={s.meta}>{v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'}</Text>
{v.summary ? <Text style={[s.meta, {fontStyle: 'italic'}]} numberOfLines={2}>{v.summary}</Text> : null}
</View>
<TouchableOpacity onPress={() => doRollback(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}>
<Text style={{color: COL_ARIA, fontSize: 12}}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => removeVersion(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}>
<Text style={{color: '#FF6B6B', fontSize: 12}}>🗑</Text>
</TouchableOpacity>
</View>
))}
</>
) : versionsLoading ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
) : null}
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}> <View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
<TouchableOpacity <TouchableOpacity
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]} style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
+60 -1
View File
@@ -158,6 +158,26 @@ export interface Skill {
version?: string; version?: string;
author?: string; // "aria" | "stefan" author?: string; // "aria" | "stefan"
setup_error?: string; setup_error?: string;
// P3: konfigurierbare Werte (API-Keys, IDs etc.) — Stefan setzt sie hier,
// Skill bekommt sie als CFG_<NAME> ENV. Werte selbst kommen via /config.
config_schema?: SkillConfigField[];
// P4: Versions-Historie. Detail-Liste kommt via /versions.
version_history?: { version_id: string; archived_at?: string; summary?: string }[];
}
export interface SkillConfigField {
name: string;
type: 'string' | 'number' | 'boolean' | 'password';
label?: string;
secret?: boolean;
description?: string;
default?: any;
}
export interface SkillVersion {
version_id: string;
archived_at?: string;
summary?: string;
} }
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */ /** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
@@ -395,7 +415,46 @@ export const brainApi = {
/** Letzte Run-Logs eines Skills. */ /** Letzte Run-Logs eines Skills. */
getSkillLogs(name: string, limit: number = 20): Promise<any[]> { getSkillLogs(name: string, limit: number = 20): Promise<any[]> {
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`); return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`)
.then((r: any) => Array.isArray(r) ? r : (r?.logs || []));
},
/** P3: Config-Schema + aktuelle Werte (secret-Felder gemaskt mit '***SET***'). */
getSkillConfig(name: string): Promise<{ schema: SkillConfigField[]; values: Record<string, any> }> {
return _send(`/skills/${encodeURIComponent(name)}/config`)
.then((r: any) => ({ schema: r?.schema || [], values: r?.values || {} }));
},
/** P3: Config-Werte komplett ueberschreiben. Werte greifen ab dem naechsten Run. */
setSkillConfig(name: string, values: Record<string, any>): Promise<{ ok: boolean; values: Record<string, any> }> {
return _send(`/skills/${encodeURIComponent(name)}/config`, {
method: 'POST',
body: { values },
timeoutMs: 10000,
});
},
/** P4: Liste archivierter Versionen, neueste zuerst. */
listSkillVersions(name: string): Promise<SkillVersion[]> {
return _send(`/skills/${encodeURIComponent(name)}/versions`)
.then((r: any) => r?.versions || []);
},
/** P4: Rollback auf eine fruehere Version. Aktueller Stand wird automatisch gesichert. */
rollbackSkill(name: string, versionId: string): Promise<{ ok: boolean; rolled_back_to: string; safety_snapshot: string }> {
return _send(`/skills/${encodeURIComponent(name)}/rollback`, {
method: 'POST',
body: { version_id: versionId },
timeoutMs: 60000, // venv-Rebuild kann dauern
});
},
/** P4: Einzelne Version dauerhaft loeschen. */
deleteSkillVersion(name: string, versionId: string): Promise<{ ok: boolean; deleted: string }> {
return _send(`/skills/${encodeURIComponent(name)}/versions/${encodeURIComponent(versionId)}`, {
method: 'DELETE',
timeoutMs: 10000,
});
}, },
// ── OAuth ──────────────────────────────────────────────────────── // ── OAuth ────────────────────────────────────────────────────────
+31 -3
View File
@@ -83,21 +83,39 @@ class RVSConnection {
// --- Verbindung --- // --- Verbindung ---
/** Verbindung zum RVS aufbauen */ /** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart
connect(): void { * schliessen + neu verbinden (auch wenn JS denkt readyState=OPEN — kann
* nach Hintergrund-Pause ein Zombie-WS sein wo TCP tot ist aber JS-State
* noch OPEN zeigt; in dem Fall war "Bereits verbunden" ein No-Op und
* Stefan musste manuell zigmal klicken). */
connect(force: boolean = false): void {
if (!this.config) { if (!this.config) {
this.log('warn', 'Keine Verbindungskonfiguration vorhanden'); this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
return; return;
} }
if (this.ws?.readyState === WebSocket.OPEN) { if (!force && this.ws?.readyState === WebSocket.OPEN) {
this.log('info', 'Bereits verbunden'); this.log('info', 'Bereits verbunden');
return; return;
} }
// Wenn ein WS-Objekt da ist (Zombie oder lebend), sauber abreissen
// bevor wir einen neuen aufbauen — sonst gibt's zwei parallele
// Verbindungen + doppelte Events.
if (this.ws) {
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
try {
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
this.ws.onerror = null;
this.ws.close();
} catch (_) {}
this.ws = null;
}
this.shouldReconnect = true; this.shouldReconnect = true;
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.usingTLSFallback = false; this.usingTLSFallback = false;
this.clearTimers();
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`); this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
this.establishConnection(); this.establishConnection();
} }
@@ -212,6 +230,16 @@ class RVSConnection {
this.ws = null; this.ws = null;
this.setState('disconnected'); this.setState('disconnected');
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
// der Hauptgrund warum die App nach Hintergrund-Rueckkehr nicht
// mehr verband — TLS-Handshake-Timeout in einem Reconnect → Fallback
// auf ws:// → Caddy refused → endlos im Fallback haengen.
if (this.usingTLSFallback) {
this.log('info', 'Reset TLS-Fallback fuer naechsten Reconnect (zurueck zu wss://)');
this.usingTLSFallback = false;
}
if (this.shouldReconnect) { if (this.shouldReconnect) {
this.scheduleReconnect(); this.scheduleReconnect();
} }
+140
View File
@@ -72,6 +72,18 @@ META_TOOLS = [
"Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). " "Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
"Skills sind IMMER Python — jeder Skill bekommt seine eigene venv " "Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
"mit den pip_packages die er braucht.\n\n" "mit den pip_packages die er braucht.\n\n"
"PFLICHT VORHER:\n"
" - `skill_list` aufrufen und pruefen ob ein passender Skill schon "
"existiert. Wenn ja: `skill_update` statt neu anlegen.\n"
" - Name OHNE Versionssuffix waehlen (kein `-v2`, `_v3`, `-new`, "
"`-fixed`, `-aria`, `-ctl`). Versionsverwaltung ist intern, Du brauchst "
"nur einen klaren Namen.\n"
" - Bei OAuth-Services (Spotify, Google, GitHub etc.): NIEMALS "
"client_id/client_secret/Tokens in den Code schreiben. Nutze "
"`oauth_get_token('<service>')` — das macht Auto-Refresh. Sonst muss "
"Stefan sich alle 60min manuell neu einloggen.\n"
" - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
"ueber `config_schema` deklarieren, NICHT hardcoden.\n\n"
"HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine " "HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
"pip-Library. Sonst muesste der Install bei jedem Container-Restart " "pip-Library. Sonst muesste der Install bei jedem Container-Restart "
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n" "neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
@@ -159,11 +171,85 @@ META_TOOLS = [
}, },
"description": {"type": "string", "description": "Neue Beschreibung (optional)"}, "description": {"type": "string", "description": "Neue Beschreibung (optional)"},
"active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"}, "active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"},
"config_schema": {
"type": "array",
"items": {"type": "object"},
"description": (
"Optional neues config_schema fuer den Skill. Liste von "
"Feldern [{name, type, label, secret?, description?, default?}]. "
"type: string|number|boolean|password (password impliziert secret=true). "
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
),
},
}, },
"required": ["name"], "required": ["name"],
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "skill_set_config",
"description": (
"Setzt Config-Werte fuer einen Skill persistent (z.B. API-Keys, "
"User-IDs, Endpoint-URLs). Werte landen als CFG_<UPPER_NAME> ENV "
"im naechsten skill_run. Nutze das wenn Stefan dir im Chat einen "
"Wert nennt ('mein OpenWeather-Key ist abc123') — schreib den "
"NICHT in den Skill-Code, sondern hierher.\n\n"
"WICHTIG: values ueberschreibt komplett. Wenn Du nur einen Wert "
"aendern willst: erst per Diagnostic-UI oder Skill-Inspect die "
"aktuelle Liste ansehen und mit dem neuen Wert ergaenzen."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Skill-Name"},
"values": {
"type": "object",
"description": "Map config-Feldname → Wert. Felder muessen im config_schema deklariert sein.",
},
},
"required": ["name", "values"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_list_versions",
"description": (
"Listet archivierte Versionen eines Skills (jeder skill_update "
"legt automatisch eine an). Returns [{version_id, archived_at, "
"summary}]. Brauchst Du fuer skill_rollback."
),
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_rollback",
"description": (
"Stellt eine fruehere Skill-Version wieder her. Vor dem Rollback "
"wird der aktuelle Stand automatisch archiviert — du verlierst "
"nichts. Nutze das wenn ein skill_update was kaputt gemacht hat "
"oder Stefan sagt 'mach den letzten Stand wieder her'. "
"version_id bekommst Du aus skill_list_versions."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version_id": {"type": "string", "description": "Format v_<timestamp>"},
},
"required": ["name", "version_id"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -844,6 +930,7 @@ class Agent:
readme=arguments.get("readme", ""), readme=arguments.get("readme", ""),
args=arguments.get("args", []), args=arguments.get("args", []),
pip_packages=arguments.get("pip_packages", []), pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None,
author="aria", author="aria",
) )
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -876,6 +963,8 @@ class Agent:
patch[k] = arguments[k] patch[k] = arguments[k]
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list): if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
patch["pip_packages"] = arguments["pip_packages"] patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
patch["config_schema"] = arguments["config_schema"]
if not patch: if not patch:
return "FEHLER: keine Felder zum Update angegeben." return "FEHLER: keine Felder zum Update angegeben."
try: try:
@@ -906,6 +995,57 @@ class Agent:
except ValueError as exc: except ValueError as exc:
return f"FEHLER: {exc}" return f"FEHLER: {exc}"
return f"OK — Skill '{skill_name}' geloescht." return f"OK — Skill '{skill_name}' geloescht."
if name == "skill_set_config":
skill_name = (arguments.get("name") or "").strip()
values = arguments.get("values")
if not skill_name or not isinstance(values, dict):
return "FEHLER: name + values (dict) erforderlich."
try:
skills_mod.set_skill_config(skill_name, values)
except ValueError as exc:
return f"FEHLER: {exc}"
masked = skills_mod.get_skill_config_masked(skill_name)
return (
f"OK — Config fuer Skill '{skill_name}' gesetzt. "
f"Aktuelle Werte (secrets gemasked): {masked}"
)
if name == "skill_list_versions":
skill_name = (arguments.get("name") or "").strip()
if not skill_name:
return "FEHLER: name ist Pflicht."
versions = skills_mod.list_skill_versions(skill_name)
if not versions:
return f"Skill '{skill_name}' hat keine archivierten Versionen."
lines = [
f"- {v.get('version_id')} ({v.get('archived_at','?')}) {v.get('summary','')}"
for v in versions
]
return "Versionen (neueste zuerst):\n" + "\n".join(lines)
if name == "skill_rollback":
skill_name = (arguments.get("name") or "").strip()
version_id = (arguments.get("version_id") or "").strip()
if not skill_name or not version_id:
return "FEHLER: name + version_id erforderlich."
try:
res = skills_mod.rollback_skill(skill_name, version_id)
except ValueError as exc:
return f"FEHLER: {exc}"
# Side-Channel-Event als skill_created getarnt — App/Diagnostic
# zeigen Rollback dann als sichtbare Aktion an
self._pending_events.append({
"type": "skill_created",
"skill": {
"name": skill_name,
"description": "(rollback)",
"execution": "local-venv",
"active": True,
"updated": True,
},
})
return (
f"OK — Skill '{skill_name}' auf '{version_id}' zurueckgerollt. "
f"Sicherheits-Snapshot des vorherigen Stands: {res.get('safety_snapshot')}"
)
if name.startswith("run_"): if name.startswith("run_"):
skill_name = name[len("run_"):] skill_name = name[len("run_"):]
res = skills_mod.run_skill(skill_name, args=arguments) res = skills_mod.run_skill(skill_name, args=arguments)
+93 -1
View File
@@ -37,6 +37,7 @@ import triggers as triggers_mod
import watcher as watcher_mod import watcher as watcher_mod
import background as background_mod import background as background_mod
import oauth as oauth_mod import oauth as oauth_mod
import seed_rules as seed_rules_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain") logger = logging.getLogger("aria-brain")
@@ -46,7 +47,13 @@ QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Beim Brain-Start: Trigger-Background-Loop anwerfen. Beim Shutdown: stoppen.""" """Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
Trigger-Background-Loop anwerfen. Beim Shutdown: Loop stoppen."""
try:
result = seed_rules_mod.apply(store(), embedder())
logger.info("Lifespan: seed_rules angewendet (%s)", result)
except Exception as exc:
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
task = asyncio.create_task(background_mod.run_loop(agent)) task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet") logger.info("Lifespan: Trigger-Loop gestartet")
try: try:
@@ -750,6 +757,7 @@ class SkillCreate(BaseModel):
requires: dict = Field(default_factory=dict) requires: dict = Field(default_factory=dict)
pip_packages: list = Field(default_factory=list) pip_packages: list = Field(default_factory=list)
author: str = "stefan" author: str = "stefan"
config_schema: list = Field(default_factory=list)
class SkillRun(BaseModel): class SkillRun(BaseModel):
@@ -762,6 +770,18 @@ class SkillPatch(BaseModel):
description: str | None = None description: str | None = None
active: bool | None = None active: bool | None = None
args: list | None = None args: list | None = None
entry_code: str | None = None
readme: str | None = None
pip_packages: list | None = None
config_schema: list | None = None
class SkillConfigSet(BaseModel):
values: dict
class SkillRollback(BaseModel):
version_id: str
@app.get("/skills/list") @app.get("/skills/list")
@@ -791,6 +811,7 @@ def skills_create(body: SkillCreate):
requires=body.requires, requires=body.requires,
pip_packages=body.pip_packages, pip_packages=body.pip_packages,
author=body.author, author=body.author,
config_schema=body.config_schema,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
@@ -827,6 +848,57 @@ def skills_logs(name: str, limit: int = 50):
return {"logs": skills_mod.list_logs(name, limit=limit)} return {"logs": skills_mod.list_logs(name, limit=limit)}
# ── Skill-Configs (P3): statische Werte (API-Keys etc.) je Skill ───
@app.get("/skills/{name}/config")
def skills_config_get(name: str):
"""Liefert config_schema + aktuelle Werte (secret-Felder gemaskt mit
'***SET***')."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {
"schema": manifest.get("config_schema") or [],
"values": skills_mod.get_skill_config_masked(name),
}
@app.post("/skills/{name}/config")
def skills_config_set(name: str, body: SkillConfigSet):
"""Setzt Config-Werte (komplett ueberschreibend). Werte greifen ab dem
naechsten skill_run. Secret-Felder werden in der Antwort gemaskt."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
skills_mod.set_skill_config(name, body.values)
return {"ok": True, "values": skills_mod.get_skill_config_masked(name)}
# ── Skill-Versions (P4): rollback ──────────────────────────────────
@app.get("/skills/{name}/versions")
def skills_versions_list(name: str):
if skills_mod.read_manifest(name) is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {"versions": skills_mod.list_skill_versions(name)}
@app.post("/skills/{name}/rollback")
def skills_rollback(name: str, body: SkillRollback):
try:
return skills_mod.rollback_skill(name, body.version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/skills/{name}/versions/{version_id}")
def skills_versions_delete(name: str, version_id: str):
try:
return skills_mod.delete_skill_version(name, version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.get("/skills/{name}/export") @app.get("/skills/{name}/export")
def skills_export(name: str): def skills_export(name: str):
try: try:
@@ -932,6 +1004,26 @@ async def oauth_revoke_endpoint(service: str):
return {"ok": oauth_mod.revoke(service)} return {"ok": oauth_mod.revoke(service)}
@app.get("/oauth/{service}/token")
async def oauth_token_endpoint(service: str):
"""Liefert das aktuelle access_token fuer einen Service (mit Auto-Refresh
wenn < 60s Restzeit). Nur fuer interne Skill-Aufrufe gedacht — Skills
sollen NIEMALS hardcoded client_secrets haben, sondern dieses Endpoint
pollen. Antwort: {access_token, expires_at, expires_in_sec}.
Bei nicht-autorisiert: 401 mit klarer Message."""
try:
rec = oauth_mod.get_token(service)
except RuntimeError as exc:
raise HTTPException(401, str(exc))
expires_at = int(rec.get("expires_at") or 0)
import time as _t
return {
"access_token": rec.get("access_token"),
"expires_at": expires_at,
"expires_in_sec": max(0, expires_at - int(_t.time())),
}
class OAuthAuthorizeIn(BaseModel): class OAuthAuthorizeIn(BaseModel):
service: str service: str
scopes: Optional[List[str]] = None scopes: Optional[List[str]] = None
+196
View File
@@ -0,0 +1,196 @@
"""
System-Seed-Regeln — werden bei jedem Brain-Boot idempotent in die
Vector-DB geschrieben (pinned, source="seed").
Im Gegensatz zu aria-data/brain-import/ (User-Saatgut, manuell via
Diagnostic-Klick migriert) ist das hier System-Regeln, die zum Brain-Code
gehoeren und mit jedem Deploy ausgerollt werden.
Idempotenz: Punkte mit gleicher `migration_key` werden vor dem Schreiben
geloescht. Editieren = Zeile aendern, Brain neu starten, fertig.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import List
from memory import Embedder, VectorStore
from memory.vector_store import COLLECTION
from qdrant_client.http import models as qm
logger = logging.getLogger(__name__)
# Jede Regel = ein eigener Memory-Punkt. Klein halten, klar formulieren —
# ARIA sieht das in jedem Chat-Turn als pinned Hot Memory.
SEED_RULES: List[dict] = [
{
"migration_key": "seed/skill-rule/list-before-create",
"type": "rule",
"title": "Skill-Regel: skill_list vor skill_create",
"category": "skills",
"content": (
"Bevor du einen neuen Skill mit `skill_create` anlegst, ruf IMMER "
"zuerst `skill_list` auf. Schau dir die Namen und Descriptions an. "
"Wenn ein passender Skill existiert: verwende ihn oder verbessere "
"ihn mit `skill_update`. Lege keinen Duplikat-Skill an."
),
},
{
"migration_key": "seed/skill-rule/no-version-suffix",
"type": "rule",
"title": "Skill-Regel: keine Versions-Suffixe im Namen",
"category": "skills",
"content": (
"Skill-Namen muessen permanent und beschreibend sein. NIEMALS "
"Suffixe wie `-v2`, `_v3`, `-new`, `-fixed`, `-aria`, `-ctl` "
"anhaengen, um eine neue Variante zu bauen. Wenn ein Skill kaputt "
"ist oder verbessert werden soll: `skill_update`. Versionsverwaltung "
"macht das System intern (Rollback ueber `skill_rollback`)."
),
},
{
"migration_key": "seed/skill-rule/update-not-recreate",
"type": "rule",
"title": "Skill-Regel: kaputten Skill reparieren, nicht neu bauen",
"category": "skills",
"content": (
"Wenn ein vorhandener Skill nicht wie erwartet funktioniert, lies "
"zuerst Code + Logs (`skill_get`, `skill_logs`). Repariere ihn dann "
"mit `skill_update` (entry_code, readme oder pip_packages patchen). "
"Baue NIEMALS einen zweiten Skill mit aehnlichem Namen — das gibt "
"Skill-Friedhof und Stefan muss aufraeumen."
),
},
{
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
"type": "rule",
"title": "Skill-Regel: keine hardcoded Credentials",
"category": "skills",
"content": (
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
"manuell neu einloggen, das nervt."
),
},
{
"migration_key": "seed/skill-rule/config-schema-for-settings",
"type": "rule",
"title": "Skill-Regel: konfigurierbare Werte ueber config_schema",
"category": "skills",
"content": (
"Wenn dein Skill konfigurierbare Werte braucht (User-IDs, "
"Default-Geraete, Endpoints, nicht-OAuth-API-Keys), deklariere "
"sie im `config_schema`-Feld der skill.json. Stefan setzt sie "
"dann in der Diagnostic-UI; der Skill bekommt die Werte zur "
"Laufzeit als Environment-Variable `CFG_<NAME>`. NICHT als "
"Argument, NICHT hardcoded."
),
},
{
"migration_key": "seed/skill-rule/brain-internal-url",
"type": "rule",
"title": "Skill-Regel: BRAIN_INTERNAL_URL ist deine Brain-Schnittstelle",
"category": "skills",
"content": (
"Jeder Skill bekommt die ENV-Variable BRAIN_INTERNAL_URL "
"(Default http://localhost:8080). Damit kann der Skill das Brain "
"aufrufen — kein hardcoden noetig:\n"
" - GET {BRAIN_INTERNAL_URL}/oauth/<service>/token -> access_token "
"(mit Auto-Refresh) fuer jeden OAuth-Service\n"
" - GET {BRAIN_INTERNAL_URL}/memory/search?q=...&k=5 -> "
"Stefans Memories semantisch durchsuchen\n"
" - GET {BRAIN_INTERNAL_URL}/memory/pinned -> Hot Memory (Identitaet, Regeln)\n"
" - GET {BRAIN_INTERNAL_URL}/skills/list -> verfuegbare Skills\n"
"Mehr Endpoints siehe Brain main.py. Lies die URL IMMER aus "
"os.environ['BRAIN_INTERNAL_URL'] — hardcoden waere kaputt sobald "
"der Port wechselt. Beispiel: ein Wetter-Skill kann Stefans "
"Standort per /memory/search holen statt ihn als Arg zu erwarten."
),
},
{
"migration_key": "seed/skill-rule/external-api-auth-strategy",
"type": "rule",
"title": "Skill-Regel: Auth-Strategie fuer externe APIs",
"category": "skills",
"content": (
"Wenn dein Skill mit einer externen API redet (Spotify, Google, "
"Reddit, GitHub, OpenWeather, OpenAI, …), entscheide IMMER bewusst "
"die Auth-Strategie in dieser Reihenfolge:\n"
" 1. OAuth2? (Spotify, Google, GitHub, Reddit, Discord, Twitch, "
"Microsoft, …) -> nutze `oauth_register_provider` falls der "
"Provider noch nicht da ist, dann `oauth_authorize` fuer "
"Initial-Login. Im Skill: Token via "
"BRAIN_INTERNAL_URL/oauth/<service>/token holen — Brain macht "
"Auto-Refresh, Stefan muss sich nicht alle 60min neu einloggen.\n"
" 2. Statischer API-Key / Bearer-Token? (OpenWeather, OpenAI, "
"Twilio, SendGrid, …) -> in skill.json `config_schema` "
"deklarieren. Stefan setzt den Wert in Diagnostic, Skill bekommt "
"ihn als CFG_<NAME> ENV.\n"
" 3. NIEMALS hardcoden — egal wie 'temporaer' es ist.\n"
"Wenn Du nicht sicher bist welche Strategie ein Service nutzt: "
"in der API-Doku des Services nachsehen ('OAuth' oder "
"'API Key' im Auth-Kapitel). Nicht raten."
),
},
]
def apply(store: VectorStore, embedder: Embedder) -> dict:
"""Schreibt alle SEED_RULES idempotent in die DB.
Vorgehen: erst alle Punkte mit `source=seed` UND passender migration_key
loeschen, dann frisch upserten. So koennen Regeln editiert/entfernt
werden indem die SEED_RULES-Liste angepasst wird.
"""
if not SEED_RULES:
return {"written": 0}
migration_keys = [r["migration_key"] for r in SEED_RULES]
# Alte Versionen entfernen (nur die mit unserer migration_key — andere
# source=seed Punkte aus zukuenftigen seed-Files sind sicher)
try:
store.client.delete(
collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
qm.FieldCondition(key="migration_key", match=qm.MatchAny(any=migration_keys))
])),
)
except Exception as exc:
logger.warning("seed_rules: delete-by-migration_key fehlgeschlagen (%s) — wahrscheinlich erster Run", exc)
# Frisch einbetten + schreiben
texts = [r["content"] for r in SEED_RULES]
vectors = embedder.embed_batch(texts)
now = datetime.now(timezone.utc).isoformat()
written = 0
for rule, vec in zip(SEED_RULES, vectors):
payload = {
"type": rule["type"],
"title": rule["title"],
"content": rule["content"],
"pinned": True,
"category": rule.get("category", ""),
"source": "seed",
"tags": [],
"created_at": now,
"updated_at": now,
"migration_key": rule["migration_key"],
"attachments": [],
}
store.client.upsert(
collection_name=COLLECTION,
points=[qm.PointStruct(id=str(uuid.uuid4()), vector=vec, payload=payload)],
)
written += 1
logger.info("seed_rules: %d Regeln in DB geschrieben", written)
return {"written": written, "keys": migration_keys}
+334
View File
@@ -47,9 +47,15 @@ logger = logging.getLogger(__name__)
SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills")) SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills"))
SHARED_UPLOADS = Path("/shared/uploads") SHARED_UPLOADS = Path("/shared/uploads")
SKILL_CONFIGS_FILE = Path(os.environ.get("SKILL_CONFIGS_FILE", "/shared/config/skill_configs.json"))
# Beim Archivieren in versions/ ausgenommen (gross, regenerierbar, sind keine Sources)
_VERSION_SKIP = {"venv", "logs", "versions", "__pycache__"}
VALID_EXECUTIONS = {"local-venv", "local-bin", "bash"} VALID_EXECUTIONS = {"local-venv", "local-bin", "bash"}
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$") NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
# Anti-Skill-Friedhof: ARIAs Lieblings-Suffixe wenn sie statt updaten neu baut
VERSION_SUFFIX_RE = re.compile(r"(?:[-_]v\d+|[-_](?:new|fixed|old|alt|copy|final|clean))$", re.I)
def _now() -> str: def _now() -> str:
@@ -66,6 +72,44 @@ def _skill_dir(name: str) -> Path:
return SKILLS_DIR / _safe_name(name) return SKILLS_DIR / _safe_name(name)
def _check_anti_graveyard(name: str) -> None:
"""Verhindert klassische Skill-Friedhof-Patterns beim Anlegen.
Hard-Reject auf:
1. Versions-Suffixe (`-v2`, `_v3`, `-new`, `-fixed`, …) im Namen
2. Prefix-Kollision mit existierendem Skill (z.B. `spotify` existiert,
jemand will `spotify-aria` oder `spotify-ctl` anlegen)
"""
if VERSION_SUFFIX_RE.search(name):
raise ValueError(
f"Skill-Name '{name}' enthaelt einen Versions-Suffix "
f"(-v2 / _v3 / -new / -fixed / -old / -alt / -copy / -final / -clean). "
f"Skills werden intern versioniert (skill_rollback). "
f"Waehle einen klaren Namen ohne Suffix oder nutze skill_update auf "
f"den bestehenden Skill."
)
if not SKILLS_DIR.exists():
return
existing = [p.name for p in SKILLS_DIR.iterdir() if p.is_dir()]
for ex in existing:
if ex == name:
continue # wird spaeter mit "existiert bereits" abgefangen
# neuer Name verlaengert existierenden Stem: 'spotify' da, neu 'spotify-aria'
if name.startswith(ex + "-") or name.startswith(ex + "_"):
raise ValueError(
f"Skill-Name '{name}' kollidiert mit existierendem '{ex}'. "
f"Wenn Du '{ex}' verbessern willst: skill_update auf '{ex}'. "
f"Wenn es wirklich was anderes ist: waehle einen Namen ohne den "
f"Praefix '{ex}-' / '{ex}_'."
)
# neuer Name ist Kurzform eines existierenden: 'spotify-aria' da, neu 'spotify'
if ex.startswith(name + "-") or ex.startswith(name + "_"):
raise ValueError(
f"Es existiert bereits '{ex}' mit Praefix '{name}'. Pruefe ob '{ex}' "
f"das schon kann; wenn ja: skill_update auf '{ex}' oder Skill umbenennen."
)
# ─── Listing ──────────────────────────────────────────────────────── # ─── Listing ────────────────────────────────────────────────────────
def list_skills(active_only: bool = False) -> list[dict]: def list_skills(active_only: bool = False) -> list[dict]:
@@ -119,6 +163,7 @@ def create_skill(
requires: Optional[dict] = None, requires: Optional[dict] = None,
pip_packages: Optional[list[str]] = None, pip_packages: Optional[list[str]] = None,
author: str = "aria", author: str = "aria",
config_schema: Optional[list] = None,
) -> dict: ) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs. """Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -128,6 +173,7 @@ def create_skill(
name = _safe_name(name) name = _safe_name(name)
if execution not in VALID_EXECUTIONS: if execution not in VALID_EXECUTIONS:
raise ValueError(f"execution muss eines von {VALID_EXECUTIONS} sein") raise ValueError(f"execution muss eines von {VALID_EXECUTIONS} sein")
_check_anti_graveyard(name)
d = _skill_dir(name) d = _skill_dir(name)
if d.exists(): if d.exists():
raise ValueError(f"Skill '{name}' existiert bereits — erst loeschen oder updaten") raise ValueError(f"Skill '{name}' existiert bereits — erst loeschen oder updaten")
@@ -166,6 +212,8 @@ def create_skill(
"use_count": 0, "use_count": 0,
"version": "1.0", "version": "1.0",
"author": author, "author": author,
"config_schema": _normalize_config_schema(config_schema),
"version_history": [],
} }
write_manifest(name, manifest) write_manifest(name, manifest)
@@ -184,6 +232,35 @@ def create_skill(
return manifest return manifest
def _normalize_config_schema(schema: Optional[list]) -> list:
"""Filter + Normalisiert das config_schema. Erwartet Liste von Dicts mit
Pflichtfeld 'name'. Optional: label, type (string|number|boolean|password),
secret (bool), default, description."""
if not schema:
return []
out = []
for f in schema:
if not isinstance(f, dict):
continue
fname = (f.get("name") or "").strip()
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]{0,40}$", fname):
continue
ftype = (f.get("type") or "string").lower()
if ftype not in ("string", "number", "boolean", "password"):
ftype = "string"
# password impliziert secret=True
secret = bool(f.get("secret")) or ftype == "password"
out.append({
"name": fname,
"type": ftype,
"label": (f.get("label") or fname),
"secret": secret,
"description": (f.get("description") or "")[:300],
"default": f.get("default"),
})
return out
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None: def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
venv = skill_dir / "venv" venv = skill_dir / "venv"
logger.info("venv erstellen: %s", venv) logger.info("venv erstellen: %s", venv)
@@ -206,10 +283,30 @@ def update_skill(name: str, patch: dict) -> dict:
if manifest is None: if manifest is None:
raise ValueError(f"Skill '{name}' nicht gefunden") raise ValueError(f"Skill '{name}' nicht gefunden")
d = _skill_dir(name) d = _skill_dir(name)
# Auto-Archive: wenn strukturelle Aenderung (Code/README/Deps/Schema), erst
# snapshot machen. So kann jeder skill_update zurueckgerollt werden.
structural = any(k in patch for k in ("entry_code", "readme", "pip_packages",
"config_schema", "args"))
if structural:
try:
archive_current_version(
name,
summary=patch.get("_change_summary") or ", ".join(
sorted(k for k in patch.keys() if k != "_change_summary")
)[:200],
)
except Exception as exc:
logger.warning("update_skill: Auto-Archive %s fehlgeschlagen: %s", name, exc)
# nach archive_current_version manifest neu laden (version_history geupdatet)
manifest = read_manifest(name) or manifest
allowed = {"description", "args", "requires", "active", "version", "entry"} allowed = {"description", "args", "requires", "active", "version", "entry"}
for k, v in patch.items(): for k, v in patch.items():
if k in allowed: if k in allowed:
manifest[k] = v manifest[k] = v
if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
# Code austauschen # Code austauschen
if "entry_code" in patch and patch["entry_code"]: if "entry_code" in patch and patch["entry_code"]:
@@ -255,9 +352,230 @@ def delete_skill(name: str) -> None:
if not d.exists(): if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden") raise ValueError(f"Skill '{name}' nicht gefunden")
shutil.rmtree(d) shutil.rmtree(d)
# Configs auch raeumen — sonst Karteileiche in skill_configs.json
try:
all_cfg = _load_all_skill_configs()
if name in all_cfg:
all_cfg.pop(name)
_save_all_skill_configs(all_cfg)
except Exception:
pass
logger.info("Skill geloescht: %s", name) logger.info("Skill geloescht: %s", name)
# ─── Skill-Configs (statische Werte je Skill — API-Keys, IDs etc.) ──
# Werte liegen zentral in /shared/config/skill_configs.json damit Stefan
# sie im Diagnostic-UI editieren kann. Skill bekommt sie zur Laufzeit
# als ENV `CFG_<UPPER_NAME>` — kein hardcoden im Code noetig.
def _load_all_skill_configs() -> dict:
if not SKILL_CONFIGS_FILE.exists():
return {}
try:
return json.loads(SKILL_CONFIGS_FILE.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("skill_configs.json kaputt (%s) — leeres dict", exc)
return {}
def _save_all_skill_configs(data: dict) -> None:
SKILL_CONFIGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SKILL_CONFIGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8")
def get_skill_config(name: str) -> dict:
"""Liefert die rohen Config-Werte fuer einen Skill (ungemasked).
Wird intern beim run_skill genutzt um CFG_<NAME>-Env zu bauen."""
return _load_all_skill_configs().get(name, {})
def set_skill_config(name: str, values: dict) -> dict:
"""Speichert die Config-Werte fuer einen Skill (komplett ueberschreiben).
Werte landen sofort persistent; naechster run_skill nutzt sie."""
if not isinstance(values, dict):
raise ValueError("values muss ein Dict sein")
all_cfg = _load_all_skill_configs()
all_cfg[name] = values
_save_all_skill_configs(all_cfg)
return values
def get_skill_config_masked(name: str) -> dict:
"""Wie get_skill_config, aber secret-Felder werden auf '***SET***' maskiert.
Schema kommt aus dem skill.json — Felder ohne secret=True werden klar
zurueckgegeben. Fuer UI-Anzeige."""
manifest = read_manifest(name)
schema = (manifest or {}).get("config_schema") or []
secret_fields = {f.get("name") for f in schema if f.get("secret")}
values = get_skill_config(name)
return {k: ("***SET***" if (k in secret_fields and v) else v)
for k, v in values.items()}
def _config_env_name(field_name: str) -> str:
"""API-Key → CFG_API_KEY. Erlaubt nur a-zA-Z0-9_."""
safe = re.sub(r"[^a-zA-Z0-9]", "_", field_name).upper()
return f"CFG_{safe}"
# ─── Versionierung (Rollback-fähiges update_skill) ───────────────────
# Vor jedem strukturellen update wird der aktuelle Stand nach
# versions/v_<ts>/ kopiert (ohne venv/logs/versions). Rollback kopiert
# eine Version zurueck — vorher noch ein Auto-Snapshot, damit auch der
# Rollback rueckholbar ist.
def _versions_dir(name: str) -> Path:
return _skill_dir(name) / "versions"
def _copytree_skill(src: Path, dst: Path) -> None:
"""Kopiert Skill-Sources (alles ausser venv/logs/versions/__pycache__)."""
dst.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
if item.name in _VERSION_SKIP:
continue
target = dst / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
def archive_current_version(name: str, summary: str = "") -> str:
"""Kopiert den aktuellen Skill-Stand nach versions/v_<ts>/. Returnt die
version_id. Im Manifest wird `version_history` gepflegt."""
d = _skill_dir(name)
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
ts = int(time.time())
version_id = f"v_{ts}"
# Kollisionsschutz bei sub-Sekunden-Calls
while (_versions_dir(name) / version_id).exists():
ts += 1
version_id = f"v_{ts}"
archive = _versions_dir(name) / version_id
_copytree_skill(d, archive)
(archive / "_version.json").write_text(json.dumps({
"version_id": version_id,
"archived_at": _now(),
"summary": (summary or "")[:300],
}, indent=2, ensure_ascii=False), encoding="utf-8")
# Manifest-History pflegen (read-back nach _copytree, damit history konsistent)
manifest = read_manifest(name)
if manifest is not None:
hist = list(manifest.get("version_history") or [])
hist.append({"version_id": version_id, "archived_at": _now(),
"summary": (summary or "")[:300]})
# Cap auf 50 Versionen — alte Eintraege wegrotieren (Dateien bleiben aber)
manifest["version_history"] = hist[-50:]
write_manifest(name, manifest)
return version_id
def list_skill_versions(name: str) -> list[dict]:
"""Liste aller archivierten Versionen, neueste zuerst."""
versions = _versions_dir(name)
if not versions.exists():
return []
out = []
for entry in sorted(versions.iterdir(), reverse=True):
if not entry.is_dir():
continue
meta = entry / "_version.json"
if meta.exists():
try:
out.append(json.loads(meta.read_text(encoding="utf-8")))
continue
except Exception:
pass
out.append({"version_id": entry.name, "archived_at": "", "summary": ""})
return out
def rollback_skill(name: str, version_id: str) -> dict:
"""Stellt eine archivierte Version wieder her. Vorher wird der aktuelle
Stand automatisch als neue Version archiviert ('safety_snapshot') —
Rollback ist also nicht destruktiv. venv wird neu aufgebaut wenn
requirements.txt vorhanden ist."""
d = _skill_dir(name)
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
archive = _versions_dir(name) / version_id
if not archive.exists() or not archive.is_dir():
raise ValueError(f"Version '{version_id}' fuer Skill '{name}' nicht gefunden")
# 1. Sicherung des aktuellen Stands
safety = archive_current_version(name, summary=f"safety-snapshot vor rollback auf {version_id}")
# 2. Aktuelle Sources loeschen (venv/logs/versions bleiben)
for item in d.iterdir():
if item.name in _VERSION_SKIP:
continue
if item.is_dir():
shutil.rmtree(item, ignore_errors=True)
else:
try:
item.unlink()
except FileNotFoundError:
pass
# 3. Archive zurueck kopieren (ohne _version.json — das ist Versions-Metadata)
for item in archive.iterdir():
if item.name == "_version.json":
continue
target = d / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
# 4. Manifest-Stempel
manifest = read_manifest(name)
if manifest is not None:
manifest["updated_at"] = _now()
manifest["last_rollback"] = {"to": version_id, "safety": safety, "at": _now()}
write_manifest(name, manifest)
# 5. venv-Rebuild bei local-venv
req_file = d / "requirements.txt"
if (manifest or {}).get("execution") == "local-venv" and req_file.exists():
pip_packages = [l.strip() for l in req_file.read_text(encoding="utf-8").splitlines()
if l.strip() and not l.strip().startswith("#")]
venv = d / "venv"
if venv.exists():
shutil.rmtree(venv, ignore_errors=True)
try:
_setup_venv(d, pip_packages)
if manifest is not None:
manifest.pop("setup_error", None)
manifest["active"] = True
write_manifest(name, manifest)
except Exception as exc:
if manifest is not None:
manifest["active"] = False
manifest["setup_error"] = str(exc)[:500]
write_manifest(name, manifest)
logger.warning("Rollback %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
return {"ok": True, "name": name, "rolled_back_to": version_id,
"safety_snapshot": safety}
def delete_skill_version(name: str, version_id: str) -> dict:
"""Loescht eine einzelne Version aus versions/. Nicht-rueckholbar."""
archive = _versions_dir(name) / version_id
if not archive.exists():
raise ValueError(f"Version '{version_id}' nicht gefunden")
shutil.rmtree(archive)
manifest = read_manifest(name)
if manifest is not None:
manifest["version_history"] = [v for v in (manifest.get("version_history") or [])
if v.get("version_id") != version_id]
write_manifest(name, manifest)
return {"ok": True, "deleted": version_id}
# ─── Run ──────────────────────────────────────────────────────────── # ─── Run ────────────────────────────────────────────────────────────
def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) -> dict: def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) -> dict:
@@ -284,6 +602,22 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
env[f"ARG_{k.upper()}"] = str(v) env[f"ARG_{k.upper()}"] = str(v)
env["SKILL_DIR"] = str(d) env["SKILL_DIR"] = str(d)
env["SHARED_UPLOADS"] = str(SHARED_UPLOADS) env["SHARED_UPLOADS"] = str(SHARED_UPLOADS)
# Brain-API fuer Skills die OAuth-Tokens / Brain-Helpers brauchen.
# Beispiel: requests.get(f"{os.environ['BRAIN_INTERNAL_URL']}/oauth/spotify/token")
env["BRAIN_INTERNAL_URL"] = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
# Config-Schema-Werte als CFG_<NAME>-ENV (P3). Default greift wenn Stefan
# noch keinen Wert gesetzt hat — None wird uebersprungen damit der Skill
# selbst entscheiden kann ob das ein Fehler ist.
schema = manifest.get("config_schema") or []
values = get_skill_config(name)
for field in schema:
fname = field.get("name")
if not fname:
continue
val = values.get(fname, field.get("default"))
if val is None:
continue
env[_config_env_name(fname)] = str(val)
# Command bauen # Command bauen
if exec_mode == "local-venv": if exec_mode == "local-venv":
+196 -25
View File
@@ -1650,36 +1650,54 @@
if (msg.type === 'chat_history') { if (msg.type === 'chat_history') {
const boxes = [chatBox, document.getElementById('chat-box-fs')].filter(Boolean); const boxes = [chatBox, document.getElementById('chat-box-fs')].filter(Boolean);
for (const b of boxes) b.innerHTML = ''; for (const b of boxes) b.innerHTML = '';
let errorCount = 0;
if (msg.messages && msg.messages.length > 0) { if (msg.messages && msg.messages.length > 0) {
for (const m of msg.messages) { for (let mi = 0; mi < msg.messages.length; mi++) {
if (m.type === 'aria_file') { const m = msg.messages[mi];
// ARIA-Datei-Bubble — addAriaFile schreibt selbst in beide Boxen try {
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted }); if (m.type === 'aria_file') {
continue; addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted });
} continue;
// [FILE: ...]-Marker rausfiltern (gleicher Filter wie addChat) }
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim(); const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(cleaned); const escaped = escapeHtml(cleaned);
let linked = linkifyText(escaped); let linked = linkifyText(escaped);
// /shared/uploads/-Bildpfade auch im History inline rendern linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi, (match) => {
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi, (match) => { return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`; });
}); const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?';
const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?'; const trashBtn = m.ts
const trashBtn = m.ts ? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>` : '';
: ''; const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`; for (const b of boxes) {
for (const b of boxes) { const el = document.createElement('div');
const el = document.createElement('div'); el.className = `chat-msg ${m.type}`;
el.className = `chat-msg ${m.type}`; if (m.ts) el.dataset.ts = String(m.ts);
if (m.ts) el.dataset.ts = String(m.ts); el.innerHTML = innerHtml;
el.innerHTML = innerHtml; b.appendChild(el);
b.appendChild(el); }
} catch (renderErr) {
// Eine kaputte Bubble darf nicht den Rest der History killen.
// Vorher passierte genau das: Frontend-Render bracht bei einer
// problematischen Antwort ab, alle nachfolgenden Nachrichten waren
// beim Reload weg. Jetzt: Fehler-Bubble einbauen + weitermachen.
errorCount++;
console.error('chat_history render error at idx ' + mi + ':', renderErr, m);
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type || 'received'}`;
if (m.ts) el.dataset.ts = String(m.ts);
el.innerHTML = `<span style="color:#FF6B6B;">⚠ Render-Fehler in Bubble (${escapeHtml(String(renderErr.message || renderErr))})</span><div class="meta">${m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?'}</div>`;
b.appendChild(el);
}
} }
} }
for (const b of boxes) b.scrollTop = b.scrollHeight; for (const b of boxes) b.scrollTop = b.scrollHeight;
} }
if (errorCount > 0) {
console.warn(`chat_history: ${errorCount} Bubble(s) konnten nicht gerendert werden`);
}
return; return;
} }
@@ -3496,6 +3514,8 @@
<button class="btn secondary" onclick="exportSkill('${escapeHtml(s.name)}')" style="padding:2px 10px;font-size:11px;color:#0096FF;border-color:#0096FF;">⬇ Export</button> <button class="btn secondary" onclick="exportSkill('${escapeHtml(s.name)}')" style="padding:2px 10px;font-size:11px;color:#0096FF;border-color:#0096FF;">⬇ Export</button>
<button class="btn secondary" onclick="deleteSkill('${escapeHtml(s.name)}')" style="padding:2px 10px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button> <button class="btn secondary" onclick="deleteSkill('${escapeHtml(s.name)}')" style="padding:2px 10px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
</div> </div>
<div id="skill-config-${escapeHtml(s.name)}" style="margin-bottom:10px;"></div>
<div id="skill-versions-${escapeHtml(s.name)}" style="margin-bottom:10px;"></div>
<div style="color:#0096FF;font-size:11px;font-weight:bold;margin:6px 0 4px;">Logs (letzte 20)</div> <div style="color:#0096FF;font-size:11px;font-weight:bold;margin:6px 0 4px;">Logs (letzte 20)</div>
<div id="skill-logs-${escapeHtml(s.name)}" style="font-size:11px;color:#8888AA;">(Logs lädt...)</div> <div id="skill-logs-${escapeHtml(s.name)}" style="font-size:11px;color:#8888AA;">(Logs lädt...)</div>
</div> </div>
@@ -3529,6 +3549,8 @@
const el = document.getElementById('skill-readme-' + name); const el = document.getElementById('skill-readme-' + name);
if (el && d.readme) el.innerHTML = '<pre style="margin:0;font-family:inherit;white-space:pre-wrap;">' + escapeHtml(d.readme) + '</pre>'; if (el && d.readme) el.innerHTML = '<pre style="margin:0;font-family:inherit;white-space:pre-wrap;">' + escapeHtml(d.readme) + '</pre>';
} catch {} } catch {}
loadSkillConfigSection(name);
loadSkillVersionsSection(name);
try { try {
const r2 = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/logs'); const r2 = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/logs');
const d2 = await r2.json(); const d2 = await r2.json();
@@ -3547,6 +3569,155 @@
} }
} }
// ── Skill-Configs (P3) ─────────────────────────────────
async function loadSkillConfigSection(name) {
const el = document.getElementById('skill-config-' + name);
if (!el) return;
try {
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config');
if (!r.ok) { el.innerHTML = ''; return; }
const d = await r.json();
const schema = d.schema || [];
if (!schema.length) { el.innerHTML = ''; return; }
const values = d.values || {};
const inputs = schema.map(f => {
const fname = f.name;
const label = f.label || fname;
const desc = f.description ? `<div style="color:#555570;font-size:10px;">${escapeHtml(f.description)}</div>` : '';
const isSecret = f.secret || f.type === 'password';
const cur = values[fname];
const placeholder = isSecret && cur === '***SET***' ? '••• gesetzt (leer lassen = unverändert) •••'
: (f.default !== undefined && f.default !== null ? `Default: ${f.default}` : '');
let inputEl;
if (f.type === 'boolean') {
const checked = (cur === true || cur === 'true') ? 'checked' : '';
inputEl = `<input type="checkbox" data-cfg="${escapeHtml(fname)}" data-type="boolean" ${checked} style="margin-right:6px;">`;
} else {
const type = isSecret ? 'password' : (f.type === 'number' ? 'number' : 'text');
const val = (isSecret) ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? escapeHtml(String(cur)) : '');
inputEl = `<input type="${type}" data-cfg="${escapeHtml(fname)}" data-type="${f.type || 'string'}" value="${val}" placeholder="${escapeHtml(placeholder)}" style="flex:1;padding:3px 6px;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;border-radius:3px;font-size:11px;">`;
}
return `<div style="margin-bottom:6px;">
<div style="display:flex;align-items:center;gap:6px;">
<label style="min-width:120px;color:#8888AA;font-size:11px;">${escapeHtml(label)}${isSecret ? ' 🔒' : ''}</label>
${inputEl}
</div>
${desc}
</div>`;
}).join('');
el.innerHTML = `
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;padding:8px;">
<div style="color:#FFD60A;font-size:11px;font-weight:bold;margin-bottom:6px;">⚙ Konfiguration</div>
${inputs}
<button class="btn secondary" onclick="saveSkillConfig('${escapeHtml(name)}')" style="padding:3px 12px;font-size:11px;color:#3FFF3F;border-color:#3FFF3F;margin-top:4px;">💾 Speichern</button>
<span id="skill-cfg-status-${escapeHtml(name)}" style="color:#8888AA;font-size:11px;margin-left:8px;"></span>
</div>`;
} catch (e) {
el.innerHTML = `<div style="color:#FF6B6B;font-size:11px;">Config-Load: ${escapeHtml(e.message)}</div>`;
}
}
async function saveSkillConfig(name) {
const el = document.getElementById('skill-config-' + name);
if (!el) return;
const inputs = el.querySelectorAll('[data-cfg]');
// Erst aktuelle gespeicherte Werte holen — secret-Felder die leer sind sollen unverändert bleiben
let existing = {};
try {
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config');
const d = await r.json();
existing = d.values || {};
} catch {}
const values = { ...existing };
inputs.forEach(inp => {
const fname = inp.getAttribute('data-cfg');
const type = inp.getAttribute('data-type');
let v;
if (type === 'boolean') v = inp.checked;
else if (type === 'number') v = inp.value === '' ? null : Number(inp.value);
else v = inp.value;
const isPassword = inp.type === 'password';
if (isPassword && v === '') return; // leer bei secret = unverändert
if (v === '' || v === null) { delete values[fname]; return; }
if (v === '***SET***') return;
values[fname] = v;
});
try {
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ values }),
});
const stat = document.getElementById('skill-cfg-status-' + name);
if (r.ok) {
if (stat) { stat.textContent = '✓ gespeichert'; stat.style.color = '#3FFF3F'; }
loadSkillConfigSection(name);
} else {
if (stat) { stat.textContent = 'Fehler ' + r.status; stat.style.color = '#FF6B6B'; }
}
} catch (e) {
alert('Speichern fehlgeschlagen: ' + e.message);
}
}
// ── Skill-Versions (P4) ─────────────────────────────────
async function loadSkillVersionsSection(name) {
const el = document.getElementById('skill-versions-' + name);
if (!el) return;
try {
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/versions');
if (!r.ok) { el.innerHTML = ''; return; }
const d = await r.json();
const versions = d.versions || [];
if (!versions.length) { el.innerHTML = ''; return; }
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('de-DE') : '?';
const rows = versions.map(v => `
<div style="display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid #1E1E2E;">
<span style="flex:1;font-family:monospace;font-size:10px;color:#E0E0F0;">${escapeHtml(v.version_id)}</span>
<span style="font-size:10px;color:#8888AA;">${fmtDate(v.archived_at)}</span>
<span style="flex:2;font-size:10px;color:#8888AA;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(v.summary || '')}</span>
<button class="btn secondary" onclick="rollbackSkillVersion('${escapeHtml(name)}','${escapeHtml(v.version_id)}')" style="padding:1px 8px;font-size:10px;color:#FFD60A;border-color:#FFD60A;">↺ Rollback</button>
<button class="btn secondary" onclick="deleteSkillVersion('${escapeHtml(name)}','${escapeHtml(v.version_id)}')" style="padding:1px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑</button>
</div>
`).join('');
el.innerHTML = `
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;padding:8px;">
<div style="color:#FFD60A;font-size:11px;font-weight:bold;margin-bottom:6px;">📦 Versionen (${versions.length})</div>
${rows}
</div>`;
} catch (e) {
el.innerHTML = `<div style="color:#FF6B6B;font-size:11px;">Versions-Load: ${escapeHtml(e.message)}</div>`;
}
}
async function rollbackSkillVersion(name, versionId) {
if (!confirm(`Skill "${name}" auf Version ${versionId} zurückrollen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`)) return;
try {
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/rollback', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ version_id: versionId }),
});
const d = await r.json();
if (r.ok) {
alert(`✓ Rollback OK\nSicherheits-Snapshot: ${d.safety_snapshot}`);
loadSkillVersionsSection(name);
loadSkills();
} else {
alert('Rollback fehlgeschlagen: ' + (d.detail || JSON.stringify(d)));
}
} catch (e) { alert('Rollback-Fehler: ' + e.message); }
}
async function deleteSkillVersion(name, versionId) {
if (!confirm(`Version ${versionId} von "${name}" wirklich löschen?\n\nNicht rückholbar.`)) return;
try {
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/versions/' + encodeURIComponent(versionId), {
method: 'DELETE',
});
if (r.ok) loadSkillVersionsSection(name);
else { const d = await r.json().catch(()=>({})); alert('Löschen fehlgeschlagen: ' + (d.detail || r.status)); }
} catch (e) { alert('Fehler: ' + e.message); }
}
async function toggleSkillActive(name, newActive) { async function toggleSkillActive(name, newActive) {
try { try {
await fetch('/api/brain/skills/' + encodeURIComponent(name), { await fetch('/api/brain/skills/' + encodeURIComponent(name), {
+10 -2
View File
@@ -701,8 +701,16 @@ function connectRVS(forcePlain) {
state.rvs.lastError = err.message; state.rvs.lastError = err.message;
broadcastState(); broadcastState();
// TLS Fallback // TLS-Fallback nur bei wirklichen TLS/Handshake-Fehlern.
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) { // Bei Netz-Problemen wie EHOSTUNREACH, ECONNREFUSED, ENETUNREACH,
// EAI_AGAIN ist der Server eh tot — Fallback bringt nichts ausser
// Log-Spam und doppelten Retries.
const netErr = (err.code || err.message || "").toString();
const isNetDown =
/^(EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)$/.test(netErr) ||
/EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/.test(err.message || "");
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered && !isNetDown) {
fallbackTriggered = true; fallbackTriggered = true;
log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://"); log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
try { ws.removeAllListeners(); ws.close(); } catch (_) {} try { ws.removeAllListeners(); ws.close(); } catch (_) {}
+1 -1
View File
@@ -39,7 +39,7 @@ services:
restart: always restart: always
ports: ports:
- "80:80" - "80:80"
- "443:443" - "444:443"
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000 command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
volumes: volumes:
- ./data/caddy/data:/data # Zertifikate (PERSISTENT) - ./data/caddy/data:/data # Zertifikate (PERSISTENT)