Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1cb2d26a | |||
| 8359500476 | |||
| 1a72f27861 | |||
| 32302a841e | |||
| 474e2c6c50 | |||
| 3e0cfef63c | |||
| b94626787b | |||
| ad87c807de | |||
| 72277098af | |||
| 80d2fe3e93 | |||
| b5ca3cd371 | |||
| d939fc4ac3 | |||
| 13e87fb083 |
@@ -301,6 +301,16 @@ aria-brain → Antwort → Bridge → RVS → App
|
||||
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
|
||||
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
|
||||
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
||||
- **3-Schichten Hang-Schutz** (gegen tote NAT-Verbindungen + asyncio-Limbo):
|
||||
(1) TCP-Keepalive auf dem RVS-Socket (30s idle / 10s probe / 3 retries —
|
||||
tote Connections in ~1 min erkannt statt nach 2h Linux-Default),
|
||||
(2) Asyncio-Heartbeat-Watchdog (eigene Coroutine, killt WS-Connection
|
||||
wenn `_last_heartbeat_ok` > 60s stale ist — Schutz gegen
|
||||
`ws.ping()`-Limbo bei halb-toten Verbindungen),
|
||||
(3) File-Based Liveness Thread (separater OS-Thread, immun gegen asyncio-
|
||||
Hangs, `os._exit(1)` nach 180s Staleness → Docker restart_policy
|
||||
uebernimmt). Plus: TLS-Fallback klebt nicht mehr — bei Reconnect
|
||||
wird wieder primary wss:// versucht.
|
||||
|
||||
### Betriebsmodi
|
||||
|
||||
@@ -332,7 +342,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
|
||||
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
|
||||
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
||||
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify, Google, GitHub, Strava, Microsoft, ...) mit client_id+client_secret pro Service + One-Click-Autorisieren, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
||||
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify Default, alle anderen Provider per ARIA on-demand oder "+ Custom"-Button mit auth_url/token_url/scopes) mit client_id+client_secret pro Service + One-Click-Autorisieren + Service-Loeschen, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
||||
|
||||
### Was zusaetzlich noch drin steckt
|
||||
|
||||
@@ -343,7 +353,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
|
||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
|
||||
- **OAuth-Callback-Pipeline**: RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Google/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat `oauth_authorize` / `oauth_get_token` / `oauth_revoke` als Brain-Tools
|
||||
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat vier Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, `oauth_revoke`
|
||||
|
||||
---
|
||||
|
||||
@@ -378,7 +388,10 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||
- **Bubble-Aktionen** (Long-Press oder ⎘-Icon): oeffnet ein Aktions-Menu mit "📋 Ganzen Text teilen" (System-Share-Sheet → Zwischenablage / WhatsApp / etc.) plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option. Plus native Text-Markierung via `selectable` ist weiter da
|
||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
||||
- **Settings-Sektionen "🛠️ Skills" und "🔑 OAuth-Apps"** (unterwegs konfigurieren ohne Diagnostic): Skills-Browser mit Run + Live-stdout/stderr + Logs der letzten 20 Runs + Loeschen; OAuth-Apps mit client_id/secret-Eingabe + "Autorisieren ↗" (oeffnet System-Browser, redirect zur RVS-Callback-Seite, Status-Refresh nach 8s) + "+ Custom"-Modal um eigene Provider mit auth_url/token_url/scopes anzulegen
|
||||
- **Voice-Speed persistent**: App-Setting wird in `voice_config.json` als `xttsSpeed` persistiert. Greift jetzt auch bei Diagnostic-Chats / Trigger-Replies / nach Bridge-Restart — nicht mehr nur waehrend der App-Chat-Sitzung
|
||||
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
|
||||
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. **Heartbeat alle 60 s**: auch ohne Bewegung wird die letzte bekannte Position erneut an die Bridge geschickt damit der Brain-State nicht nach 5 min (NEAR_MAX_AGE_SEC) veraltet — kein extra GPS-Wakeup, akkufreundlich. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
|
||||
@@ -598,16 +611,27 @@ tar -czf aria-backup-$(date +%Y%m%d).tar.gz aria-data/
|
||||
|
||||
## RVS — Rendezvous-Server
|
||||
|
||||
Laeuft im Rechenzentrum. WebSocket Relay + Auto-Update Server.
|
||||
Laeuft im Rechenzentrum. WebSocket Relay + OAuth-Callback HTTP-Server.
|
||||
Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
|
||||
|
||||
```bash
|
||||
cd rvs
|
||||
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Stack:**
|
||||
- `caddy` (TLS-Terminator + Let's Encrypt, lauscht auf 80+443)
|
||||
- `rvs` (WebSocket Relay + OAuth-Callback HTTP, nur intern auf Port 3000)
|
||||
|
||||
Caddy holt automatisch ein Zertifikat fuer `PUBLIC_URL` via HTTP-01-Challenge.
|
||||
ACME-State persistent in `./data/caddy/` (gitignored) — kein Rate-Limit-Drama
|
||||
bei Container-Restart. WebSocket-Upgrades reicht Caddy transparent durch.
|
||||
|
||||
**Features:**
|
||||
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
|
||||
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
|
||||
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
|
||||
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
|
||||
- Heartbeat + tote Verbindungen aufraeumen
|
||||
|
||||
@@ -620,6 +644,11 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
|
||||
|
||||
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
|
||||
|
||||
**Ohne Caddy / eigener TLS-Terminator:** Wenn Du schon einen Reverse-Proxy
|
||||
(nginx/Traefik) davor hast, kommentier den `caddy`-Service in der
|
||||
`rvs/docker-compose.yml` aus und gib `rvs` wieder einen `ports`-Block
|
||||
(z.B. `["3000:3000"]`). Dein Reverse-Proxy macht dann TLS und reicht weiter.
|
||||
|
||||
---
|
||||
|
||||
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
|
||||
@@ -896,6 +925,12 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
|
||||
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
|
||||
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der Gamebox (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
|
||||
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
|
||||
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
|
||||
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
|
||||
- [x] **Bridge-Hang-Schutz + Voice-Speed persistent**: 3-Schichten-Watchdog (TCP-Keepalive + Asyncio-Watchdog + File-Based Liveness mit Self-Kill), TLS-Fallback klebt nicht mehr beim Reconnect. `xttsSpeed` jetzt im voice_config.json persistiert — greift auch bei Diagnostic-Chats und nach Bridge-Restart
|
||||
- [x] **Bubble-Aktionen in der App**: Long-Press oder ⎘-Icon auf einer Chat-Bubble → Aktions-Menu mit "📋 Ganzen Text teilen" plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option (System-Share-Sheet → Zwischenablage / Apps / Browser)
|
||||
|
||||
### Phase 2 — ARIA wird produktiv
|
||||
|
||||
|
||||
+19
-1
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
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 { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
return () => {
|
||||
appStateSub.remove();
|
||||
rvs.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10601
|
||||
versionName "0.1.6.1"
|
||||
versionCode 10603
|
||||
versionName "0.1.6.3"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.6.1",
|
||||
"version": "0.1.6.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* OAuth-Browser — Verwaltung der OAuth-Provider (Spotify + Custom) und ihrer
|
||||
* Credentials. Eingesetzt von SettingsScreen → Sektion "OAuth-Apps".
|
||||
*
|
||||
* Pro Service:
|
||||
* - Status (verbunden / konfiguriert / leer)
|
||||
* - client_id + client_secret (Passwort-Toggle)
|
||||
* - Bei Custom-Services: auch auth_url + token_url + scopes editierbar
|
||||
* - "Autorisieren ↗" oeffnet die Provider-Auth-Seite im System-Browser
|
||||
* - "Abmelden" + (bei Custom) "🗑 Service entfernen"
|
||||
*
|
||||
* Plus: "+ Custom-Service" oeffnet ein Modal fuer name/auth_url/token_url/scopes.
|
||||
*
|
||||
* Hinweis zu Credentials: client_id/client_secret laufen ueber HTTP zur
|
||||
* Bridge, von dort zum Brain. Wenn die App via RVS verbunden ist, geht alles
|
||||
* ueber TLS (wss://) — der Wert ist nie im Klartext im Netz unterwegs.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Linking,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import brainApi, { OAuthServiceStatus, OAuthAppConfig } from '../services/brainApi';
|
||||
|
||||
const COL_OK = '#34C759';
|
||||
const COL_PENDING = '#FFD60A';
|
||||
const COL_OFF = '#666680';
|
||||
const COL_ERR = '#FF6B6B';
|
||||
|
||||
function fmtExpiry(secs: number | null | undefined): string {
|
||||
if (secs == null) return '';
|
||||
if (secs <= 0) return 'abgelaufen';
|
||||
if (secs < 60) return `${secs}s`;
|
||||
if (secs < 3600) return `${Math.round(secs / 60)} min`;
|
||||
if (secs < 86400) return `${Math.round(secs / 3600)} h`;
|
||||
return `${Math.round(secs / 86400)} Tage`;
|
||||
}
|
||||
|
||||
interface MergedService extends OAuthServiceStatus {
|
||||
app?: OAuthAppConfig;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export const OAuthBrowser: React.FC = () => {
|
||||
const [services, setServices] = useState<MergedService[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [editService, setEditService] = useState<MergedService | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
Promise.all([brainApi.listOAuthServices(), brainApi.getOAuthApps()])
|
||||
.then(([statusRes, appsRes]) => {
|
||||
const apps = appsRes.apps || {};
|
||||
const defaults = new Set(appsRes.defaults || []);
|
||||
const items: MergedService[] = (statusRes.services || []).map(s => ({
|
||||
...s,
|
||||
app: apps[s.service],
|
||||
isDefault: defaults.has(s.service),
|
||||
}));
|
||||
items.sort((a, b) => {
|
||||
if (a.authenticated !== b.authenticated) return a.authenticated ? -1 : 1;
|
||||
if (a.configured !== b.configured) return a.configured ? -1 : 1;
|
||||
return a.service.localeCompare(b.service);
|
||||
});
|
||||
setServices(items);
|
||||
})
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const renderItem = ({ item }: { item: MergedService }) => {
|
||||
let statusColor: string = COL_OFF;
|
||||
let statusIcon = '⚫';
|
||||
let statusText = 'nicht konfiguriert';
|
||||
if (item.authenticated) {
|
||||
statusColor = COL_OK; statusIcon = '✅';
|
||||
statusText = `verbunden${item.expiresInSec != null ? ' · noch ' + fmtExpiry(item.expiresInSec) : ''}`;
|
||||
} else if (item.configured) {
|
||||
statusColor = COL_PENDING; statusIcon = '🟡';
|
||||
statusText = 'konfiguriert, nicht autorisiert';
|
||||
}
|
||||
return (
|
||||
<TouchableOpacity style={s.row} onPress={() => setEditService(item)}>
|
||||
<View style={{flex: 1, marginRight: 8}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2}}>
|
||||
<Text style={{color: '#E0E0F0', fontWeight: '600', fontSize: 14, textTransform: 'capitalize'}}>{item.service}</Text>
|
||||
{!item.isDefault ? (
|
||||
<Text style={{color: '#8888AA', fontSize: 10}}>(custom)</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={{color: statusColor, fontSize: 12}}>{statusIcon} {statusText}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<View style={s.toolbar}>
|
||||
<Text style={{color: '#8888AA', fontSize: 11, flex: 1}}>
|
||||
Verbinde ARIA mit externen Services (Spotify u.a.).
|
||||
</Text>
|
||||
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
|
||||
<Text style={{fontSize: 13, color: '#fff', fontWeight: '700'}}>+ Custom</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
{loading && services.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={services}
|
||||
keyExtractor={s => s.service}
|
||||
renderItem={renderItem}
|
||||
nestedScrollEnabled={true}
|
||||
ListEmptyComponent={
|
||||
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||
(keine OAuth-Services — frag ARIA: "verbinde mich mit X")
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{paddingBottom: 20}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editService ? (
|
||||
<OAuthEditModal
|
||||
service={editService}
|
||||
onClose={() => setEditService(null)}
|
||||
onReload={() => { setEditService(null); load(); }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showNew ? (
|
||||
<OAuthCustomNewModal
|
||||
onClose={() => setShowNew(false)}
|
||||
onCreated={() => { setShowNew(false); load(); }}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Edit-Modal (Credentials + Authorize + Revoke + Delete) ──────────
|
||||
|
||||
interface EditProps {
|
||||
service: MergedService;
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
const OAuthEditModal: React.FC<EditProps> = ({ service: svc, onClose, onReload }) => {
|
||||
const [clientId, setClientId] = useState(svc.app?.client_id || '');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [authUrl, setAuthUrl] = useState(svc.app?.auth_url || '');
|
||||
const [tokenUrl, setTokenUrl] = useState(svc.app?.token_url || '');
|
||||
const [scopes, setScopes] = useState((svc.app?.scopes || []).join(' '));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
if (!clientId.trim()) {
|
||||
Alert.alert('Fehler', 'client_id darf nicht leer sein.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const body: any = {
|
||||
service: svc.service,
|
||||
client_id: clientId.trim(),
|
||||
};
|
||||
if (clientSecret) body.client_secret = clientSecret;
|
||||
if (authUrl.trim()) body.auth_url = authUrl.trim();
|
||||
if (tokenUrl.trim()) body.token_url = tokenUrl.trim();
|
||||
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
|
||||
try {
|
||||
await brainApi.saveOAuthApp(body);
|
||||
onReload();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Speichern fehlgeschlagen', String(e?.message || e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = async () => {
|
||||
if (!svc.configured) {
|
||||
Alert.alert('Erst Credentials eintragen', 'client_id und client_secret muessen vor dem Autorisieren gespeichert sein.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await brainApi.authorizeOAuth(svc.service);
|
||||
// Im System-Browser oeffnen — InAppBrowser wuerde z.T. von Providern blockiert
|
||||
const ok = await Linking.canOpenURL(r.url);
|
||||
if (!ok) {
|
||||
Alert.alert('Browser nicht verfuegbar', 'Konnte die Auth-URL nicht oeffnen.');
|
||||
return;
|
||||
}
|
||||
Linking.openURL(r.url);
|
||||
Alert.alert(
|
||||
'Im Browser anmelden',
|
||||
`Bitte stimme bei ${svc.service} zu. Nach dem Redirect zur Callback-Seite kannst du den Tab schliessen — ARIA bekommt das Token automatisch.\n\nDie Status-Anzeige in der App aktualisiert sich nach Refresh.`,
|
||||
[{ text: 'OK', onPress: () => setTimeout(onReload, 8000) }],
|
||||
);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Authorize fehlgeschlagen', String(e?.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = () => {
|
||||
Alert.alert(
|
||||
'Abmelden?',
|
||||
`Token fuer ${svc.service} entfernen. Du musst danach neu autorisieren.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Abmelden',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try { await brainApi.revokeOAuth(svc.service); onReload(); }
|
||||
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const removeService = () => {
|
||||
Alert.alert(
|
||||
'Service komplett entfernen?',
|
||||
`"${svc.service}" wird inkl. client_id/secret und Token geloescht.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try { await brainApi.deleteOAuthApp(svc.service); onReload(); }
|
||||
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={s.modalTitle} numberOfLines={1}>{svc.service}</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||
{svc.authenticated ? (
|
||||
<View style={[s.metaBox, {borderLeftWidth: 3, borderLeftColor: COL_OK, marginBottom: 12}]}>
|
||||
<Text style={[s.meta, {color: COL_OK, fontWeight: '700'}]}>
|
||||
✅ verbunden{svc.expiresInSec != null ? ` · Token noch ${fmtExpiry(svc.expiresInSec)}` : ''}
|
||||
</Text>
|
||||
{svc.hasRefresh ? <Text style={s.meta}>refresh_token vorhanden — auto-renew aktiv</Text>
|
||||
: <Text style={[s.meta, {color: COL_ERR}]}>KEIN refresh_token — Token verfaellt komplett</Text>}
|
||||
{svc.scope ? <Text style={s.meta}>scopes: {svc.scope}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={s.label}>client_id</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={clientId}
|
||||
onChangeText={setClientId}
|
||||
placeholder="aus dem Provider-Developer-Dashboard"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>
|
||||
client_secret {svc.app?.has_client_secret ? '— gespeichert (leer = behalten)' : '— fehlt'}
|
||||
</Text>
|
||||
<View style={{flexDirection: 'row', gap: 6}}>
|
||||
<TextInput
|
||||
style={[s.input, {flex: 1}]}
|
||||
value={clientSecret}
|
||||
onChangeText={setClientSecret}
|
||||
placeholder={svc.app?.has_client_secret ? '(neuen eintragen oder leer lassen)' : 'aus dem Dashboard'}
|
||||
placeholderTextColor="#444460"
|
||||
secureTextEntry={!showSecret}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#1A1A2E', justifyContent: 'center'}]}
|
||||
onPress={() => setShowSecret(v => !v)}
|
||||
>
|
||||
<Text style={{color: '#8888AA', fontSize: 14}}>{showSecret ? '🙈' : '👁'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* URLs/Scopes: bei Defaults hinter "advanced" versteckt damit Stefan
|
||||
nicht ausversehen die Spotify-URLs ueberschreibt. */}
|
||||
{svc.isDefault ? (
|
||||
<TouchableOpacity onPress={() => setShowAdvanced(v => !v)} style={{marginTop: 12}}>
|
||||
<Text style={{color: '#666680', fontSize: 11, fontStyle: 'italic'}}>
|
||||
{showAdvanced ? '▼' : '▶'} Default-URLs ueberschreiben (advanced)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{(!svc.isDefault || showAdvanced) ? (
|
||||
<View style={{marginTop: 8}}>
|
||||
<Text style={s.label}>auth_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={authUrl}
|
||||
onChangeText={setAuthUrl}
|
||||
placeholder="https://provider.com/oauth/authorize"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={s.label}>token_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={tokenUrl}
|
||||
onChangeText={setTokenUrl}
|
||||
placeholder="https://provider.com/oauth/token"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={s.label}>scopes (space-separated)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={scopes}
|
||||
onChangeText={setScopes}
|
||||
placeholder="read write user.email"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16}}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]}
|
||||
onPress={save}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||
{saving ? 'speichert...' : 'Speichern'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: svc.configured ? '#34C759' : '#1E1E2E', flex: 1}]}
|
||||
onPress={authorize}
|
||||
disabled={!svc.configured}
|
||||
>
|
||||
<Text style={{color: svc.configured ? '#fff' : '#555570', textAlign: 'center', fontWeight: '700'}}>
|
||||
Autorisieren ↗
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{svc.authenticated ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 12}]}
|
||||
onPress={revoke}
|
||||
>
|
||||
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>Abmelden (Token loeschen)</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{!svc.isDefault ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 8}]}
|
||||
onPress={removeService}
|
||||
>
|
||||
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>🗑 Service komplett entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<View style={{height: 30}} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Neuer Custom-Provider ──────────────────────────────────────────
|
||||
|
||||
interface NewProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const OAuthCustomNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [authUrl, setAuthUrl] = useState('https://');
|
||||
const [tokenUrl, setTokenUrl] = useState('https://');
|
||||
const [scopes, setScopes] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const create = async () => {
|
||||
const svc = name.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_-]+$/.test(svc)) {
|
||||
Alert.alert('Ungueltiger Name', 'Erlaubt: a-z 0-9 _ -');
|
||||
return;
|
||||
}
|
||||
if (!authUrl.startsWith('http') || !tokenUrl.startsWith('http')) {
|
||||
Alert.alert('Ungueltige URLs', 'auth_url und token_url muessen http(s):// sein.');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const body: any = { service: svc, auth_url: authUrl.trim(), token_url: tokenUrl.trim() };
|
||||
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
|
||||
await brainApi.saveOAuthApp(body);
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={s.modalTitle}>Custom OAuth-Provider</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 12}}>
|
||||
Trag die OAuth2-Endpunkte des Anbieters ein. client_id + client_secret
|
||||
kommen anschliessend ins Edit-Formular. Die Callback-URL die du beim
|
||||
Anbieter eintragen musst, zeigt dir der OAuth-Block im Brain-System-Prompt.
|
||||
</Text>
|
||||
|
||||
<Text style={s.label}>Service-Name (z.B. dropbox, discord)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="kurz, a-z 0-9 _ -"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>auth_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={authUrl}
|
||||
onChangeText={setAuthUrl}
|
||||
placeholder="https://provider.com/oauth/authorize"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>token_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={tokenUrl}
|
||||
onChangeText={setTokenUrl}
|
||||
placeholder="https://provider.com/oauth/token"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>scopes (space-separated, optional)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={scopes}
|
||||
onChangeText={setScopes}
|
||||
placeholder="read write user.email"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 20}}>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]} onPress={onClose}>
|
||||
<Text style={{color: '#8888AA', textAlign: 'center'}}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]} onPress={create} disabled={creating}>
|
||||
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||
{creating ? '...' : 'Anlegen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Styles ─────────────────────────────────────────────────────────
|
||||
|
||||
const s = StyleSheet.create({
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
iconBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
row: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
err: {
|
||||
color: '#FF6B6B',
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
},
|
||||
modal: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D1A',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
modalTitle: {
|
||||
color: '#E0E0F0',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
label: {
|
||||
color: '#8888AA',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
borderRadius: 6,
|
||||
color: '#E0E0F0',
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
metaBox: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
gap: 4,
|
||||
},
|
||||
meta: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export default OAuthBrowser;
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import brainApi, { Skill } from '../services/brainApi';
|
||||
import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi';
|
||||
|
||||
const COL_ACTIVE = '#34C759';
|
||||
const COL_INACTIVE = '#555570';
|
||||
@@ -177,8 +177,30 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
|
||||
const [logs, setLogs] = useState<any[] | null>(null);
|
||||
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 : [];
|
||||
|
||||
// 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) =>
|
||||
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 (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
@@ -274,6 +375,92 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
|
||||
</>
|
||||
) : 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}}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
|
||||
|
||||
@@ -57,6 +57,7 @@ import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/back
|
||||
import MemoryBrowser from '../components/MemoryBrowser';
|
||||
import TriggerBrowser from '../components/TriggerBrowser';
|
||||
import SkillBrowser from '../components/SkillBrowser';
|
||||
import OAuthBrowser from '../components/OAuthBrowser';
|
||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
@@ -108,6 +109,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
|
||||
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
|
||||
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
|
||||
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||
] as const;
|
||||
@@ -930,7 +932,7 @@ const SettingsScreen: React.FC = () => {
|
||||
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills'}
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
|
||||
>
|
||||
|
||||
{currentSection === null && (
|
||||
@@ -1824,6 +1826,20 @@ const SettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === OAuth-Apps === */}
|
||||
{currentSection === 'oauth' && (<>
|
||||
<Text style={styles.sectionTitle}>OAuth-Apps</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||
Verbinde ARIA mit externen Services (Spotify, Dropbox, Discord, ...).
|
||||
Trag client_id + client_secret aus dem Developer-Dashboard des Anbieters ein,
|
||||
dann "Autorisieren ↗" tippen. Custom-Services kannst Du via "+ Custom" anlegen —
|
||||
ARIA kann das auch selbst per Chat ("verbinde mich mit X").
|
||||
</Text>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<OAuthBrowser />
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Logs === */}
|
||||
{currentSection === 'protocol' && (<>
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
|
||||
@@ -121,6 +121,27 @@ export interface Memory {
|
||||
attachments?: MemoryAttachment[];
|
||||
}
|
||||
|
||||
/** OAuth-Service-Status wie aus Brain `/oauth/services` zurueckkommt. */
|
||||
export interface OAuthServiceStatus {
|
||||
service: string;
|
||||
configured: boolean;
|
||||
authenticated: boolean;
|
||||
expiresAt?: number | null;
|
||||
expiresInSec?: number | null;
|
||||
hasRefresh: boolean;
|
||||
scope?: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** OAuth-App-Config (client_id/scopes/URLs) — client_secret kommt NIE rausgegeben. */
|
||||
export interface OAuthAppConfig {
|
||||
client_id: string;
|
||||
has_client_secret: boolean;
|
||||
scopes?: string[] | null;
|
||||
auth_url?: string | null;
|
||||
token_url?: string | null;
|
||||
}
|
||||
|
||||
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||
export interface Skill {
|
||||
name: string;
|
||||
@@ -137,6 +158,26 @@ export interface Skill {
|
||||
version?: string;
|
||||
author?: string; // "aria" | "stefan"
|
||||
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. */
|
||||
@@ -374,7 +415,102 @@ export const brainApi = {
|
||||
|
||||
/** Letzte Run-Logs eines Skills. */
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Services mit Auth-Status (configured/authenticated/expires). */
|
||||
listOAuthServices(): Promise<{ services: OAuthServiceStatus[] }> {
|
||||
return _send('/oauth/services');
|
||||
},
|
||||
|
||||
/** Persistierte Provider-Configs (URLs/scopes/client_id, KEIN client_secret). */
|
||||
getOAuthApps(): Promise<{ apps: Record<string, OAuthAppConfig>; defaults: string[] }> {
|
||||
return _send('/oauth/apps');
|
||||
},
|
||||
|
||||
/** Provider-Config setzen/aktualisieren. Leerer client_secret laesst
|
||||
* den bestehenden Wert stehen. */
|
||||
saveOAuthApp(body: {
|
||||
service: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scopes?: string[];
|
||||
auth_url?: string;
|
||||
token_url?: string;
|
||||
}): Promise<{ ok: boolean; service: string }> {
|
||||
return _send('/oauth/apps', {
|
||||
method: 'POST',
|
||||
body,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Service-Eintrag komplett entfernen (incl. Token). */
|
||||
deleteOAuthApp(service: string): Promise<{ ok: boolean }> {
|
||||
return _send(`/oauth/apps/${encodeURIComponent(service)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Authorize-URL bauen (Brain speichert state, gibt url + redirect_uri zurueck). */
|
||||
authorizeOAuth(service: string, scopes?: string[]): Promise<{
|
||||
url: string; state: string; redirect_uri: string; service: string;
|
||||
}> {
|
||||
return _send('/oauth/authorize', {
|
||||
method: 'POST',
|
||||
body: { service, scopes },
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Token loeschen (lokal — kein Provider-Revoke). */
|
||||
revokeOAuth(service: string): Promise<{ ok: boolean }> {
|
||||
return _send(`/oauth/${encodeURIComponent(service)}/revoke`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -83,21 +83,39 @@ class RVSConnection {
|
||||
|
||||
// --- Verbindung ---
|
||||
|
||||
/** Verbindung zum RVS aufbauen */
|
||||
connect(): void {
|
||||
/** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart
|
||||
* 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) {
|
||||
this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
if (!force && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.log('info', 'Bereits verbunden');
|
||||
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.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.usingTLSFallback = false;
|
||||
this.clearTimers();
|
||||
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
|
||||
this.establishConnection();
|
||||
}
|
||||
@@ -212,6 +230,16 @@ class RVSConnection {
|
||||
this.ws = null;
|
||||
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) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
@@ -72,6 +72,18 @@ META_TOOLS = [
|
||||
"Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
|
||||
"Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
|
||||
"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 "
|
||||
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
|
||||
"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)"},
|
||||
"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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"function": {
|
||||
@@ -307,6 +393,65 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "oauth_register_provider",
|
||||
"description": (
|
||||
"Registriert einen NEUEN OAuth2-Provider in oauth_apps.json — "
|
||||
"nutze das wenn Stefan einen Service nutzen will, der noch nicht "
|
||||
"in der Default-Liste (spotify, google, github, strava, microsoft) "
|
||||
"ist. Du kennst typische OAuth-Endpunkte aus deinem Training "
|
||||
"(Dropbox, Twitch, Discord, Slack, Reddit, LinkedIn, Notion, "
|
||||
"Zoom, Trello, ...). Trag NUR die URLs ein — client_id / "
|
||||
"client_secret bleiben Stefans Job (Diagnostic > OAuth-Apps oder "
|
||||
"App > Settings > OAuth-Apps).\n\n"
|
||||
"**Workflow bei neuem Service:**\n"
|
||||
"1. `oauth_register_provider` mit auth_url + token_url + scopes\n"
|
||||
"2. Sag Stefan: \"Service '{name}' ist eingerichtet. Trag in "
|
||||
"Diagnostic/App > OAuth-Apps deine client_id + client_secret aus "
|
||||
"dem {name}-Developer-Dashboard ein. Plus die Callback-URL "
|
||||
"{callback} musst Du dort einmal als Redirect-URI eintragen.\"\n"
|
||||
"3. Warten bis Stefan fertig ist\n"
|
||||
"4. `oauth_authorize` rufen\n\n"
|
||||
"**`client_auth`-Wert:** Die meisten Provider wollen client_id+"
|
||||
"secret im Body (`body`, default). Spotify und manche andere "
|
||||
"wollen Basic-Auth-Header (`basic`). Wenn du unsicher bist, "
|
||||
"nimm `body` — schlaegt der Token-Request dann mit 401 fehl, "
|
||||
"switch auf `basic`.\n\n"
|
||||
"Bei Provider die du wirklich nicht kennst: frag Stefan oder "
|
||||
"such die Docs raus statt zu raten."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "Service-Name (a-z 0-9 _ -, kurz, z.B. 'dropbox', 'discord')",
|
||||
},
|
||||
"auth_url": {
|
||||
"type": "string",
|
||||
"description": "Authorize-Endpoint, z.B. 'https://www.dropbox.com/oauth2/authorize'",
|
||||
},
|
||||
"token_url": {
|
||||
"type": "string",
|
||||
"description": "Token-Endpoint, z.B. 'https://api.dropboxapi.com/oauth2/token'",
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Default-Scopes die der User beim Auth zustimmen muss",
|
||||
},
|
||||
"client_auth": {
|
||||
"type": "string",
|
||||
"enum": ["body", "basic"],
|
||||
"description": "Wie der Provider client_id/secret erwartet (Default 'body')",
|
||||
},
|
||||
},
|
||||
"required": ["service", "auth_url", "token_url"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -785,6 +930,7 @@ class Agent:
|
||||
readme=arguments.get("readme", ""),
|
||||
args=arguments.get("args", []),
|
||||
pip_packages=arguments.get("pip_packages", []),
|
||||
config_schema=arguments.get("config_schema") or None,
|
||||
author="aria",
|
||||
)
|
||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||
@@ -817,6 +963,8 @@ class Agent:
|
||||
patch[k] = arguments[k]
|
||||
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||
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:
|
||||
return "FEHLER: keine Felder zum Update angegeben."
|
||||
try:
|
||||
@@ -847,6 +995,57 @@ class Agent:
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
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_"):
|
||||
skill_name = name[len("run_"):]
|
||||
res = skills_mod.run_skill(skill_name, args=arguments)
|
||||
@@ -927,6 +1126,37 @@ class Agent:
|
||||
else:
|
||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||
return "\n".join(lines)
|
||||
if name == "oauth_register_provider":
|
||||
svc = (arguments.get("service") or "").strip()
|
||||
auth_url = (arguments.get("auth_url") or "").strip()
|
||||
token_url = (arguments.get("token_url") or "").strip()
|
||||
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
|
||||
client_auth = (arguments.get("client_auth") or "body").strip().lower()
|
||||
if not svc or not auth_url or not token_url:
|
||||
return "FEHLER: service, auth_url, token_url sind Pflicht."
|
||||
try:
|
||||
entry = oauth_mod.register_provider(
|
||||
svc, auth_url, token_url, scopes=scopes, client_auth=client_auth,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
except Exception as exc:
|
||||
logger.exception("oauth_register_provider fehlgeschlagen")
|
||||
return f"FEHLER: {exc}"
|
||||
cb = oauth_mod._callback_url(svc) if os.environ.get("RVS_HOST") else f"<RVS_HOST nicht gesetzt>/oauth/callback/{svc}"
|
||||
scopes_str = ", ".join(entry.get("scopes") or []) or "(keine)"
|
||||
return (
|
||||
f"OK — Provider '{svc}' registriert.\n"
|
||||
f" auth_url: {entry['auth_url']}\n"
|
||||
f" token_url: {entry['token_url']}\n"
|
||||
f" scopes: {scopes_str}\n"
|
||||
f" client_auth: {entry['client_auth']}\n\n"
|
||||
f"Sage Stefan: Trag in Diagnostic > OAuth-Apps (oder App > "
|
||||
f"Settings > OAuth-Apps) deine client_id + client_secret aus "
|
||||
f"dem {svc}-Developer-Dashboard ein. Plus die Callback-URL "
|
||||
f"`{cb}` musst Du dort einmal als Redirect-URI registrieren.\n"
|
||||
f"Sobald Stefan das gemacht hat, rufe `oauth_authorize` auf."
|
||||
)
|
||||
if name == "oauth_authorize":
|
||||
svc = (arguments.get("service") or "").strip()
|
||||
if not svc:
|
||||
|
||||
+93
-1
@@ -37,6 +37,7 @@ import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import background as background_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")
|
||||
logger = logging.getLogger("aria-brain")
|
||||
@@ -46,7 +47,13 @@ QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||
|
||||
@asynccontextmanager
|
||||
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))
|
||||
logger.info("Lifespan: Trigger-Loop gestartet")
|
||||
try:
|
||||
@@ -750,6 +757,7 @@ class SkillCreate(BaseModel):
|
||||
requires: dict = Field(default_factory=dict)
|
||||
pip_packages: list = Field(default_factory=list)
|
||||
author: str = "stefan"
|
||||
config_schema: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillRun(BaseModel):
|
||||
@@ -762,6 +770,18 @@ class SkillPatch(BaseModel):
|
||||
description: str | None = None
|
||||
active: bool | 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")
|
||||
@@ -791,6 +811,7 @@ def skills_create(body: SkillCreate):
|
||||
requires=body.requires,
|
||||
pip_packages=body.pip_packages,
|
||||
author=body.author,
|
||||
config_schema=body.config_schema,
|
||||
)
|
||||
except ValueError as 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)}
|
||||
|
||||
|
||||
# ── 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")
|
||||
def skills_export(name: str):
|
||||
try:
|
||||
@@ -932,6 +1004,26 @@ async def oauth_revoke_endpoint(service: str):
|
||||
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):
|
||||
service: str
|
||||
scopes: Optional[List[str]] = None
|
||||
|
||||
+44
-28
@@ -40,7 +40,10 @@ APPS_FILE = CONFIG_DIR / "oauth_apps.json"
|
||||
TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json"
|
||||
|
||||
# Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config
|
||||
# uebersteuert). Stefan muss nur client_id + client_secret eintragen.
|
||||
# uebersteuert). Aktuell nur Spotify als out-of-the-box Service — fuer alles
|
||||
# andere benutzt ARIA das `oauth_register_provider` Tool (legt Provider on-
|
||||
# demand mit den jeweiligen Endpunkten an). Stefan muss bei jedem Provider
|
||||
# danach nur client_id + client_secret in Diagnostic / App eintragen.
|
||||
DEFAULT_PROVIDERS: dict[str, dict] = {
|
||||
"spotify": {
|
||||
"auth_url": "https://accounts.spotify.com/authorize",
|
||||
@@ -50,33 +53,6 @@ DEFAULT_PROVIDERS: dict[str, dict] = {
|
||||
"user-library-read"],
|
||||
"client_auth": "basic", # client_id:client_secret als Basic-Auth-Header
|
||||
},
|
||||
"google": {
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["openid", "email", "profile"],
|
||||
"client_auth": "body", # client_id+secret im Body
|
||||
"extra_auth_params": {"access_type": "offline", "prompt": "consent"},
|
||||
},
|
||||
"github": {
|
||||
"auth_url": "https://github.com/login/oauth/authorize",
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"scopes": ["read:user"],
|
||||
"client_auth": "body",
|
||||
"accept_header": "application/json", # GitHub returns form-urlencoded otherwise
|
||||
},
|
||||
"strava": {
|
||||
"auth_url": "https://www.strava.com/oauth/authorize",
|
||||
"token_url": "https://www.strava.com/oauth/token",
|
||||
"scopes": ["read", "activity:read_all"],
|
||||
"client_auth": "body",
|
||||
"extra_auth_params": {"approval_prompt": "auto"},
|
||||
},
|
||||
"microsoft": {
|
||||
"auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
"token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
"scopes": ["User.Read", "offline_access"],
|
||||
"client_auth": "body",
|
||||
},
|
||||
}
|
||||
|
||||
# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at}
|
||||
@@ -149,6 +125,46 @@ def _provider_credentials(service: str) -> tuple[str, str]:
|
||||
return cid, sec
|
||||
|
||||
|
||||
def register_provider(service: str, auth_url: str, token_url: str,
|
||||
scopes: Optional[list[str]] = None,
|
||||
client_auth: str = "body",
|
||||
extra_auth_params: Optional[dict] = None,
|
||||
accept_header: Optional[str] = None) -> dict:
|
||||
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
|
||||
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
|
||||
vom Brain-Tool `oauth_register_provider` gerufen.
|
||||
|
||||
Wenn der Service schon existiert: URLs/Scopes werden ueberschrieben,
|
||||
aber vorhandene client_id/client_secret bleiben unberuehrt.
|
||||
"""
|
||||
svc = (service or "").strip()
|
||||
if not svc or not all(c.isalnum() or c in "_-" for c in svc) or len(svc) > 60:
|
||||
raise ValueError(f"Ungueltiger service-Name: {service!r}")
|
||||
if not auth_url.startswith(("http://", "https://")):
|
||||
raise ValueError(f"auth_url muss http(s):// sein: {auth_url!r}")
|
||||
if not token_url.startswith(("http://", "https://")):
|
||||
raise ValueError(f"token_url muss http(s):// sein: {token_url!r}")
|
||||
if client_auth not in ("body", "basic"):
|
||||
raise ValueError(f"client_auth muss 'body' oder 'basic' sein: {client_auth!r}")
|
||||
|
||||
apps = _load_json(APPS_FILE)
|
||||
entry = apps.get(svc) or {}
|
||||
entry["auth_url"] = auth_url.strip()
|
||||
entry["token_url"] = token_url.strip()
|
||||
if scopes is not None:
|
||||
entry["scopes"] = list(scopes)
|
||||
entry["client_auth"] = client_auth
|
||||
if extra_auth_params is not None:
|
||||
entry["extra_auth_params"] = extra_auth_params
|
||||
if accept_header is not None:
|
||||
entry["accept_header"] = accept_header
|
||||
apps[svc] = entry
|
||||
_save_json(APPS_FILE, apps)
|
||||
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
|
||||
svc, auth_url, token_url, len(entry.get("scopes") or []))
|
||||
return entry
|
||||
|
||||
|
||||
def _cleanup_pending() -> None:
|
||||
"""Entfernt abgelaufene Pending-Auths."""
|
||||
now = time.time()
|
||||
|
||||
@@ -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}
|
||||
@@ -47,9 +47,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills"))
|
||||
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"}
|
||||
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:
|
||||
@@ -66,6 +72,44 @@ def _skill_dir(name: str) -> Path:
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
def list_skills(active_only: bool = False) -> list[dict]:
|
||||
@@ -119,6 +163,7 @@ def create_skill(
|
||||
requires: Optional[dict] = None,
|
||||
pip_packages: Optional[list[str]] = None,
|
||||
author: str = "aria",
|
||||
config_schema: Optional[list] = None,
|
||||
) -> dict:
|
||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||
|
||||
@@ -128,6 +173,7 @@ def create_skill(
|
||||
name = _safe_name(name)
|
||||
if execution not in VALID_EXECUTIONS:
|
||||
raise ValueError(f"execution muss eines von {VALID_EXECUTIONS} sein")
|
||||
_check_anti_graveyard(name)
|
||||
d = _skill_dir(name)
|
||||
if d.exists():
|
||||
raise ValueError(f"Skill '{name}' existiert bereits — erst loeschen oder updaten")
|
||||
@@ -166,6 +212,8 @@ def create_skill(
|
||||
"use_count": 0,
|
||||
"version": "1.0",
|
||||
"author": author,
|
||||
"config_schema": _normalize_config_schema(config_schema),
|
||||
"version_history": [],
|
||||
}
|
||||
write_manifest(name, manifest)
|
||||
|
||||
@@ -184,6 +232,35 @@ def create_skill(
|
||||
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:
|
||||
venv = skill_dir / "venv"
|
||||
logger.info("venv erstellen: %s", venv)
|
||||
@@ -206,10 +283,30 @@ def update_skill(name: str, patch: dict) -> dict:
|
||||
if manifest is None:
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
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"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
manifest[k] = v
|
||||
if "config_schema" in patch:
|
||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||
|
||||
# Code austauschen
|
||||
if "entry_code" in patch and patch["entry_code"]:
|
||||
@@ -255,9 +352,230 @@ def delete_skill(name: str) -> None:
|
||||
if not d.exists():
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
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)
|
||||
|
||||
|
||||
# ─── 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
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["SKILL_DIR"] = str(d)
|
||||
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
|
||||
if exec_mode == "local-venv":
|
||||
|
||||
+22
-2
@@ -1614,10 +1614,21 @@ class ARIABridge:
|
||||
|
||||
except websockets.ConnectionClosed:
|
||||
logger.warning("[rvs] Verbindung verloren")
|
||||
# Bei Reconnect wieder primary (wss://) versuchen — die
|
||||
# Bedingungen die zum Fallback gefuehrt haben sind transient
|
||||
# (z.B. Caddy noch nicht fertig mit ACME).
|
||||
if using_fallback:
|
||||
logger.info("[rvs] Reset auf primary URL fuer Reconnect-Versuch")
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
except ConnectionRefusedError:
|
||||
logger.warning("[rvs] Nicht erreichbar")
|
||||
if using_fallback:
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
except (ssl.SSLError, OSError) as e:
|
||||
# TLS-Fehler — Fallback auf ws:// versuchen
|
||||
# TLS-Fehler — Fallback auf ws:// nur einmal pro Connect-Versuch,
|
||||
# bei naechstem Reconnect wieder primary probieren.
|
||||
if not using_fallback and self.rvs_url_fallback:
|
||||
logger.warning("[rvs] TLS-Fehler: %s", e)
|
||||
logger.warning("[rvs] TLS gewollt aber nicht verfuegbar — Fallback auf ws://")
|
||||
@@ -1626,8 +1637,17 @@ class ARIABridge:
|
||||
retry_delay = 1 # Sofort versuchen
|
||||
else:
|
||||
logger.error("[rvs] SSL-Fehler (kein Fallback): %s", e)
|
||||
except Exception:
|
||||
# Auch hier: nach gescheitertem Fallback wieder primary probieren
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
except Exception as e:
|
||||
logger.exception("[rvs] WebSocket-Fehler")
|
||||
# InvalidMessage (HTTP 400 von TLS-Endpoint bei ws-Connect)
|
||||
# → wir kleben auf dem falschen Fallback, zurueck zu primary.
|
||||
if using_fallback:
|
||||
logger.warning("[rvs] Fallback liefert auch nichts — schalte zurueck auf primary")
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
finally:
|
||||
self.ws_rvs = None
|
||||
|
||||
|
||||
+270
-27
@@ -1650,36 +1650,54 @@
|
||||
if (msg.type === 'chat_history') {
|
||||
const boxes = [chatBox, document.getElementById('chat-box-fs')].filter(Boolean);
|
||||
for (const b of boxes) b.innerHTML = '';
|
||||
let errorCount = 0;
|
||||
if (msg.messages && msg.messages.length > 0) {
|
||||
for (const m of msg.messages) {
|
||||
if (m.type === 'aria_file') {
|
||||
// ARIA-Datei-Bubble — addAriaFile schreibt selbst in beide Boxen
|
||||
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 escaped = escapeHtml(cleaned);
|
||||
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) => {
|
||||
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 trashBtn = m.ts
|
||||
? `<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>`;
|
||||
for (const b of boxes) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `chat-msg ${m.type}`;
|
||||
if (m.ts) el.dataset.ts = String(m.ts);
|
||||
el.innerHTML = innerHtml;
|
||||
b.appendChild(el);
|
||||
for (let mi = 0; mi < msg.messages.length; mi++) {
|
||||
const m = msg.messages[mi];
|
||||
try {
|
||||
if (m.type === 'aria_file') {
|
||||
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted });
|
||||
continue;
|
||||
}
|
||||
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
|
||||
const escaped = escapeHtml(cleaned);
|
||||
let linked = linkifyText(escaped);
|
||||
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'">`;
|
||||
});
|
||||
const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?';
|
||||
const trashBtn = m.ts
|
||||
? `<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>`;
|
||||
for (const b of boxes) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `chat-msg ${m.type}`;
|
||||
if (m.ts) el.dataset.ts = String(m.ts);
|
||||
el.innerHTML = innerHtml;
|
||||
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;
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
console.warn(`chat_history: ${errorCount} Bubble(s) konnten nicht gerendert werden`);
|
||||
}
|
||||
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="deleteSkill('${escapeHtml(s.name)}')" style="padding:2px 10px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
|
||||
</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 id="skill-logs-${escapeHtml(s.name)}" style="font-size:11px;color:#8888AA;">(Logs lädt...)</div>
|
||||
</div>
|
||||
@@ -3529,6 +3549,8 @@
|
||||
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>';
|
||||
} catch {}
|
||||
loadSkillConfigSection(name);
|
||||
loadSkillVersionsSection(name);
|
||||
try {
|
||||
const r2 = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/logs');
|
||||
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) {
|
||||
try {
|
||||
await fetch('/api/brain/skills/' + encodeURIComponent(name), {
|
||||
@@ -3900,11 +4071,27 @@
|
||||
const isCustom = !knownDefaults.includes(svcName);
|
||||
const customMark = isCustom ? ' <span style="color:#8888AA;font-size:10px;">(custom)</span>' : '';
|
||||
card.style.cssText = 'background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;';
|
||||
// Custom-Provider zeigen URL/Scope-Felder zum Editieren — Defaults
|
||||
// verstecken die Felder hinter einem "<details>" damit sie nicht
|
||||
// ausversehen ueberschrieben werden.
|
||||
const scopesValue = Array.isArray(app.scopes) ? app.scopes.join(' ') : '';
|
||||
const urlFieldsHtml = `
|
||||
<label style="color:#8888AA;font-size:11px;margin-top:6px;">auth_url:</label>
|
||||
<input type="text" id="oauth-auth-${_ofmt(svcName)}" value="${_ofmt(app.auth_url || '')}" placeholder="https://provider.com/oauth/authorize"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:11px;font-family:monospace;">
|
||||
<label style="color:#8888AA;font-size:11px;">token_url:</label>
|
||||
<input type="text" id="oauth-tok-${_ofmt(svcName)}" value="${_ofmt(app.token_url || '')}" placeholder="https://provider.com/oauth/token"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:11px;font-family:monospace;">
|
||||
<label style="color:#8888AA;font-size:11px;">scopes (space-separated):</label>
|
||||
<input type="text" id="oauth-scopes-${_ofmt(svcName)}" value="${_ofmt(scopesValue)}" placeholder="read write user.email"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:11px;font-family:monospace;">
|
||||
`;
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong style="color:#FFF;text-transform:capitalize;">${_ofmt(svcName)}</strong>${customMark}
|
||||
<span style="color:${statusColor};font-size:12px;flex:1;">${statusText}</span>
|
||||
${s.authenticated ? `<button class="btn secondary" onclick="revokeOAuth('${_ofmt(svcName)}')" style="padding:2px 8px;font-size:10px;" title="Token loeschen">Abmelden</button>` : ''}
|
||||
${isCustom ? `<button class="btn secondary" onclick="deleteOAuthApp('${_ofmt(svcName)}')" style="padding:2px 8px;font-size:10px;background:#3A1F1F;color:#FF6B6B;border-color:#FF6B6B;" title="Service komplett entfernen">🗑</button>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<label style="color:#8888AA;font-size:11px;">client_id:</label>
|
||||
@@ -3916,6 +4103,12 @@
|
||||
style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
|
||||
<button type="button" class="btn secondary" onclick="toggleSecret('oauth-sec-${_ofmt(svcName)}', this)" style="padding:2px 8px;font-size:10px;">👁</button>
|
||||
</div>
|
||||
${isCustom ? urlFieldsHtml : `
|
||||
<details style="margin-top:4px;">
|
||||
<summary style="color:#666680;font-size:10px;cursor:pointer;">Default-URLs überschreiben (advanced)</summary>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:6px;">${urlFieldsHtml}</div>
|
||||
</details>
|
||||
`}
|
||||
<div style="display:flex;gap:6px;margin-top:4px;">
|
||||
<button class="btn primary" onclick="saveOAuthApp('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;">Speichern</button>
|
||||
<button class="btn secondary" onclick="authorizeOAuth('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;" ${!s.configured ? 'disabled title="Erst client_id+secret eintragen"' : ''}>
|
||||
@@ -3926,25 +4119,75 @@
|
||||
`;
|
||||
listEl.appendChild(card);
|
||||
}
|
||||
// "+ Custom Service hinzufuegen"-Button am Ende
|
||||
const addCard = document.createElement('div');
|
||||
addCard.style.cssText = 'background:#0D0D1A;border:1px dashed #2A2A3E;border-radius:6px;padding:10px 12px;';
|
||||
addCard.innerHTML = `
|
||||
<button class="btn secondary" onclick="openOAuthCustomDialog()" style="width:100%;padding:8px;font-size:12px;color:#8888AA;">
|
||||
➕ Custom OAuth-Provider hinzufuegen (Dropbox, Discord, Notion, ...)
|
||||
</button>
|
||||
`;
|
||||
listEl.appendChild(addCard);
|
||||
if (allServices.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:#555570;">Keine Services bekannt.</div>';
|
||||
// (addCard ist trotzdem schon dran)
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div style="color:#FF6B6B;">Fehler beim Laden: ${_ofmt(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
function openOAuthCustomDialog() {
|
||||
const name = (prompt('Service-Name (z.B. dropbox, discord) — a-z 0-9 _ -:') || '').trim().toLowerCase();
|
||||
if (!name || !/^[a-z0-9_-]+$/.test(name)) {
|
||||
if (name) alert('Ungueltiger Name. Erlaubt: a-z 0-9 _ -');
|
||||
return;
|
||||
}
|
||||
const authUrl = (prompt(`auth_url fuer ${name}:`, 'https://') || '').trim();
|
||||
if (!authUrl) return;
|
||||
const tokenUrl = (prompt(`token_url fuer ${name}:`, 'https://') || '').trim();
|
||||
if (!tokenUrl) return;
|
||||
const scopesRaw = (prompt(`scopes (space-separated, optional):`, '') || '').trim();
|
||||
const scopes = scopesRaw ? scopesRaw.split(/\s+/).filter(Boolean) : undefined;
|
||||
fetch('/api/brain/oauth/apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service: name, auth_url: authUrl, token_url: tokenUrl, scopes }),
|
||||
})
|
||||
.then(r => r.ok ? r.json() : r.text().then(t => Promise.reject(new Error(t))))
|
||||
.then(() => loadOAuthServices())
|
||||
.catch(e => alert('Custom-Service anlegen fehlgeschlagen: ' + e.message));
|
||||
}
|
||||
async function deleteOAuthApp(service) {
|
||||
if (!confirm(`Service "${service}" komplett entfernen? client_id/secret + Token werden geloescht.`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/brain/oauth/apps/' + encodeURIComponent(service), { method: 'DELETE' });
|
||||
if (!r.ok) {
|
||||
alert('Loeschen fehlgeschlagen: ' + (await r.text()));
|
||||
return;
|
||||
}
|
||||
loadOAuthServices();
|
||||
} catch (e) {
|
||||
alert('Loeschen fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
async function saveOAuthApp(service) {
|
||||
const cid = document.getElementById('oauth-cid-' + service)?.value?.trim() || '';
|
||||
const sec = document.getElementById('oauth-sec-' + service)?.value || '';
|
||||
const authUrl = (document.getElementById('oauth-auth-' + service)?.value || '').trim();
|
||||
const tokenUrl = (document.getElementById('oauth-tok-' + service)?.value || '').trim();
|
||||
const scopesRaw = (document.getElementById('oauth-scopes-' + service)?.value || '').trim();
|
||||
if (!cid) {
|
||||
alert('client_id darf nicht leer sein.');
|
||||
return;
|
||||
}
|
||||
const body = { service, client_id: cid, client_secret: sec };
|
||||
if (authUrl) body.auth_url = authUrl;
|
||||
if (tokenUrl) body.token_url = tokenUrl;
|
||||
if (scopesRaw) body.scopes = scopesRaw.split(/\s+/).filter(Boolean);
|
||||
try {
|
||||
const r = await fetch('/api/brain/oauth/apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service, client_id: cid, client_secret: sec }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
|
||||
+10
-2
@@ -701,8 +701,16 @@ function connectRVS(forcePlain) {
|
||||
state.rvs.lastError = err.message;
|
||||
broadcastState();
|
||||
|
||||
// TLS Fallback
|
||||
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) {
|
||||
// TLS-Fallback nur bei wirklichen TLS/Handshake-Fehlern.
|
||||
// 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;
|
||||
log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
|
||||
try { ws.removeAllListeners(); ws.close(); } catch (_) {}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# ════════════════════════════════════════════════════════
|
||||
# ARIA RVS — Server-seitige Config
|
||||
# Kopieren nach .env und Werte eintragen.
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
# Oeffentlich erreichbarer DNS-Name dieses Servers. Caddy holt darauf ein
|
||||
# Let's Encrypt-Zertifikat (HTTP-01 Challenge ueber Port 80) und routet
|
||||
# WebSocket + HTTP weiter an den RVS-Container.
|
||||
#
|
||||
# WICHTIG:
|
||||
# - Die Domain muss per DNS-A-Record/AAAA auf diese Maschine zeigen
|
||||
# - Port 80 + 443 muessen vom Internet aus erreichbar sein
|
||||
# - Kein anderer Reverse-Proxy davor (sonst Cert-Konflikt)
|
||||
PUBLIC_URL=rvs.example.de
|
||||
@@ -0,0 +1,8 @@
|
||||
# Docker-Compose Konfiguration mit echtem Domain-Namen
|
||||
.env
|
||||
|
||||
# Caddy persistent state (Zertifikate, ACME-Account)
|
||||
data/
|
||||
|
||||
# APK-Verzeichnis bleibt — wird ueber release.sh befuellt + commited als latest.apk
|
||||
# (siehe Hauptverzeichnis README)
|
||||
+45
-2
@@ -1,10 +1,53 @@
|
||||
# ════════════════════════════════════════════════════════
|
||||
# ARIA RVS Stack — WebSocket Relay + OAuth Callback HTTP
|
||||
# Caddy davor terminiert TLS via Let's Encrypt (HTTP-01
|
||||
# Challenge ueber Port 80). OAuth-Provider wie Spotify
|
||||
# verlangen HTTPS fuer non-localhost Redirect-URIs.
|
||||
# ════════════════════════════════════════════════════════
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - Port 80 + 443 frei (kein anderer Reverse-Proxy davor)
|
||||
# - Domain (PUBLIC_URL) zeigt per DNS auf diese Maschine
|
||||
# - .env mit PUBLIC_URL gesetzt
|
||||
#
|
||||
# Start: docker compose up -d
|
||||
# Wenn Du einen eigenen TLS-Terminator nutzt (z.B. nginx,
|
||||
# externer Caddy): caddy-service auskommentieren und
|
||||
# rvs-Container den ports-Block geben (3000 → public Port).
|
||||
|
||||
services:
|
||||
rvs:
|
||||
build: .
|
||||
ports:
|
||||
- "${RVS_PORT:-443}:3000"
|
||||
restart: always
|
||||
# KEIN ports-Block — Caddy ist davor, RVS nur intern
|
||||
# via aria-rvs-net erreichbar. Wenn Du Caddy nicht nutzt,
|
||||
# diesen ports-Block reaktivieren: ports: ["${RVS_PORT:-443}:3000"]
|
||||
volumes:
|
||||
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
||||
environment:
|
||||
- MAX_SESSIONS=10
|
||||
networks:
|
||||
- aria-rvs-net
|
||||
|
||||
# TLS-Terminator + Let's Encrypt. Holt automatisch ein Zertifikat
|
||||
# fuer ${PUBLIC_URL} (HTTP-01 Challenge ueber Port 80). WebSocket-
|
||||
# Upgrades und HTTP-Routes (OAuth-Callback) werden im reverse-proxy
|
||||
# Modus automatisch durchgereicht. ACME-Cache liegt in ./data/caddy/
|
||||
# damit Restart nicht jedes Mal ein neues Cert holt (Rate-Limit!).
|
||||
caddy:
|
||||
image: caddy:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "444:443"
|
||||
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
|
||||
volumes:
|
||||
- ./data/caddy/data:/data # Zertifikate (PERSISTENT)
|
||||
- ./data/caddy/config:/config # Caddy-Config-Cache
|
||||
depends_on:
|
||||
- rvs
|
||||
networks:
|
||||
- aria-rvs-net
|
||||
|
||||
networks:
|
||||
aria-rvs-net:
|
||||
|
||||
Reference in New Issue
Block a user