Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d2fe3e93 | |||
| b5ca3cd371 | |||
| d939fc4ac3 | |||
| 13e87fb083 | |||
| 30c1dd7473 | |||
| 9ed9c99b0e | |||
| 1ea614c26b | |||
| acaa9fc3f2 | |||
| 0887674497 | |||
| f5243b1abb | |||
| eb5c178139 | |||
| 31b0bfaac1 | |||
| 1d3c45fdda | |||
| 84a59d7b4f |
@@ -16,11 +16,21 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
|
||||
# Alle muessen den gleichen Host, Port und Token nutzen.
|
||||
|
||||
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
|
||||
# WICHTIG: muss oeffentlich aufloesbar sein (DNS), nicht nur intern.
|
||||
# Wird auch fuer OAuth-Callback-URLs verwendet — Spotify/Google/etc.
|
||||
# redirecten Stefan im Browser an https://{RVS_HOST}/oauth/callback/{service}.
|
||||
RVS_HOST=rvs.example.de
|
||||
|
||||
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
|
||||
RVS_PORT=443
|
||||
|
||||
# Oeffentlich erreichbarer TLS-Port — was Browser/Provider von aussen sehen.
|
||||
# Meist identisch mit RVS_PORT, kann aber abweichen wenn ein TLS-Terminator
|
||||
# (Caddy/Nginx) davor steht der z.B. 444 auf intern 3000 mappt. Wird fuer
|
||||
# die OAuth-Callback-URL benutzt; muss zu dem Eintrag im Provider-Dashboard
|
||||
# passen. Leer/ungesetzt = RVS_PORT wird verwendet.
|
||||
RVS_PORT_PUBLIC=
|
||||
|
||||
# TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://)
|
||||
RVS_TLS=true
|
||||
|
||||
@@ -35,6 +45,21 @@ RVS_TLS_FALLBACK=true
|
||||
# Generieren: ./generate-token.sh (traegt den Token automatisch ein)
|
||||
RVS_TOKEN=
|
||||
|
||||
# ── Brain-Timeouts ───────────────────────────────
|
||||
# Brain redet via HTTP mit dem Proxy-Container. Da der Proxy non-streaming
|
||||
# antwortet (Response kommt erst nach subprocess-close), kann ein Brain-Call
|
||||
# bei langen Agent-Sessions (Pentests, Multi-Step-Tasks) >1h dauern.
|
||||
# PROXY_TIMEOUT_SEC ist der httpx-Read-Timeout im Brain — wir setzen ihn
|
||||
# bewusst hoch (24h), der Proxy hat einen eigenen Idle-Watchdog
|
||||
# (ARIA_IDLE_TIMEOUT_MS in der proxy-Logik, default 20min Inaktivitaet)
|
||||
# der den Subprocess killt wenn wirklich was haengt.
|
||||
# Connect/Write/Pool bleiben klein damit toter Proxy in 10s erkannt wird.
|
||||
PROXY_TIMEOUT_SEC=86400
|
||||
# Diese drei sind defensive Defaults — aendern nur wenn netzwerk-bedingt noetig.
|
||||
# PROXY_CONNECT_TIMEOUT_SEC=10
|
||||
# PROXY_WRITE_TIMEOUT_SEC=30
|
||||
# PROXY_POOL_TIMEOUT_SEC=10
|
||||
|
||||
# ── Gitea — Release-Verwaltung ───────────────────
|
||||
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
|
||||
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
|
||||
|
||||
@@ -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, 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
|
||||
|
||||
@@ -342,7 +352,8 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
|
||||
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
|
||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||
- **SSH Terminal**: direkter SSH-Zugang zu aria-wohnung
|
||||
- **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**: 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`
|
||||
|
||||
---
|
||||
|
||||
@@ -377,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
|
||||
@@ -597,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
|
||||
|
||||
@@ -619,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)
|
||||
@@ -895,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
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
import gpsTrackingService from './src/services/gpsTracking';
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
@@ -99,6 +100,13 @@ const App: React.FC = () => {
|
||||
};
|
||||
initBackground();
|
||||
|
||||
// GPS-Tracking-Status aus AsyncStorage wiederherstellen (war
|
||||
// bisher nur an SettingsScreen-Mount gekoppelt; wenn Stefan
|
||||
// direkt im Chat startete blieb GPS aus bis er Settings oeffnete).
|
||||
gpsTrackingService.restoreFromStorage().catch((err) => {
|
||||
console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err);
|
||||
});
|
||||
|
||||
// Beim Beenden: Verbindung sauber trennen
|
||||
return () => {
|
||||
rvs.disconnect();
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Skill-Browser — Liste aller Skills mit Toggle, Tap-zum-Details, Run,
|
||||
* Logs und Loeschen.
|
||||
*
|
||||
* Eingesetzt von SettingsScreen → Sektion "Skills".
|
||||
*
|
||||
* Brain-API ueber brainApi (RVS-Brain-Proxy). Code-Edits laufen NICHT
|
||||
* ueber diese UI — Skill-Code-Aenderungen sind ARIAs Domaene
|
||||
* (skill_update Brain-Tool). Hier nur Manifest-Felder + Run + Cleanup.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import brainApi, { Skill } from '../services/brainApi';
|
||||
|
||||
const COL_ACTIVE = '#34C759';
|
||||
const COL_INACTIVE = '#555570';
|
||||
const COL_ARIA = '#FFD60A';
|
||||
const COL_STEFAN = '#0096FF';
|
||||
|
||||
function relTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (!t) return '—';
|
||||
const diffSec = Math.floor((Date.now() - t) / 1000);
|
||||
if (diffSec < 60) return `vor ${diffSec}s`;
|
||||
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
|
||||
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
|
||||
return `vor ${Math.floor(diffSec / 86400)}d`;
|
||||
}
|
||||
|
||||
export const SkillBrowser: React.FC = () => {
|
||||
const [items, setItems] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [detail, setDetail] = useState<Skill | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
brainApi.listSkills()
|
||||
.then(s => {
|
||||
s.sort((a, b) => {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
setItems(s);
|
||||
})
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const visible = items.filter(s => {
|
||||
if (filter === 'active') return s.active;
|
||||
if (filter === 'inactive') return !s.active;
|
||||
return true;
|
||||
});
|
||||
|
||||
const toggleActive = (s: Skill) => {
|
||||
brainApi.updateSkill(s.name, { active: !s.active })
|
||||
.then(() => load())
|
||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Skill }) => {
|
||||
const isAria = (item.author || '').toLowerCase() === 'aria';
|
||||
const authorColor = isAria ? COL_ARIA : COL_STEFAN;
|
||||
const authorLabel = isAria ? '🤖 von ARIA' : '👤 von Stefan';
|
||||
return (
|
||||
<TouchableOpacity style={s.row} onPress={() => setDetail(item)}>
|
||||
<View style={{flex: 1, marginRight: 8}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||||
<Text style={{color: authorColor, fontSize: 10, fontWeight: '700'}}>{authorLabel}</Text>
|
||||
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||||
</View>
|
||||
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.description}</Text>
|
||||
{item.setup_error ? (
|
||||
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}} numberOfLines={2}>
|
||||
⚠ Setup-Fehler: {item.setup_error}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||||
{item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={item.active}
|
||||
onValueChange={() => toggleActive(item)}
|
||||
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||||
thumbColor="#E0E0F0"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<View style={s.toolbar}>
|
||||
{(['all', 'active', 'inactive'] as const).map(f => (
|
||||
<TouchableOpacity
|
||||
key={f}
|
||||
style={[s.chip, filter === f && s.chipActive]}
|
||||
onPress={() => setFilter(f)}
|
||||
>
|
||||
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||||
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<View style={{flex: 1}} />
|
||||
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={visible}
|
||||
keyExtractor={s => s.name}
|
||||
renderItem={renderItem}
|
||||
nestedScrollEnabled={true}
|
||||
ListEmptyComponent={
|
||||
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||
{items.length === 0
|
||||
? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")'
|
||||
: '(keine Treffer für diesen Filter)'}
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{paddingBottom: 20}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detail ? (
|
||||
<SkillDetailModal
|
||||
skill={detail}
|
||||
onClose={() => setDetail(null)}
|
||||
onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Detail-Modal mit Run + Logs + Delete ─────────────────────────────
|
||||
|
||||
interface DetailProps {
|
||||
skill: Skill;
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) => {
|
||||
const [argValues, setArgValues] = useState<Record<string, string>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runResult, setRunResult] = useState<{
|
||||
ok: boolean; exit_code: number; stdout: string; stderr: string; duration_sec: number;
|
||||
} | null>(null);
|
||||
const [logs, setLogs] = useState<any[] | null>(null);
|
||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||
|
||||
const args = Array.isArray(skill.args) ? skill.args : [];
|
||||
|
||||
const setArg = (name: string, value: string) =>
|
||||
setArgValues(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
const run = () => {
|
||||
setRunning(true); setRunResult(null);
|
||||
const argsObj: Record<string, any> = {};
|
||||
for (const a of args) {
|
||||
if (a?.name && argValues[a.name] !== undefined && argValues[a.name] !== '') {
|
||||
argsObj[a.name] = argValues[a.name];
|
||||
}
|
||||
}
|
||||
brainApi.runSkill(skill.name, argsObj)
|
||||
.then(r => setRunResult(r))
|
||||
.catch(e => setRunResult({
|
||||
ok: false, exit_code: -1, stdout: '', stderr: String(e?.message || e), duration_sec: 0,
|
||||
}))
|
||||
.finally(() => setRunning(false));
|
||||
};
|
||||
|
||||
const loadLogs = () => {
|
||||
setLoadingLogs(true);
|
||||
brainApi.getSkillLogs(skill.name, 20)
|
||||
.then(setLogs)
|
||||
.catch(e => Alert.alert('Logs-Fehler', String(e?.message || e)))
|
||||
.finally(() => setLoadingLogs(false));
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
Alert.alert(
|
||||
'Skill loeschen?',
|
||||
`"${skill.name}" wird komplett entfernt (venv, logs, manifest). Nicht rueckholbar.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
brainApi.deleteSkill(skill.name)
|
||||
.then(() => { onReload(); onClose(); })
|
||||
.catch(e => 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}>{skill.name}</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={s.label}>Beschreibung</Text>
|
||||
<Text style={{color: '#E0E0F0', marginBottom: 12}}>{skill.description}</Text>
|
||||
|
||||
<View style={s.metaBox}>
|
||||
<Text style={s.meta}>execution: {skill.execution} · entry: {skill.entry}</Text>
|
||||
<Text style={s.meta}>author: {skill.author || '?'} · version: {skill.version || '?'}</Text>
|
||||
<Text style={s.meta}>{skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)}</Text>
|
||||
{skill.setup_error ? (
|
||||
<Text style={[s.meta, {color: '#FF6B6B'}]}>setup_error: {skill.setup_error}</Text>
|
||||
) : null}
|
||||
{Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? (
|
||||
<Text style={s.meta}>pip: {skill.requires!.pip!.join(', ')}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Args-Inputs */}
|
||||
{args.length > 0 ? (
|
||||
<>
|
||||
<Text style={[s.label, {marginTop: 18}]}>Argumente</Text>
|
||||
{args.map((a: any) => (
|
||||
<View key={a.name} style={{marginBottom: 10}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
|
||||
{a.name}{a.required ? ' *' : ''} {a.description ? `— ${a.description}` : ''}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={argValues[a.name] || ''}
|
||||
onChangeText={(v) => setArg(a.name, v)}
|
||||
placeholder={a.type || 'string'}
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
|
||||
onPress={run}
|
||||
disabled={!skill.active || running}
|
||||
>
|
||||
<Text style={{color: skill.active ? '#fff' : '#555570', fontWeight: '700', textAlign: 'center'}}>
|
||||
{running ? 'läuft...' : '▶ Ausführen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]}
|
||||
onPress={loadLogs}
|
||||
>
|
||||
<Text style={{color: '#8888AA', textAlign: 'center'}}>📜 Logs</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!skill.active ? (
|
||||
<Text style={{color: '#FFD60A', fontSize: 12, marginTop: 6, fontStyle: 'italic'}}>
|
||||
Skill ist deaktiviert — toggle in der Liste zum Aktivieren.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Run-Result */}
|
||||
{runResult ? (
|
||||
<View style={[s.metaBox, {marginTop: 14, borderLeftWidth: 3, borderLeftColor: runResult.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||
<Text style={[s.meta, {color: runResult.ok ? COL_ACTIVE : '#FF6B6B', fontWeight: '700'}]}>
|
||||
{runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s
|
||||
</Text>
|
||||
{runResult.stdout ? (
|
||||
<>
|
||||
<Text style={[s.meta, {marginTop: 6, color: '#8888AA', fontWeight: '600'}]}>stdout:</Text>
|
||||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]}>{runResult.stdout}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{runResult.stderr ? (
|
||||
<>
|
||||
<Text style={[s.meta, {marginTop: 6, color: '#FF6B6B', fontWeight: '600'}]}>stderr:</Text>
|
||||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#FF9999'}]}>{runResult.stderr}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Logs */}
|
||||
{loadingLogs ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
|
||||
) : logs ? (
|
||||
<View style={{marginTop: 14}}>
|
||||
<Text style={[s.label, {marginTop: 0}]}>Letzte Runs (Top 20)</Text>
|
||||
{logs.length === 0 ? (
|
||||
<Text style={{color: '#555570', fontStyle: 'italic'}}>(keine Logs)</Text>
|
||||
) : logs.map((log, idx) => (
|
||||
<View key={idx} style={[s.metaBox, {marginTop: 6, borderLeftWidth: 2, borderLeftColor: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||
<Text style={[s.meta, {color: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||
{log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s
|
||||
</Text>
|
||||
{log.stdout ? (
|
||||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]} numberOfLines={3}>
|
||||
{String(log.stdout).slice(0, 300)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={{height: 30}} />
|
||||
</ScrollView>
|
||||
|
||||
<View style={s.modalFooter}>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF6B6B'}]} onPress={remove}>
|
||||
<Text style={{color: '#FF6B6B', fontWeight: '700'}}>🗑 Loeschen</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={{flex: 1}} />
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E'}]} onPress={onClose}>
|
||||
<Text style={{color: '#8888AA'}}>Schliessen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Styles ───────────────────────────────────────────────────────────
|
||||
|
||||
const s = StyleSheet.create({
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: '#FFD60A',
|
||||
},
|
||||
iconBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
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,
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1E1E2E',
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
color: '#8888AA',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
borderRadius: 6,
|
||||
color: '#E0E0F0',
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
metaBox: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
marginTop: 6,
|
||||
gap: 4,
|
||||
},
|
||||
meta: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export default SkillBrowser;
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
AppState,
|
||||
NativeModules,
|
||||
Alert,
|
||||
Pressable,
|
||||
Share,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -1987,7 +1989,7 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
<Pressable
|
||||
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
||||
onLayout={e => {
|
||||
// Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
|
||||
@@ -1995,6 +1997,9 @@ const ChatScreen: React.FC = () => {
|
||||
// unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
|
||||
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
|
||||
}}
|
||||
onLongPress={() => openBubbleActions(item)}
|
||||
delayLongPress={500}
|
||||
android_ripple={null}
|
||||
>
|
||||
{/* Anhang-Vorschau */}
|
||||
{item.attachments?.map((att, idx) => (
|
||||
@@ -2125,6 +2130,15 @@ const ChatScreen: React.FC = () => {
|
||||
) : null}
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.timestamp}>{time}</Text>
|
||||
{item.text.length > 0 ? (
|
||||
<TouchableOpacity
|
||||
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||
onPress={() => openBubbleActions(item)}
|
||||
accessibilityLabel="Aktionen"
|
||||
>
|
||||
<Text style={styles.bubbleCopyIcon}>{'⎘'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
{isUser && item.deliveryStatus ? (
|
||||
item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
||||
<TouchableOpacity
|
||||
@@ -2148,7 +2162,58 @@ const ChatScreen: React.FC = () => {
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon).
|
||||
// Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert
|
||||
// teilen kann ohne den umliegenden Text mitzunehmen.
|
||||
const extractCopyables = (text: string): { label: string; value: string }[] => {
|
||||
const items: { label: string; value: string }[] = [];
|
||||
const urlRe = /https?:\/\/[^\s<>"']+/gi;
|
||||
const mailRe = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
||||
const telRe = /(?:\+?\d[\d ()/-]{6,}\d)/g;
|
||||
const seen = new Set<string>();
|
||||
const push = (label: string, value: string) => {
|
||||
const trimmed = value.trim().replace(/[,;.)\]}>]+$/g, '');
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
items.push({ label, value: trimmed });
|
||||
};
|
||||
(text.match(urlRe) || []).forEach(u => push('URL', u));
|
||||
(text.match(mailRe) || []).forEach(m => push('E-Mail', m));
|
||||
(text.match(telRe) || []).forEach(t => push('Telefon', t));
|
||||
return items.slice(0, 5); // max 5 items, mehr wird unleserlich
|
||||
};
|
||||
|
||||
// Long-Press oder ⎘-Icon auf einer Bubble. Zeigt einen Alert mit
|
||||
// "Text teilen" (= System-Share-Sheet, dort gibt's auch Zwischenablage)
|
||||
// sowie pro extrahierte URL/E-Mail/Telefonnummer eine Option um
|
||||
// gezielt nur dieses Item zu teilen.
|
||||
const openBubbleActions = (item: ChatMessage) => {
|
||||
const text = showSystemHints ? item.text : stripSystemHints(item.text);
|
||||
if (!text) return;
|
||||
const copyables = extractCopyables(text);
|
||||
const buttons: any[] = [
|
||||
{
|
||||
text: '📋 Ganzen Text teilen',
|
||||
onPress: () => Share.share({ message: text }).catch(() => {}),
|
||||
},
|
||||
];
|
||||
for (const c of copyables) {
|
||||
buttons.push({
|
||||
text: `📎 ${c.label}: ${c.value.slice(0, 32)}${c.value.length > 32 ? '…' : ''}`,
|
||||
onPress: () => Share.share({ message: c.value }).catch(() => {}),
|
||||
});
|
||||
}
|
||||
buttons.push({ text: 'Abbrechen', style: 'cancel' });
|
||||
Alert.alert(
|
||||
'Bubble-Aktionen',
|
||||
copyables.length > 0
|
||||
? 'Was moechtest du teilen / kopieren?'
|
||||
: 'Text in System-Share-Sheet oeffnen (dort "In Zwischenablage" verfuegbar).',
|
||||
buttons,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3126,6 +3191,12 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
bubbleCopyIcon: {
|
||||
fontSize: 13,
|
||||
color: '#8888AA',
|
||||
marginLeft: 6,
|
||||
opacity: 0.7,
|
||||
},
|
||||
fullscreenOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.95)',
|
||||
|
||||
@@ -56,6 +56,8 @@ import gpsTrackingService from '../services/gpsTracking';
|
||||
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
||||
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,
|
||||
@@ -106,6 +108,8 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||
{ 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;
|
||||
@@ -928,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'}
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
|
||||
>
|
||||
|
||||
{currentSection === null && (
|
||||
@@ -1809,6 +1813,33 @@ const SettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Skills === */}
|
||||
{currentSection === 'skills' && (<>
|
||||
<Text style={styles.sectionTitle}>Skills</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||
Wiederverwendbare Python-Skills die ARIA selbst gebaut hat oder die Du importiert hast.
|
||||
Toggle aktiv/inaktiv, Tap fuer Details + Run + Logs. Code-Aenderungen macht ARIA via
|
||||
ihr skill_update Brain-Tool — hier nur Manifest-Felder + Run + Cleanup.
|
||||
</Text>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<SkillBrowser />
|
||||
</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,45 @@ 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;
|
||||
description: string;
|
||||
execution: string; // local-venv | local-bin | bash
|
||||
entry: string; // run.py | run.sh
|
||||
args?: any[]; // [{name, type, required, description}]
|
||||
requires?: { pip?: string[]; binaries?: string[] };
|
||||
active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
last_used?: string | null;
|
||||
use_count?: number;
|
||||
version?: string;
|
||||
author?: string; // "aria" | "stefan"
|
||||
setup_error?: string;
|
||||
}
|
||||
|
||||
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||
export interface Trigger {
|
||||
name: string;
|
||||
@@ -236,9 +275,12 @@ export const brainApi = {
|
||||
|
||||
// ── Triggers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Trigger (aktive + inaktive). */
|
||||
/** Liste aller Trigger (aktive + inaktive).
|
||||
* Brain returnt {triggers: [...]} — wir unwrappen damit der Caller einfach
|
||||
* t.sort/filter/map nutzen kann. Ohne das Unwrap warf t.sort() eine
|
||||
* TypeError-Exception und der TriggerBrowser blieb leer. */
|
||||
listTriggers(): Promise<Trigger[]> {
|
||||
return _send('/triggers/list');
|
||||
return _send('/triggers/list').then((r: any) => Array.isArray(r) ? r : (r?.triggers || []));
|
||||
},
|
||||
|
||||
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||
@@ -301,6 +343,116 @@ export const brainApi = {
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── Skills ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Skills (aktive + inaktive). Brain returnt {skills: [...]}. */
|
||||
listSkills(): Promise<Skill[]> {
|
||||
return _send('/skills/list').then((r: any) => Array.isArray(r) ? r : (r?.skills || []));
|
||||
},
|
||||
|
||||
/** Einzelnen Skill holen (inkl. setup_error, last_used, use_count). */
|
||||
getSkill(name: string): Promise<Skill> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
/** Skill ausfuehren (mit args als ENV ARG_XXX). Skill-Run kann lange dauern,
|
||||
* 5 min Default-Timeout. */
|
||||
runSkill(name: string, args: Record<string, any> = {}): Promise<{
|
||||
ok: boolean; exit_code: number; stdout: string; stderr: string;
|
||||
duration_sec: number; log_path?: string;
|
||||
}> {
|
||||
return _send('/skills/run', {
|
||||
method: 'POST',
|
||||
body: { name, args, timeout_sec: 300 },
|
||||
timeoutMs: 320000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Skill-Manifest aendern (description, active, args...). Code-Aenderungen
|
||||
* gehen ueber ARIAs eigene skill_update-Tool — die App-UI sollte sie
|
||||
* NICHT direkt anbieten (zu fehleranfaellig). */
|
||||
updateSkill(name: string, body: Partial<{
|
||||
description: string;
|
||||
active: boolean;
|
||||
args: any[];
|
||||
version: string;
|
||||
}>): Promise<Skill> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Skill loeschen (samt venv + logs). */
|
||||
deleteSkill(name: string): Promise<{ deleted: string }> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Letzte Run-Logs eines Skills. */
|
||||
getSkillLogs(name: string, limit: number = 20): Promise<any[]> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||
},
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
|
||||
@@ -21,6 +21,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# CPU-only torch zuerst — sonst zieht sentence-transformers den Default
|
||||
# torch-Wheel der ~5 GB CUDA-Libs (nvidia-cudnn, nvidia-cublas, cuda-toolkit,
|
||||
# triton, ...) als Dependencies einsaugt. Brain laeuft komplett auf CPU
|
||||
# (MiniLM-Embeddings ~120 MB), wir brauchen das alles nicht.
|
||||
RUN pip install --no-cache-dir torch==2.5.1 \
|
||||
--index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
+354
-2
@@ -30,6 +30,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage
|
||||
import skills as skills_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import oauth as oauth_mod
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
||||
@@ -122,6 +123,67 @@ META_TOOLS = [
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skill_update",
|
||||
"description": (
|
||||
"Aktualisiere einen EXISTIERENDEN Skill statt eine zweite Version "
|
||||
"mit `-v2`/`-new`/`-fixed` Suffix anzulegen. Stefan hasst Skill-"
|
||||
"Friedhoefe. Wenn Du `youtube2mp3` umbauen sollst → `skill_update` "
|
||||
"auf den bestehenden, NICHT `skill_create` mit neuem Namen.\n\n"
|
||||
"Du kannst gleichzeitig `entry_code` (Python-Code austauschen), "
|
||||
"`readme`, `pip_packages` (bei Aenderung wird die venv automatisch "
|
||||
"neu aufgebaut), `args`, `description` und `active` setzen. Felder "
|
||||
"die Du weglaesst bleiben unberuehrt.\n\n"
|
||||
"WENN Du Dir bei einem grundlegenden API-Bruch unsicher bist ob "
|
||||
"der Skill noch zum Namen passt: lieber `skill_delete` + "
|
||||
"`skill_create` mit neuem semantischen Namen statt eines "
|
||||
"halbgaren Updates."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Bestehender Skill-Name"},
|
||||
"entry_code": {"type": "string", "description": "Neuer Python-Code (optional)"},
|
||||
"readme": {"type": "string", "description": "Neuer README-Inhalt (optional)"},
|
||||
"pip_packages": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Neue pip-Pakete (ueberschreibt komplette Liste; triggert venv-Rebuild)",
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": "Neues Args-Schema (optional)",
|
||||
},
|
||||
"description": {"type": "string", "description": "Neue Beschreibung (optional)"},
|
||||
"active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skill_delete",
|
||||
"description": (
|
||||
"Loescht einen Skill samt venv und Logs. Nutze das wenn:\n"
|
||||
"1. Stefan explizit sagt der Skill soll weg\n"
|
||||
"2. Du eine alte Skill-Version losgeworden bist nachdem `skill_create` "
|
||||
"mit besserem Namen erfolgreich war (Aufraeumen statt Skill-Friedhof)\n"
|
||||
"3. Ein Skill grundlegend kaputt und ein Update sich nicht mehr lohnt — "
|
||||
"in dem Fall bestaetige vorher kurz bei Stefan.\n\n"
|
||||
"Nicht rueckholbar."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -245,6 +307,147 @@ 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": {
|
||||
"name": "oauth_authorize",
|
||||
"description": (
|
||||
"Startet einen OAuth2-Authorize-Flow fuer einen externen "
|
||||
"Service (Spotify, Google, GitHub, Strava, Microsoft, ...). "
|
||||
"Returnt eine URL die Stefan im Browser oeffnen muss — er "
|
||||
"loggt sich beim Provider ein und stimmt den Scopes zu, der "
|
||||
"Provider redirected zu unserem RVS-Callback, RVS forwarded "
|
||||
"an Brain, Token wird automatisch gespeichert.\n\n"
|
||||
"**Nutze das wenn:** Stefan moechte einen Service nutzen "
|
||||
"(z.B. \"verbinde mich mit Spotify\", \"baue einen Spotify-"
|
||||
"Skill\"), aber `oauth_get_token` wirft *Kein Token gespeichert*.\n\n"
|
||||
"**Workflow:**\n"
|
||||
"1. `oauth_authorize(service='spotify')` -> URL\n"
|
||||
"2. Gib Stefan die URL als anklickbaren Link\n"
|
||||
"3. Warte bis er sagt dass er autorisiert hat\n"
|
||||
"4. `oauth_get_token('spotify')` -> access_token, kannst Du im API-Call nutzen\n\n"
|
||||
"Voraussetzung: Stefan hat in Diagnostic > OAuth-Apps fuer den "
|
||||
"Service `client_id` + `client_secret` eingetragen. Falls nicht, "
|
||||
"wirft das Tool eine entsprechende Fehlermeldung — sage Stefan "
|
||||
"er soll das machen, NICHT versuchen die Credentials selbst zu "
|
||||
"raten oder zu generieren."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "Service-Name. Vordefinierte: spotify, google, github, strava, microsoft. Custom-Services moeglich wenn Stefan sie in oauth_apps.json eingetragen hat (mit auth_url + token_url).",
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional: Provider-spezifische Scopes (z.B. fuer Spotify ['user-read-playback-state','playlist-modify-public']). Wenn weggelassen, werden die Default-Scopes des Services genutzt.",
|
||||
},
|
||||
},
|
||||
"required": ["service"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "oauth_get_token",
|
||||
"description": (
|
||||
"Liefert das aktuelle access_token fuer einen Service. "
|
||||
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
|
||||
"und der Provider einen refresh_token mitgegeben hat.\n\n"
|
||||
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
|
||||
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
|
||||
"z.B. `Authorization: Bearer <token>`.\n\n"
|
||||
"Wirft wenn Service noch nicht authentifiziert ist oder der "
|
||||
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service": {"type": "string", "description": "z.B. spotify, google, ..."},
|
||||
},
|
||||
"required": ["service"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "oauth_revoke",
|
||||
"description": (
|
||||
"Loescht das gespeicherte Token fuer einen Service (lokal). "
|
||||
"Stefan muss danach via `oauth_authorize` neu autorisieren wenn "
|
||||
"er den Service wieder nutzen will. Nutze das wenn Stefan sagt "
|
||||
"\"melde mich bei X ab\" oder \"vergiss meine Spotify-Anmeldung\"."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"service": {"type": "string"}},
|
||||
"required": ["service"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -540,11 +743,24 @@ class Agent:
|
||||
|
||||
# 5. System-Prompt + Window-Messages
|
||||
flux_config = _load_flux_config()
|
||||
# OAuth-Block: aktuelle Service-States + Callback-URL fuer ARIA
|
||||
try:
|
||||
oauth_services = oauth_mod.list_services()
|
||||
except Exception as exc:
|
||||
logger.warning("oauth list_services fehlgeschlagen: %s", exc)
|
||||
oauth_services = None
|
||||
oauth_host = os.environ.get("RVS_HOST", "").strip()
|
||||
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
|
||||
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
|
||||
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
|
||||
triggers=all_triggers,
|
||||
condition_vars=condition_vars,
|
||||
condition_funcs=condition_funcs,
|
||||
flux_config=flux_config)
|
||||
flux_config=flux_config,
|
||||
oauth_services=oauth_services,
|
||||
oauth_callback_host=oauth_host,
|
||||
oauth_callback_port=oauth_port,
|
||||
oauth_callback_tls=oauth_tls)
|
||||
messages = [ProxyMessage(role="system", content=system_prompt)]
|
||||
for t in self.conversation.window():
|
||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||
@@ -553,8 +769,14 @@ class Agent:
|
||||
len(hot), len(cold), len(active_skills), len(all_skills),
|
||||
len(self.conversation.window()), len(system_prompt))
|
||||
|
||||
# 6. Tool-Use-Loop
|
||||
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
|
||||
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
|
||||
# ist bereits in der Conversation. Ohne Gegenpart wird die naechste
|
||||
# Anfrage im Window an Claude geschickt mit user → user als letzten
|
||||
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
|
||||
# zu 400-Errors fuehren kann.
|
||||
final_reply = ""
|
||||
try:
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools)
|
||||
if result.tool_calls:
|
||||
@@ -588,6 +810,19 @@ class Agent:
|
||||
if not final_reply:
|
||||
raise RuntimeError("Leerer Reply vom Proxy")
|
||||
|
||||
except Exception as exc:
|
||||
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
|
||||
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
|
||||
# Error-Marker statt zu rollback-en (rollback wuerde Race-Conditions
|
||||
# mit der JSONL-Persistenz aufmachen).
|
||||
err_text = f"[Fehler: {exc}]"
|
||||
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
|
||||
try:
|
||||
self.conversation.add("assistant", err_text)
|
||||
except Exception as add_exc:
|
||||
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
|
||||
raise
|
||||
|
||||
# 7. Assistant-Turn (final reply) in die Conversation
|
||||
self.conversation.add("assistant", final_reply)
|
||||
return final_reply
|
||||
@@ -631,6 +866,46 @@ class Agent:
|
||||
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
||||
for s in items
|
||||
)
|
||||
if name == "skill_update":
|
||||
skill_name = (arguments.get("name") or "").strip()
|
||||
if not skill_name:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
patch: dict = {}
|
||||
for k in ("entry_code", "readme", "description", "args", "active"):
|
||||
if k in arguments and arguments[k] is not None:
|
||||
patch[k] = arguments[k]
|
||||
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||
patch["pip_packages"] = arguments["pip_packages"]
|
||||
if not patch:
|
||||
return "FEHLER: keine Felder zum Update angegeben."
|
||||
try:
|
||||
manifest = skills_mod.update_skill(skill_name, patch)
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
# Side-Channel-Event als skill_created getarnt — gleiche Bubble-Mechanik
|
||||
# in App/Diagnostic; das Update soll fuer Stefan ebenfalls sichtbar werden.
|
||||
self._pending_events.append({
|
||||
"type": "skill_created",
|
||||
"skill": {
|
||||
"name": manifest["name"],
|
||||
"description": manifest.get("description", ""),
|
||||
"execution": manifest.get("execution", ""),
|
||||
"active": manifest.get("active", True),
|
||||
"setup_error": manifest.get("setup_error"),
|
||||
"updated": True,
|
||||
},
|
||||
})
|
||||
changed = ", ".join(sorted(patch.keys()))
|
||||
return f"OK — Skill '{skill_name}' aktualisiert ({changed}). active={manifest['active']}"
|
||||
if name == "skill_delete":
|
||||
skill_name = (arguments.get("name") or "").strip()
|
||||
if not skill_name:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
try:
|
||||
skills_mod.delete_skill(skill_name)
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
return f"OK — Skill '{skill_name}' geloescht."
|
||||
if name.startswith("run_"):
|
||||
skill_name = name[len("run_"):]
|
||||
res = skills_mod.run_skill(skill_name, args=arguments)
|
||||
@@ -711,6 +986,83 @@ 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:
|
||||
return "FEHLER: service ist Pflicht (z.B. 'spotify')."
|
||||
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
|
||||
try:
|
||||
info = oauth_mod.build_authorize_url(svc, scopes=scopes)
|
||||
except RuntimeError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
except Exception as exc:
|
||||
logger.exception("oauth_authorize fehlgeschlagen")
|
||||
return f"FEHLER: {exc}"
|
||||
return (
|
||||
f"OK — Authorize-URL fuer {svc} bereit.\n"
|
||||
f"Sage Stefan: Klicke diesen Link um Dich bei {svc} anzumelden:\n\n"
|
||||
f"{info['url']}\n\n"
|
||||
f"Nach Zustimmung schickt Dich der Provider zu unserem Callback "
|
||||
f"({info['redirect_uri']}); RVS schnappt sich den code automatisch, "
|
||||
f"Brain tauscht ihn gegen ein Token. Du musst nichts copy-pasten.\n"
|
||||
f"Falls beim Provider 'redirect_uri_mismatch' auftaucht, muss Stefan "
|
||||
f"`{info['redirect_uri']}` einmalig im Provider-Dashboard als gueltige "
|
||||
f"Redirect-URI eintragen."
|
||||
)
|
||||
if name == "oauth_get_token":
|
||||
svc = (arguments.get("service") or "").strip()
|
||||
if not svc:
|
||||
return "FEHLER: service ist Pflicht."
|
||||
try:
|
||||
record = oauth_mod.get_token(svc)
|
||||
except RuntimeError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
tok = record.get("access_token", "")
|
||||
ttype = record.get("token_type", "Bearer")
|
||||
exp = record.get("expires_at", 0)
|
||||
remain = max(0, int(exp) - int(__import__("time").time()))
|
||||
return (
|
||||
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
|
||||
f"access_token: {tok}\n"
|
||||
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
|
||||
)
|
||||
if name == "oauth_revoke":
|
||||
svc = (arguments.get("service") or "").strip()
|
||||
if not svc:
|
||||
return "FEHLER: service ist Pflicht."
|
||||
ok = oauth_mod.revoke(svc)
|
||||
return f"OK — Token fuer {svc} entfernt." if ok else f"Kein Token fuer {svc} vorhanden."
|
||||
if name == "flux_generate":
|
||||
prompt = (arguments.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
|
||||
@@ -36,6 +36,7 @@ import metrics as metrics_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import background as background_mod
|
||||
import oauth as oauth_mod
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("aria-brain")
|
||||
@@ -849,3 +850,118 @@ async def skills_import(request: Request, overwrite: bool = False):
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
return {"imported": manifest}
|
||||
|
||||
|
||||
# ── OAuth ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/oauth/services")
|
||||
async def oauth_services_list():
|
||||
"""Liste aller Services mit Status (configured/authenticated/expires)."""
|
||||
return {"services": oauth_mod.list_services()}
|
||||
|
||||
|
||||
@app.get("/oauth/apps")
|
||||
async def oauth_apps_get():
|
||||
"""Liefert die persistierte Provider-Config (client_id sichtbar, client_secret
|
||||
NICHT — wer den Wert braucht muss ihn neu eintragen). Fuer Diagnostic-UI."""
|
||||
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
|
||||
safe = {}
|
||||
for service, entry in apps.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
safe[service] = {
|
||||
"client_id": entry.get("client_id", ""),
|
||||
"has_client_secret": bool(entry.get("client_secret")),
|
||||
"scopes": entry.get("scopes"),
|
||||
"auth_url": entry.get("auth_url"),
|
||||
"token_url": entry.get("token_url"),
|
||||
}
|
||||
return {"apps": safe, "defaults": list(oauth_mod.DEFAULT_PROVIDERS.keys())}
|
||||
|
||||
|
||||
class OAuthAppIn(BaseModel):
|
||||
service: str
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
scopes: Optional[List[str]] = None
|
||||
auth_url: Optional[str] = None
|
||||
token_url: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/oauth/apps")
|
||||
async def oauth_apps_set(body: OAuthAppIn):
|
||||
"""Speichert/aktualisiert eine Provider-Config. Leerer client_secret laesst
|
||||
den bestehenden Wert stehen (damit man die Form ohne Re-Eingabe absenden
|
||||
kann fuer reine scope-Aenderungen)."""
|
||||
service = (body.service or "").strip()
|
||||
if not service or not service.isidentifier() and not all(c.isalnum() or c in "_-" for c in service):
|
||||
raise HTTPException(400, "Ungueltiger service-Name (a-z0-9_- erlaubt)")
|
||||
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
|
||||
entry = apps.get(service) or {}
|
||||
if body.client_id:
|
||||
entry["client_id"] = body.client_id.strip()
|
||||
if body.client_secret:
|
||||
entry["client_secret"] = body.client_secret.strip()
|
||||
if body.scopes is not None:
|
||||
entry["scopes"] = body.scopes
|
||||
if body.auth_url:
|
||||
entry["auth_url"] = body.auth_url.strip()
|
||||
if body.token_url:
|
||||
entry["token_url"] = body.token_url.strip()
|
||||
apps[service] = entry
|
||||
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
|
||||
logger.info("OAuth-App %s gespeichert (client_id=%s, has_secret=%s)",
|
||||
service, entry.get("client_id", ""), bool(entry.get("client_secret")))
|
||||
return {"ok": True, "service": service}
|
||||
|
||||
|
||||
@app.delete("/oauth/apps/{service}")
|
||||
async def oauth_apps_delete(service: str):
|
||||
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
|
||||
if service in apps:
|
||||
apps.pop(service)
|
||||
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
|
||||
# Token auch wegwerfen
|
||||
oauth_mod.revoke(service)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/oauth/{service}/revoke")
|
||||
async def oauth_revoke_endpoint(service: str):
|
||||
return {"ok": oauth_mod.revoke(service)}
|
||||
|
||||
|
||||
class OAuthAuthorizeIn(BaseModel):
|
||||
service: str
|
||||
scopes: Optional[List[str]] = None
|
||||
|
||||
|
||||
@app.post("/oauth/authorize")
|
||||
async def oauth_authorize_endpoint(body: OAuthAuthorizeIn):
|
||||
"""Baut eine Authorize-URL fuer einen Service. Diagnostic kann das nutzen
|
||||
um den Auth-Flow manuell anzustossen. ARIA selbst nutzt das Tool
|
||||
`oauth_authorize` (in agent._dispatch_tool gemapped auf die gleiche Logik)."""
|
||||
try:
|
||||
return oauth_mod.build_authorize_url(body.service, scopes=body.scopes)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@app.post("/internal/oauth-callback")
|
||||
async def oauth_callback_internal(request: Request):
|
||||
"""Wird von aria-bridge gerufen wenn ein RVS oauth_callback ankommt.
|
||||
Macht den state-Match + token-exchange und persistiert."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"bad json: {exc}")
|
||||
service = (body.get("service") or "").strip()
|
||||
code = (body.get("code") or "").strip()
|
||||
state = (body.get("state") or "").strip()
|
||||
err = body.get("error") or None
|
||||
err_desc = body.get("errorDescription") or None
|
||||
if not service:
|
||||
raise HTTPException(400, "service erforderlich")
|
||||
result = oauth_mod.handle_callback(service, code, state, error=err, error_description=err_desc)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
OAuth-Manager fuer ARIA. Generischer OAuth2 Authorization-Code-Flow fuer
|
||||
Spotify, Google, GitHub, Strava, Microsoft etc.
|
||||
|
||||
Architektur:
|
||||
- Brain haelt einen Pending-Store: state-String → pending Auth-Request
|
||||
(mit timeout). Wenn ein Callback ankommt (via aria-bridge ueber RVS),
|
||||
matched der state und der code wird gegen access_token getauscht.
|
||||
- Token-Storage: /shared/config/oauth_tokens.json (pro Service ein Eintrag
|
||||
mit access_token, refresh_token, expires_at, scope).
|
||||
- Provider-Configs: /shared/config/oauth_apps.json — pro Service
|
||||
{client_id, client_secret, auth_url, token_url, scopes, ...}. Wird
|
||||
typischerweise via Diagnostic-UI gefuellt.
|
||||
- Token-Refresh: automatisch wenn access_token abgelaufen oder < 60s
|
||||
bis Ablauf bei get_token() Aufruf.
|
||||
|
||||
OAuth-Callback-URL: https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service}
|
||||
RVS_PORT_PUBLIC ist nicht zwingend gleich RVS_PORT (port-mapping via TLS-Proxy).
|
||||
ARIA setzt die URL beim Auth-Request automatisch — Stefan muss sie EINMAL pro
|
||||
Service im Provider-Dashboard registrieren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_DIR = Path("/shared/config")
|
||||
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). 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",
|
||||
"token_url": "https://accounts.spotify.com/api/token",
|
||||
"scopes": ["user-read-playback-state", "user-modify-playback-state",
|
||||
"user-read-currently-playing", "playlist-read-private",
|
||||
"user-library-read"],
|
||||
"client_auth": "basic", # client_id:client_secret als Basic-Auth-Header
|
||||
},
|
||||
}
|
||||
|
||||
# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at}
|
||||
_PENDING: dict[str, dict] = {}
|
||||
PENDING_TTL_SEC = 600 # 10 min — laenger nicht sinnvoll, OAuth-Codes sind eh kurzlebig
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _callback_url(service: str) -> str:
|
||||
"""Baut die Redirect-URL die wir bei der Provider-Auth angeben.
|
||||
Liest RVS_HOST / RVS_PORT_PUBLIC / RVS_TLS aus env."""
|
||||
host = os.environ.get("RVS_HOST", "").strip()
|
||||
if not host:
|
||||
raise RuntimeError("RVS_HOST nicht gesetzt — OAuth-Callbacks nicht moeglich")
|
||||
port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
|
||||
tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
|
||||
scheme = "https" if tls else "http"
|
||||
# Default-Ports 443/80 nicht in URL anhaengen
|
||||
if (tls and port == "443") or (not tls and port == "80"):
|
||||
return f"{scheme}://{host}/oauth/callback/{service}"
|
||||
return f"{scheme}://{host}:{port}/oauth/callback/{service}"
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
try:
|
||||
if path.exists():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("OAuth-Datei %s lesen fehlgeschlagen: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_json(path: Path, data: dict) -> None:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
# 600 — enthaelt Secrets
|
||||
try: os.chmod(path, 0o600)
|
||||
except OSError: pass
|
||||
except Exception as exc:
|
||||
logger.error("OAuth-Datei %s speichern fehlgeschlagen: %s", path, exc)
|
||||
|
||||
|
||||
def _provider_config(service: str) -> dict:
|
||||
"""Mergt Default-Provider-Config mit User-Override aus oauth_apps.json."""
|
||||
defaults = DEFAULT_PROVIDERS.get(service, {}).copy()
|
||||
apps = _load_json(APPS_FILE)
|
||||
user = (apps.get(service) or {}).copy()
|
||||
# Tiefes Merge nicht noetig — die kollidierenden Felder sind alle scalar/list.
|
||||
merged = {**defaults, **user}
|
||||
return merged
|
||||
|
||||
|
||||
def _provider_credentials(service: str) -> tuple[str, str]:
|
||||
"""Liest client_id + client_secret aus oauth_apps.json. Wirft wenn nicht
|
||||
konfiguriert — der OAuth-Flow kann ohne nicht starten."""
|
||||
apps = _load_json(APPS_FILE)
|
||||
entry = apps.get(service) or {}
|
||||
cid = (entry.get("client_id") or "").strip()
|
||||
sec = (entry.get("client_secret") or "").strip()
|
||||
if not cid or not sec:
|
||||
raise RuntimeError(
|
||||
f"OAuth-App '{service}' nicht konfiguriert. Bitte in Diagnostic > "
|
||||
f"OAuth-Apps client_id + client_secret eintragen."
|
||||
)
|
||||
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()
|
||||
for state, info in list(_PENDING.items()):
|
||||
if now - info.get("created_at", 0) > PENDING_TTL_SEC:
|
||||
_PENDING.pop(state, None)
|
||||
|
||||
|
||||
# ── Authorize ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_authorize_url(service: str, scopes: Optional[list[str]] = None,
|
||||
extra_params: Optional[dict] = None) -> dict:
|
||||
"""Baut die Authorize-URL fuer einen Provider. Speichert den state
|
||||
im Pending-Store. Returns {url, state, redirect_uri, service}.
|
||||
|
||||
Wird vom Brain-Tool oauth_authorize gerufen. ARIA gibt die url an Stefan,
|
||||
der oeffnet sie im Browser, autorisiert, Provider redirected zur
|
||||
redirect_uri (= RVS), RVS broadcasted, bridge forwarded, brain matched
|
||||
state → exchange.
|
||||
"""
|
||||
_cleanup_pending()
|
||||
cfg = _provider_config(service)
|
||||
if not cfg.get("auth_url") or not cfg.get("token_url"):
|
||||
raise RuntimeError(f"Provider '{service}' hat keine auth_url/token_url. "
|
||||
f"In oauth_apps.json eintragen oder einen der "
|
||||
f"vordefinierten Services nutzen ({', '.join(DEFAULT_PROVIDERS)}).")
|
||||
cid, _ = _provider_credentials(service)
|
||||
redirect_uri = _callback_url(service)
|
||||
state = secrets.token_urlsafe(32)
|
||||
use_scopes = scopes if scopes else cfg.get("scopes") or []
|
||||
|
||||
params = {
|
||||
"client_id": cid,
|
||||
"response_type": "code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": state,
|
||||
}
|
||||
if use_scopes:
|
||||
params["scope"] = " ".join(use_scopes)
|
||||
params.update(cfg.get("extra_auth_params") or {})
|
||||
if extra_params:
|
||||
params.update(extra_params)
|
||||
|
||||
url = cfg["auth_url"] + "?" + urllib.parse.urlencode(params)
|
||||
|
||||
_PENDING[state] = {
|
||||
"service": service,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scopes": use_scopes,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
logger.info("[oauth] Authorize-URL fuer %s gebaut: state=%s redirect=%s",
|
||||
service, state[:8] + "...", redirect_uri)
|
||||
return {"url": url, "state": state, "redirect_uri": redirect_uri, "service": service}
|
||||
|
||||
|
||||
# ── Token-Exchange ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _token_request(token_url: str, body_params: dict, cfg: dict,
|
||||
client_id: str, client_secret: str) -> dict:
|
||||
"""POST an provider /token endpoint. Returns parsed JSON oder wirft."""
|
||||
data = urllib.parse.urlencode(body_params).encode("utf-8")
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
if cfg.get("accept_header"):
|
||||
headers["Accept"] = cfg["accept_header"]
|
||||
# Client-Auth: 'basic' (Header) oder 'body' (im Form-Body)
|
||||
if cfg.get("client_auth") == "basic":
|
||||
auth_str = f"{client_id}:{client_secret}"
|
||||
b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii")
|
||||
headers["Authorization"] = f"Basic {b64}"
|
||||
else:
|
||||
# bereits im body_params drin (siehe Caller)
|
||||
pass
|
||||
req = urllib.request.Request(token_url, data=data, method="POST", headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read().decode("utf-8", "ignore")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
# GitHub default ist form-urlencoded — accept_header sollte
|
||||
# JSON anfordern, aber falls's doch mal kommt:
|
||||
parsed = urllib.parse.parse_qs(raw)
|
||||
return {k: v[0] if isinstance(v, list) and v else v for k, v in parsed.items()}
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "ignore")[:500]
|
||||
raise RuntimeError(f"Token-Request HTTP {e.code}: {body}") from e
|
||||
|
||||
|
||||
def handle_callback(service: str, code: str, state: str,
|
||||
error: Optional[str] = None,
|
||||
error_description: Optional[str] = None) -> dict:
|
||||
"""Verarbeitet einen OAuth-Callback. Validiert state, tauscht code gegen
|
||||
Token, speichert. Returns {ok, service, message, ...}.
|
||||
|
||||
Wird von /internal/oauth-callback (HTTP, von aria-bridge) gerufen.
|
||||
"""
|
||||
_cleanup_pending()
|
||||
|
||||
if error:
|
||||
# Provider hat User-Abbruch oder Fehler gemeldet
|
||||
_PENDING.pop(state, None) if state else None
|
||||
logger.warning("[oauth] Provider-Error %s/%s: %s — %s",
|
||||
service, state[:8] + "..." if state else "?", error, error_description)
|
||||
return {"ok": False, "service": service, "error": error,
|
||||
"errorDescription": error_description}
|
||||
|
||||
pending = _PENDING.pop(state, None)
|
||||
if not pending:
|
||||
logger.warning("[oauth] Unknown state %s fuer %s — abgelaufen oder CSRF?", state[:8] + "...", service)
|
||||
return {"ok": False, "service": service,
|
||||
"error": "invalid_state",
|
||||
"errorDescription": "Unbekannter oder abgelaufener state (Auth-Anfrage muss erst per oauth_authorize neu gestartet werden)."}
|
||||
if pending.get("service") != service:
|
||||
logger.warning("[oauth] state-Service-Mismatch: pending=%s vs callback=%s",
|
||||
pending.get("service"), service)
|
||||
return {"ok": False, "service": service,
|
||||
"error": "service_mismatch",
|
||||
"errorDescription": "state gehoert zu einem anderen Service."}
|
||||
|
||||
if not code:
|
||||
return {"ok": False, "service": service, "error": "no_code"}
|
||||
|
||||
cfg = _provider_config(service)
|
||||
try:
|
||||
client_id, client_secret = _provider_credentials(service)
|
||||
except RuntimeError as exc:
|
||||
return {"ok": False, "service": service, "error": "no_credentials",
|
||||
"errorDescription": str(exc)}
|
||||
|
||||
body = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": pending["redirect_uri"],
|
||||
}
|
||||
if cfg.get("client_auth") != "basic":
|
||||
body["client_id"] = client_id
|
||||
body["client_secret"] = client_secret
|
||||
|
||||
try:
|
||||
token_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
|
||||
except Exception as exc:
|
||||
logger.exception("[oauth] Token-Exchange fehlgeschlagen fuer %s", service)
|
||||
return {"ok": False, "service": service, "error": "exchange_failed",
|
||||
"errorDescription": str(exc)[:200]}
|
||||
|
||||
access = token_data.get("access_token")
|
||||
if not access:
|
||||
return {"ok": False, "service": service, "error": "no_access_token",
|
||||
"errorDescription": str(token_data)[:200]}
|
||||
|
||||
expires_in = int(token_data.get("expires_in") or 3600)
|
||||
refresh = token_data.get("refresh_token") or ""
|
||||
scope = token_data.get("scope") or " ".join(pending.get("scopes") or [])
|
||||
token_type = token_data.get("token_type") or "Bearer"
|
||||
|
||||
record = {
|
||||
"service": service,
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"token_type": token_type,
|
||||
"scope": scope,
|
||||
"expires_at": int(time.time()) + expires_in,
|
||||
"obtained_at": int(time.time()),
|
||||
}
|
||||
_persist_token(service, record)
|
||||
logger.info("[oauth] %s authentifiziert — expires in %ds, refresh=%s",
|
||||
service, expires_in, "ja" if refresh else "nein")
|
||||
return {"ok": True, "service": service, "expiresIn": expires_in,
|
||||
"hasRefresh": bool(refresh), "scope": scope}
|
||||
|
||||
|
||||
# ── Token-Storage / Refresh / Revoke ─────────────────────────
|
||||
|
||||
|
||||
def _persist_token(service: str, record: dict) -> None:
|
||||
tokens = _load_json(TOKENS_FILE)
|
||||
tokens[service] = record
|
||||
_save_json(TOKENS_FILE, tokens)
|
||||
|
||||
|
||||
def _load_token(service: str) -> Optional[dict]:
|
||||
return _load_json(TOKENS_FILE).get(service)
|
||||
|
||||
|
||||
def get_token(service: str, refresh_threshold_sec: int = 60) -> dict:
|
||||
"""Holt das aktuelle access_token fuer einen Service. Refresht automatisch
|
||||
wenn weniger als refresh_threshold_sec Restzeit. Returns das ganze
|
||||
record-dict — Caller nimmt sich access_token raus.
|
||||
|
||||
Wirft wenn nicht authentifiziert oder Refresh fehlschlaegt — Tool-Aufrufer
|
||||
soll dann oauth_authorize anbieten."""
|
||||
record = _load_token(service)
|
||||
if not record:
|
||||
raise RuntimeError(f"Kein Token fuer '{service}' gespeichert. Erst per "
|
||||
f"oauth_authorize authentifizieren.")
|
||||
exp = int(record.get("expires_at") or 0)
|
||||
remaining = exp - int(time.time())
|
||||
if remaining > refresh_threshold_sec:
|
||||
return record
|
||||
# Refresh noetig
|
||||
refresh_tok = (record.get("refresh_token") or "").strip()
|
||||
if not refresh_tok:
|
||||
raise RuntimeError(f"Token fuer '{service}' abgelaufen und kein refresh_token "
|
||||
f"vorhanden — bitte neu autorisieren mit oauth_authorize.")
|
||||
cfg = _provider_config(service)
|
||||
client_id, client_secret = _provider_credentials(service)
|
||||
body = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_tok,
|
||||
}
|
||||
if cfg.get("client_auth") != "basic":
|
||||
body["client_id"] = client_id
|
||||
body["client_secret"] = client_secret
|
||||
try:
|
||||
new_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Token-Refresh fuer '{service}' fehlgeschlagen: {exc}") from exc
|
||||
|
||||
new_access = new_data.get("access_token")
|
||||
if not new_access:
|
||||
raise RuntimeError(f"Refresh-Antwort ohne access_token: {new_data}")
|
||||
expires_in = int(new_data.get("expires_in") or 3600)
|
||||
# refresh_token kann (manche Provider) bei jedem Refresh rotieren
|
||||
new_refresh = (new_data.get("refresh_token") or refresh_tok).strip()
|
||||
record.update({
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
"expires_at": int(time.time()) + expires_in,
|
||||
"obtained_at": int(time.time()),
|
||||
})
|
||||
if new_data.get("scope"):
|
||||
record["scope"] = new_data["scope"]
|
||||
_persist_token(service, record)
|
||||
logger.info("[oauth] %s Token refreshed — neue Restzeit %ds", service, expires_in)
|
||||
return record
|
||||
|
||||
|
||||
def revoke(service: str) -> bool:
|
||||
"""Entfernt das Token aus dem Storage (Best-Effort, kein Provider-Revoke-Call)."""
|
||||
tokens = _load_json(TOKENS_FILE)
|
||||
if service not in tokens:
|
||||
return False
|
||||
tokens.pop(service, None)
|
||||
_save_json(TOKENS_FILE, tokens)
|
||||
logger.info("[oauth] %s Token geloescht (lokal).", service)
|
||||
return True
|
||||
|
||||
|
||||
def list_services() -> list[dict]:
|
||||
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
|
||||
+ Ablaufzeit. Wird von Diagnostic genutzt."""
|
||||
apps = _load_json(APPS_FILE)
|
||||
tokens = _load_json(TOKENS_FILE)
|
||||
out = []
|
||||
services = set(apps.keys()) | set(tokens.keys()) | set(DEFAULT_PROVIDERS.keys())
|
||||
now = int(time.time())
|
||||
for s in sorted(services):
|
||||
app = apps.get(s) or {}
|
||||
tok = tokens.get(s) or {}
|
||||
configured = bool(app.get("client_id") and app.get("client_secret"))
|
||||
out.append({
|
||||
"service": s,
|
||||
"configured": configured,
|
||||
"authenticated": bool(tok.get("access_token")),
|
||||
"expiresAt": tok.get("expires_at"),
|
||||
"expiresInSec": (tok.get("expires_at", 0) - now) if tok.get("expires_at") else None,
|
||||
"hasRefresh": bool(tok.get("refresh_token")),
|
||||
"scope": tok.get("scope", ""),
|
||||
"isDefault": s in DEFAULT_PROVIDERS,
|
||||
})
|
||||
return out
|
||||
+71
-1
@@ -240,6 +240,63 @@ def build_triggers_section(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_oauth_section(oauth_services: list[dict] | None,
|
||||
callback_host: str = "",
|
||||
callback_port: str = "443",
|
||||
callback_tls: bool = True) -> str:
|
||||
"""Block fuer den System-Prompt: zeigt ARIA welche externen Services
|
||||
via OAuth verfuegbar sind, welche schon authentifiziert sind, und welche
|
||||
Callback-URL beim Provider eingetragen werden muss."""
|
||||
scheme = "https" if callback_tls else "http"
|
||||
if callback_host:
|
||||
if (callback_tls and callback_port == "443") or (not callback_tls and callback_port == "80"):
|
||||
base = f"{scheme}://{callback_host}/oauth/callback/<SERVICE>"
|
||||
else:
|
||||
base = f"{scheme}://{callback_host}:{callback_port}/oauth/callback/<SERVICE>"
|
||||
else:
|
||||
base = "<nicht konfiguriert — RVS_HOST in brain env fehlt>"
|
||||
|
||||
lines = [
|
||||
"## OAuth externe Services",
|
||||
"",
|
||||
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
|
||||
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
|
||||
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
|
||||
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
|
||||
"",
|
||||
f"**Callback-URL (fest, NICHT raten):** `{base}`",
|
||||
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
|
||||
"Redirect-URI eintragen. Sie ist hardcoded an die RVS-Infrastruktur gebunden "
|
||||
"und aendert sich nicht — auch nicht wenn Du als Brain neu aufgesetzt wirst.",
|
||||
"",
|
||||
"**NICHT** versuchen client_id / client_secret selbst zu generieren oder zu "
|
||||
"raten. Wenn nicht eingetragen → Stefan sagen er soll es in Diagnostic > "
|
||||
"OAuth-Apps machen.",
|
||||
]
|
||||
if oauth_services:
|
||||
lines.append("")
|
||||
lines.append("**Aktuelle Service-Status:**")
|
||||
for s in oauth_services:
|
||||
name = s.get("service", "?")
|
||||
configured = s.get("configured", False)
|
||||
auth = s.get("authenticated", False)
|
||||
remain = s.get("expiresInSec")
|
||||
parts = []
|
||||
if not configured:
|
||||
parts.append("Credentials fehlen")
|
||||
elif not auth:
|
||||
parts.append("nicht authentifiziert")
|
||||
else:
|
||||
if remain is None:
|
||||
parts.append("authentifiziert")
|
||||
elif remain > 0:
|
||||
parts.append(f"authentifiziert, Token gueltig noch {remain}s")
|
||||
else:
|
||||
parts.append("Token abgelaufen (wird automatisch refresht)")
|
||||
lines.append(f"- `{name}`: {' / '.join(parts)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_flux_section(flux_config: dict) -> str:
|
||||
"""Block fuer den System-Prompt: aktuelle Diagnostic-Settings fuer
|
||||
Bildgenerierung (Default-Modell + User-konfigurierbare Keywords).
|
||||
@@ -279,8 +336,12 @@ def build_system_prompt(
|
||||
condition_vars: List[dict] | None = None,
|
||||
condition_funcs: List[dict] | None = None,
|
||||
flux_config: dict | None = None,
|
||||
oauth_services: list[dict] | None = None,
|
||||
oauth_callback_host: str = "",
|
||||
oauth_callback_port: str = "443",
|
||||
oauth_callback_tls: bool = True,
|
||||
) -> str:
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX."""
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||
parts = [build_hot_memory_section(pinned), "", build_time_section()]
|
||||
if skills:
|
||||
parts.append("")
|
||||
@@ -291,6 +352,15 @@ def build_system_prompt(
|
||||
if flux_config is not None:
|
||||
parts.append("")
|
||||
parts.append(build_flux_section(flux_config))
|
||||
# OAuth-Block bauen wir nur wenn RVS_HOST konfiguriert ist (sonst hat
|
||||
# die Callback-URL keinen Sinn). Sonst lassen wir den Block weg statt
|
||||
# ARIA eine "<nicht konfiguriert>"-URL zu zeigen.
|
||||
if oauth_callback_host:
|
||||
parts.append("")
|
||||
parts.append(build_oauth_section(oauth_services,
|
||||
callback_host=oauth_callback_host,
|
||||
callback_port=oauth_callback_port,
|
||||
callback_tls=oauth_callback_tls))
|
||||
if cold:
|
||||
parts.append("")
|
||||
parts.append(build_cold_memory_section(cold))
|
||||
|
||||
@@ -25,7 +25,17 @@ logger = logging.getLogger(__name__)
|
||||
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
||||
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
||||
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
||||
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200"))
|
||||
# Read-Timeout: wie lange wir auf die HTTP-Antwort vom Proxy warten.
|
||||
# Proxy ist non-streaming → erstes Byte kommt erst NACH subprocess close.
|
||||
# Agent-Loops (Pentests etc.) koennen >1h dauern → muss hoch sein.
|
||||
# Default 24h, kann via PROXY_TIMEOUT_SEC env ueberschrieben werden.
|
||||
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "86400"))
|
||||
# Connect/Write/Pool: klein damit toter Proxy schnell erkannt wird.
|
||||
# Wenn der Proxy-Container nicht antwortet beim TCP-Connect oder waehrend
|
||||
# wir den Request-Body schreiben, ist er kaputt — kein Grund 24h zu warten.
|
||||
PROXY_CONNECT_TIMEOUT_SEC = float(os.environ.get("PROXY_CONNECT_TIMEOUT_SEC", "10"))
|
||||
PROXY_WRITE_TIMEOUT_SEC = float(os.environ.get("PROXY_WRITE_TIMEOUT_SEC", "30"))
|
||||
PROXY_POOL_TIMEOUT_SEC = float(os.environ.get("PROXY_POOL_TIMEOUT_SEC", "10"))
|
||||
|
||||
|
||||
def _read_model_from_runtime() -> str:
|
||||
@@ -62,8 +72,15 @@ class ProxyClient:
|
||||
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call
|
||||
self._client = httpx.Client(timeout=PROXY_TIMEOUT_SEC)
|
||||
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call.
|
||||
# Timeouts split nach Phase: connect/write/pool klein (toter Proxy → schnell
|
||||
# ReadTimeout), read gross (ARIA darf ewig rechnen).
|
||||
self._client = httpx.Client(timeout=httpx.Timeout(
|
||||
connect=PROXY_CONNECT_TIMEOUT_SEC,
|
||||
read=PROXY_TIMEOUT_SEC,
|
||||
write=PROXY_WRITE_TIMEOUT_SEC,
|
||||
pool=PROXY_POOL_TIMEOUT_SEC,
|
||||
))
|
||||
|
||||
def chat(self, messages: List[Message], model: Optional[str] = None) -> str:
|
||||
"""Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck."""
|
||||
|
||||
@@ -194,14 +194,59 @@ def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
||||
|
||||
|
||||
def update_skill(name: str, patch: dict) -> dict:
|
||||
"""Aktualisiert einen bestehenden Skill. Manifest-Felder ueber den
|
||||
`allowed`-Filter, Code-Aenderungen ueber dedizierte Keys:
|
||||
|
||||
- `entry_code` (str) → ueberschreibt run.py / run.sh
|
||||
- `readme` (str) → ueberschreibt README.md
|
||||
- `pip_packages` (list) → ueberschreibt requirements.txt + venv-Rebuild
|
||||
(nur bei local-venv)
|
||||
"""
|
||||
manifest = read_manifest(name)
|
||||
if manifest is None:
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
d = _skill_dir(name)
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
manifest[k] = v
|
||||
|
||||
# Code austauschen
|
||||
if "entry_code" in patch and patch["entry_code"]:
|
||||
execution = manifest.get("execution", "local-venv")
|
||||
if execution == "local-venv":
|
||||
entry_path = d / "run.py"
|
||||
entry_path.write_text(patch["entry_code"], encoding="utf-8")
|
||||
else:
|
||||
entry_path = d / "run.sh"
|
||||
content = patch["entry_code"] if patch["entry_code"].startswith("#!") else "#!/usr/bin/env bash\nset -euo pipefail\n" + patch["entry_code"]
|
||||
entry_path.write_text(content, encoding="utf-8")
|
||||
entry_path.chmod(0o755)
|
||||
|
||||
# README austauschen
|
||||
if "readme" in patch and patch["readme"] is not None:
|
||||
(d / "README.md").write_text(patch["readme"], encoding="utf-8")
|
||||
|
||||
# pip_packages geaendert → requirements.txt + venv neu aufbauen
|
||||
if "pip_packages" in patch and manifest.get("execution") == "local-venv":
|
||||
pip_packages = patch["pip_packages"] or []
|
||||
(d / "requirements.txt").write_text("\n".join(pip_packages) + "\n", encoding="utf-8")
|
||||
# venv loeschen + neu aufbauen, damit alte Pakete weg sind
|
||||
venv = d / "venv"
|
||||
if venv.exists():
|
||||
shutil.rmtree(venv, ignore_errors=True)
|
||||
try:
|
||||
_setup_venv(d, pip_packages)
|
||||
# Falls vorher wegen Setup-Error deaktiviert war: jetzt frei
|
||||
manifest.pop("setup_error", None)
|
||||
manifest["active"] = patch.get("active", True)
|
||||
except Exception as exc:
|
||||
manifest["active"] = False
|
||||
manifest["setup_error"] = str(exc)[:500]
|
||||
logger.warning("Skill %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
|
||||
|
||||
write_manifest(name, manifest)
|
||||
logger.info("Skill aktualisiert: %s (keys=%s)", name, sorted(patch.keys()))
|
||||
return manifest
|
||||
|
||||
|
||||
|
||||
+314
-6
@@ -20,7 +20,9 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -48,6 +50,35 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("aria-bridge")
|
||||
|
||||
|
||||
# ── TCP-Keepalive Helper ────────────────────────────────────
|
||||
#
|
||||
# Aktiviert TCP-Level Keepalive auf einer websockets-Verbindung mit
|
||||
# aggressiven Intervallen: 30s idle bis erster Probe, 10s zwischen
|
||||
# Probes, 3 verfehlte → Verbindung tot. Das deckt den Fall ab dass
|
||||
# NAT-Tabellen-Verfall die TCP-Verbindung still kills ohne RST — Linux-
|
||||
# Default braeucht sonst 2 Stunden idle bis der Kernel selber probt.
|
||||
def _enable_tcp_keepalive(ws) -> None:
|
||||
try:
|
||||
sock = ws.transport.get_extra_info("socket")
|
||||
if sock is None:
|
||||
return
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
# Linux-spezifisch — TCP_KEEPIDLE/INTVL/CNT existieren auf macOS
|
||||
# mit anderem Namen; im Container ist Linux garantiert.
|
||||
for opt, val in (
|
||||
("TCP_KEEPIDLE", 30),
|
||||
("TCP_KEEPINTVL", 10),
|
||||
("TCP_KEEPCNT", 3),
|
||||
):
|
||||
const = getattr(socket, opt, None)
|
||||
if const is not None:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, const, val)
|
||||
logger.info("[rvs] TCP-Keepalive aktiviert (idle=30s, intvl=10s, cnt=3)")
|
||||
except Exception as exc:
|
||||
logger.warning("[rvs] TCP-Keepalive konnte nicht aktiviert werden: %s", exc)
|
||||
|
||||
|
||||
# ── Konfiguration ───────────────────────────────────────────
|
||||
|
||||
VOICES_DIR = Path("/voices")
|
||||
@@ -488,6 +519,10 @@ class ARIABridge:
|
||||
self.xtts_voice = ""
|
||||
self._f5tts_config: dict = {}
|
||||
self._flux_config: dict = {}
|
||||
# Persistente TTS-Speed (App-Setting), wird aus voice_config.json
|
||||
# gelesen + bei config-Broadcasts (siehe handle config in chat)
|
||||
# geupdated. Fallback wenn der Per-Request-Override fehlt.
|
||||
self._persistent_xtts_speed: Optional[float] = None
|
||||
vc: dict = {}
|
||||
# Gespeicherte Voice-Config laden
|
||||
try:
|
||||
@@ -497,6 +532,19 @@ class ARIABridge:
|
||||
vc = json.load(f)
|
||||
self.tts_enabled = vc.get("ttsEnabled", True)
|
||||
self.xtts_voice = vc.get("xttsVoice", "")
|
||||
# Persistente TTS-Speed: vorher war's nur per-Chat-Override
|
||||
# (App schickte speed mit jeder Nachricht). Bei Diagnostic-Chat
|
||||
# OHNE App-Vor-Chat blieb _next_speed_override=None → 1.0.
|
||||
# Jetzt persistent — Bridge greift bei TTS immer auf den
|
||||
# zuletzt von der App gesetzten Wert zurueck.
|
||||
try:
|
||||
persisted_speed = float(vc.get("xttsSpeed", 1.0))
|
||||
if 0.1 <= persisted_speed <= 5.0:
|
||||
self._persistent_xtts_speed: Optional[float] = persisted_speed
|
||||
else:
|
||||
self._persistent_xtts_speed = None
|
||||
except (TypeError, ValueError):
|
||||
self._persistent_xtts_speed = None
|
||||
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
||||
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
|
||||
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
|
||||
@@ -1154,7 +1202,16 @@ class ARIABridge:
|
||||
# TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis
|
||||
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
|
||||
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
|
||||
xtts_speed = self._next_speed_override or 1.0
|
||||
# Speed-Reihenfolge: Per-Request-Override (App schickte gerade) >
|
||||
# persistierter App-Setting (voice_config.json xttsSpeed) > 1.0 default.
|
||||
# Damit greift die App-Speed auch bei Diagnostic-Chats / Trigger-
|
||||
# Replies / Bridge-Restart, ohne dass die App vorher noch mal getippt
|
||||
# haben muss.
|
||||
xtts_speed = (
|
||||
self._next_speed_override
|
||||
or getattr(self, "_persistent_xtts_speed", None)
|
||||
or 1.0
|
||||
)
|
||||
|
||||
tts_text = tts_text_preview or text
|
||||
if not tts_text:
|
||||
@@ -1243,6 +1300,8 @@ class ARIABridge:
|
||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||
"whisperModel": self.stt_engine.model_size,
|
||||
}
|
||||
if getattr(self, "_persistent_xtts_speed", None) is not None:
|
||||
payload["xttsSpeed"] = self._persistent_xtts_speed
|
||||
payload.update(getattr(self, "_f5tts_config", {}) or {})
|
||||
payload.update(getattr(self, "_flux_config", {}) or {})
|
||||
await self._send_to_rvs({
|
||||
@@ -1254,6 +1313,24 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e)
|
||||
|
||||
async def _persist_speed_change(self, speed: float) -> None:
|
||||
"""Schreibt nur den xttsSpeed-Eintrag in voice_config.json — der
|
||||
Rest bleibt unangetastet. Wird gerufen wenn App per chat-Event
|
||||
einen neuen Speed mitschickt (kein config-Broadcast)."""
|
||||
try:
|
||||
path = "/shared/config/voice_config.json"
|
||||
data: dict = {}
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
data = json.load(f) or {}
|
||||
data["xttsSpeed"] = speed
|
||||
os.makedirs("/shared/config", exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.info("[speed] Persistiert: %.2fx", speed)
|
||||
except Exception as exc:
|
||||
logger.warning("[speed] Persistierung fehlgeschlagen: %s", exc)
|
||||
|
||||
def _fetch_active_session(self) -> None:
|
||||
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
||||
try:
|
||||
@@ -1500,6 +1577,20 @@ class ARIABridge:
|
||||
retry_delay = 2
|
||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||
|
||||
# TCP-Keepalive auf dem unterliegenden Socket aktivieren —
|
||||
# damit NAT-Tabellen-Verfall oder "halb-tote" Verbindungen
|
||||
# (kein RST, kein FIN) innerhalb von ~1 Minute erkannt
|
||||
# werden statt nach Linux-Default (2h idle). Ohne das
|
||||
# hat die Bridge schon mal 5+h auf einer toten Connection
|
||||
# gehangen ohne dass irgendeine Exception kam.
|
||||
_enable_tcp_keepalive(ws)
|
||||
|
||||
# Heartbeat-Watchdog: jeden erfolgreichen Ping markieren wir
|
||||
# in _last_heartbeat_ok. Ein separater Watchdog killt die
|
||||
# WS-Verbindung wenn diese Marke > 60s stale ist — schuetzt
|
||||
# gegen den Fall dass ws.ping() selbst nie zurueckkommt.
|
||||
self._last_heartbeat_ok = time.monotonic()
|
||||
|
||||
# Aktuellen Modus broadcasten damit gerade verbundene Apps/Diagnostic
|
||||
# ihren UI-State sofort syncen koennen
|
||||
await self._broadcast_current_mode()
|
||||
@@ -1512,19 +1603,32 @@ class ARIABridge:
|
||||
|
||||
# Heartbeat senden (RVS erwartet Ping alle 30s)
|
||||
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
|
||||
watchdog_task = asyncio.create_task(self._rvs_heartbeat_watchdog())
|
||||
|
||||
try:
|
||||
async for raw_message in ws:
|
||||
await self._handle_rvs_message(raw_message)
|
||||
finally:
|
||||
heartbeat_task.cancel()
|
||||
watchdog_task.cancel()
|
||||
|
||||
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://")
|
||||
@@ -1533,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
|
||||
|
||||
@@ -1544,7 +1657,12 @@ class ARIABridge:
|
||||
retry_delay = min(retry_delay * 2, 30)
|
||||
|
||||
async def _rvs_heartbeat(self) -> None:
|
||||
"""Sendet Heartbeats + WebSocket Pings an den RVS damit die Verbindung offen bleibt."""
|
||||
"""Sendet Heartbeats + WebSocket Pings an den RVS damit die Verbindung offen bleibt.
|
||||
|
||||
Markiert nach jedem erfolgreichen Ping `_last_heartbeat_ok` —
|
||||
`_rvs_heartbeat_watchdog` schaut darauf und killt die Verbindung
|
||||
wenn die Marke stale ist (Fallback fuer den Fall dass ping() selbst
|
||||
in einer halb-toten TCP-Verbindung ewig blockt)."""
|
||||
while True:
|
||||
await asyncio.sleep(15)
|
||||
if self.ws_rvs:
|
||||
@@ -1552,6 +1670,8 @@ class ARIABridge:
|
||||
# WebSocket Protocol-Level Ping (haelt TCP-Verbindung am Leben)
|
||||
pong = await self.ws_rvs.ping()
|
||||
await asyncio.wait_for(pong, timeout=10)
|
||||
# Erfolgreicher Pong → Watchdog-Marke updaten
|
||||
self._last_heartbeat_ok = time.monotonic()
|
||||
except Exception:
|
||||
logger.warning("[rvs] Ping fehlgeschlagen — Verbindung tot, erzwinge Reconnect")
|
||||
try:
|
||||
@@ -1568,6 +1688,45 @@ class ARIABridge:
|
||||
except Exception:
|
||||
break
|
||||
|
||||
# Heartbeat-Watchdog: wenn der letzte erfolgreiche Ping > HEARTBEAT_STALE_SEC
|
||||
# her ist (z.B. weil ws.ping() im Limbo haengt), erzwingen wir ein hartes
|
||||
# Schliessen der Verbindung. Das wirft den `async for raw_message in ws`-
|
||||
# Loop aus, der Reconnect-Loop in connect_to_rvs greift dann.
|
||||
HEARTBEAT_STALE_SEC = 60.0
|
||||
HEARTBEAT_WATCHDOG_INTERVAL_SEC = 20.0
|
||||
|
||||
async def _rvs_heartbeat_watchdog(self) -> None:
|
||||
"""Independent watchdog der den Heartbeat-Status ueberwacht und
|
||||
bei staleness die WS-Verbindung haert killt. Wird parallel zu
|
||||
`_rvs_heartbeat` gestartet, ist aber unabhaengig davon — auch wenn
|
||||
die heartbeat-Coroutine in einem await ewig haengen wuerde, laeuft
|
||||
diese hier weiter (eigene Coroutine, eigener await-Slot)."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.HEARTBEAT_WATCHDOG_INTERVAL_SEC)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not self.ws_rvs:
|
||||
return
|
||||
stale = time.monotonic() - getattr(self, "_last_heartbeat_ok", time.monotonic())
|
||||
if stale > self.HEARTBEAT_STALE_SEC:
|
||||
logger.error(
|
||||
"[rvs] Heartbeat stale (%.0fs > %.0fs) — erzwinge harten Reconnect",
|
||||
stale, self.HEARTBEAT_STALE_SEC,
|
||||
)
|
||||
ws = self.ws_rvs
|
||||
self.ws_rvs = None
|
||||
try:
|
||||
# close mit Reason — falls's hängt killen wir via Underlying-Transport
|
||||
await asyncio.wait_for(ws.close(code=1011, reason="heartbeat-stale"), timeout=3.0)
|
||||
except Exception:
|
||||
# Letzte Option: Transport direkt schliessen, das wirft den recv-Loop
|
||||
try:
|
||||
ws.transport.close() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
|
||||
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
|
||||
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
|
||||
@@ -1639,11 +1798,23 @@ class ARIABridge:
|
||||
self._next_voice_override = voice_override or None
|
||||
logger.info("[rvs] Voice fuer Antworten: %s",
|
||||
self._next_voice_override or "(Default)")
|
||||
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
|
||||
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet) —
|
||||
# plus persistente Spiegelung damit der Wert nach Bridge-Restart
|
||||
# erhalten bleibt und Diagnostic-Chats / Trigger-Replies den
|
||||
# zuletzt von der App gesetzten Speed bekommen.
|
||||
if "speed" in payload:
|
||||
try:
|
||||
speed = float(payload.get("speed", 0) or 0)
|
||||
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
|
||||
if 0.1 <= speed <= 5.0:
|
||||
self._next_speed_override = speed
|
||||
# Persistieren wenn der Wert sich gegenueber dem
|
||||
# gespeicherten geaendert hat — vermeidet voice_config.json
|
||||
# auf jeder Nachricht zu schreiben.
|
||||
if speed != getattr(self, "_persistent_xtts_speed", None):
|
||||
self._persistent_xtts_speed = speed
|
||||
asyncio.create_task(self._persist_speed_change(speed))
|
||||
else:
|
||||
self._next_speed_override = None
|
||||
except (TypeError, ValueError):
|
||||
self._next_speed_override = None
|
||||
if text:
|
||||
@@ -1677,6 +1848,12 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
if msg_type == "cancel_request":
|
||||
hard = bool(payload.get("hard"))
|
||||
if hard:
|
||||
logger.warning("[rvs] NOT-AUS — hard cancel: Diagnostic /api/cancel + Proxy /cancel-all")
|
||||
await self._cancel_via_diagnostic()
|
||||
await self._cancel_proxy_subprocesses()
|
||||
else:
|
||||
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
|
||||
await self._cancel_via_diagnostic()
|
||||
await self._emit_activity("idle", "")
|
||||
@@ -1766,6 +1943,15 @@ class ARIABridge:
|
||||
self.xtts_voice = payload["xttsVoice"]
|
||||
logger.info("[rvs] XTTS-Stimme: %s", self.xtts_voice or "default")
|
||||
changed = True
|
||||
if "xttsSpeed" in payload:
|
||||
try:
|
||||
new_speed = float(payload["xttsSpeed"])
|
||||
if 0.1 <= new_speed <= 5.0:
|
||||
self._persistent_xtts_speed = new_speed
|
||||
logger.info("[rvs] XTTS-Speed (persistent): %.2fx", new_speed)
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "whisperModel" in payload:
|
||||
new_model = payload["whisperModel"]
|
||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||
@@ -1801,6 +1987,8 @@ class ARIABridge:
|
||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||
"whisperModel": self.stt_engine.model_size,
|
||||
}
|
||||
if getattr(self, "_persistent_xtts_speed", None) is not None:
|
||||
config_data["xttsSpeed"] = self._persistent_xtts_speed
|
||||
config_data.update(getattr(self, "_f5tts_config", {}))
|
||||
config_data.update(getattr(self, "_flux_config", {}))
|
||||
with open("/shared/config/voice_config.json", "w") as f:
|
||||
@@ -2332,6 +2520,13 @@ class ARIABridge:
|
||||
future.set_result(text)
|
||||
return
|
||||
|
||||
elif msg_type == "oauth_callback":
|
||||
# RVS hat einen OAuth-Provider-Callback empfangen (z.B. Spotify
|
||||
# nach User-Authorize) und broadcastet ihn. Wir forwarden an Brain,
|
||||
# das den state-Match macht + code gegen access_token tauscht.
|
||||
asyncio.create_task(self._forward_oauth_callback(payload))
|
||||
return
|
||||
|
||||
elif msg_type == "flux_response":
|
||||
# Antwort der flux-bridge auf unseren flux_request. Erste Nachricht
|
||||
# mit state='rendering' ist nur Progress-Ping — die echte Antwort
|
||||
@@ -2709,6 +2904,50 @@ class ARIABridge:
|
||||
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||
|
||||
async def _forward_oauth_callback(self, payload: dict) -> None:
|
||||
"""Forwarded den OAuth-Callback (kommt via RVS vom RVS-HTTP-Handler)
|
||||
per HTTP an Brain. Brain hat den pending-state + macht den token-
|
||||
exchange. Fire-and-forget — bei Failure loggen wir nur."""
|
||||
service = (payload.get("service") or "").strip()
|
||||
if not service:
|
||||
logger.warning("[oauth] callback ohne service, ignoriert")
|
||||
return
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
url = f"{brain_url}/internal/oauth-callback"
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
|
||||
except Exception as e:
|
||||
return f"error: {e}", ""
|
||||
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[oauth] Forward %s → brain: %s %s", service, status, body)
|
||||
|
||||
async def _cancel_proxy_subprocesses(self) -> None:
|
||||
"""Not-Aus: ruft den proxy-internen /cancel-all Side-Channel auf
|
||||
(siehe proxy-patches/routes.js). Killt alle aktiven Claude-Code-
|
||||
Subprocesses sofort. Bridge ist auf aria-net, Proxy auch — also
|
||||
per Container-Name + Side-Channel-Port (Default 3457) erreichbar."""
|
||||
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/cancel-all"
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
req = urllib.request.Request(url, method="POST", data=b"")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
|
||||
except Exception as e:
|
||||
return f"error: {e}", ""
|
||||
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.warning("[NOT-AUS] proxy /cancel-all: %s %s", status, body)
|
||||
|
||||
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||
|
||||
@@ -2890,6 +3129,24 @@ class ARIABridge:
|
||||
# selbst wenn derselbe Name zweimal in Folge kommt.
|
||||
asyncio.create_task(self._emit_activity("tool", tool, force=True))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/agent-stream":
|
||||
# Vom Proxy gefeuert: voller Live-Stream der Claude-Code-
|
||||
# Session (assistant_text, tool_use mit Input, tool_result
|
||||
# mit truncated Output, start/end Markers). Wir leiten 1:1
|
||||
# als RVS agent_stream an Diagnostic (ARIA-Live-View) und
|
||||
# App weiter — read-only Mirror der gerade laufenden
|
||||
# ARIA-Aktivitaet.
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "agent_stream",
|
||||
"payload": data,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/flux-generate":
|
||||
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
||||
# Render-Request via RVS an die flux-bridge (Gamebox),
|
||||
@@ -3145,6 +3402,51 @@ class ARIABridge:
|
||||
self.running = False
|
||||
|
||||
|
||||
# ── File-Based Liveness Watchdog ─────────────────────────────
|
||||
#
|
||||
# Separater OS-Thread (NICHT asyncio) — schreibt periodisch eine
|
||||
# Liveness-Datei mit aktuellem Timestamp und prüft ob der asyncio-Loop
|
||||
# noch lebt. Wenn ueber LIVENESS_SELFKILL_SEC keine erfolgreiche Heart-
|
||||
# beat-Bestätigung vom RVS kam, killt der Watchdog den ganzen Prozess
|
||||
# (os._exit). Docker restart-Policy startet neu. Last-Resort fuer den
|
||||
# Fall dass weder TCP-Keepalive noch der asyncio-Heartbeat-Watchdog
|
||||
# greifen — z.B. wenn der event loop selbst korrumpiert ist.
|
||||
|
||||
LIVENESS_FILE = Path("/shared/health/bridge_alive")
|
||||
LIVENESS_CHECK_INTERVAL_SEC = 15
|
||||
LIVENESS_SELFKILL_SEC = 180 # 3 min — alle anderen Watchdogs (TCP-Keepalive
|
||||
# ~1 min, asyncio-Watchdog 60s) sollten vorher
|
||||
# greifen. Wenn nicht, ist der Prozess wirklich
|
||||
# kaputt.
|
||||
|
||||
|
||||
def _liveness_watchdog(bridge: "ARIABridge") -> None:
|
||||
try:
|
||||
LIVENESS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
while True:
|
||||
time.sleep(LIVENESS_CHECK_INTERVAL_SEC)
|
||||
# 1) Timestamp schreiben — externe Watcher koennen das pollen
|
||||
try:
|
||||
LIVENESS_FILE.write_text(str(int(time.time())))
|
||||
except Exception:
|
||||
pass
|
||||
# 2) Letzten heartbeat checken (wird vom asyncio-Loop gesetzt). Wenn
|
||||
# zu lange stale → Self-Kill. Docker-restart-Policy uebernimmt.
|
||||
last_ok = getattr(bridge, "_last_heartbeat_ok", None)
|
||||
if last_ok is None:
|
||||
continue # noch keine RVS-Verbindung gewesen, fair, kein Kill
|
||||
stale = time.monotonic() - last_ok
|
||||
if stale > LIVENESS_SELFKILL_SEC:
|
||||
sys.stderr.write(
|
||||
f"[liveness] heartbeat {int(stale)}s stale — Self-Kill "
|
||||
f"(Docker restart_policy uebernimmt)\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
os._exit(1)
|
||||
|
||||
|
||||
# ── Hauptprogramm ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -3168,6 +3470,12 @@ def main() -> None:
|
||||
logger.exception("Initialisierung fehlgeschlagen")
|
||||
sys.exit(1)
|
||||
|
||||
# Liveness-Watchdog als daemon-Thread starten (immune gegen asyncio-Hangs)
|
||||
threading.Thread(target=_liveness_watchdog, args=(bridge,),
|
||||
daemon=True, name="liveness-watchdog").start()
|
||||
logger.info("[liveness] Watchdog-Thread gestartet (selfkill nach %ds Heartbeat-Staleness)",
|
||||
LIVENESS_SELFKILL_SEC)
|
||||
|
||||
# Event-Loop starten
|
||||
try:
|
||||
asyncio.run(bridge.run())
|
||||
|
||||
+413
-100
@@ -395,18 +395,29 @@
|
||||
<div class="card" style="margin-top:12px; padding: 8px 0 0 0;">
|
||||
<div style="padding: 0 12px;">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" id="live-tab-ssh" onclick="switchLiveTab('ssh')">SSH Terminal</button>
|
||||
<button class="tab-btn active" id="live-tab-aria" onclick="switchLiveTab('aria')">ARIA Live</button>
|
||||
<button class="tab-btn" id="live-tab-desktop" onclick="switchLiveTab('desktop')">Desktop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#080810; border:1px solid #1E1E2E; border-radius:0 0 6px 6px; position:relative;">
|
||||
<!-- SSH Terminal -->
|
||||
<div id="live-ssh" style="height:350px; padding:4px;">
|
||||
<div id="live-ssh-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;">
|
||||
<button class="btn" onclick="startLiveSSH()" id="btn-live-ssh" style="padding:4px 12px;font-size:11px;">Verbinden</button>
|
||||
<span id="live-ssh-status" style="font-size:11px;color:#8888AA;">Nicht verbunden</span>
|
||||
<!-- ARIA Live (read-only Mirror der Claude-Code-Session) -->
|
||||
<div id="live-aria" style="height:350px; padding:4px; display:flex; flex-direction:column;">
|
||||
<div id="live-aria-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;flex-shrink:0;">
|
||||
<span id="live-aria-status" style="font-size:11px;color:#8888AA;flex:1;">Idle — warte auf ARIA-Aktivitaet</span>
|
||||
<button class="btn" onclick="clearAriaLive()" style="padding:4px 12px;font-size:11px;" title="Live-Mitschrift leeren">Leeren</button>
|
||||
<label style="font-size:11px;color:#8888AA;display:flex;align-items:center;gap:4px;cursor:pointer;" title="Bei jeder neuen Zeile ans Ende scrollen">
|
||||
<input type="checkbox" id="live-aria-autoscroll" checked style="margin:0;"> Auto-Scroll
|
||||
</label>
|
||||
<button class="btn" onclick="ariaPanicStop()"
|
||||
style="padding:4px 14px;font-size:11px;background:#FF3B30;color:#fff;border-color:#FF3B30;font-weight:bold;"
|
||||
title="NOT-AUS: killt alle aktiven Claude-Code-Subprocesses sofort">
|
||||
⛔ Not-Aus
|
||||
</button>
|
||||
</div>
|
||||
<div id="live-aria-stream"
|
||||
style="flex:1;overflow-y:auto;background:#040408;font-family:'Courier New',monospace;font-size:11px;line-height:1.4;color:#C0C0D0;padding:6px 8px;border-top:1px solid #1E1E2E;">
|
||||
<div style="color:#555570;font-style:italic;">Sobald ARIA denkt oder ein Tool nutzt, taucht es hier in Echtzeit auf.</div>
|
||||
</div>
|
||||
<div id="live-ssh-term" style="height:calc(100% - 32px);"></div>
|
||||
</div>
|
||||
<!-- Desktop Viewer -->
|
||||
<div id="live-desktop" style="height:350px; display:none; position:relative;">
|
||||
@@ -669,6 +680,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Apps -->
|
||||
<div class="settings-section">
|
||||
<h2>OAuth-Apps (Spotify, Google, GitHub, Strava, Microsoft, ...)</h2>
|
||||
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||
Trag pro Service `client_id` + `client_secret` ein (aus dem Developer-Dashboard
|
||||
des Providers). RVS stellt die Callback-URL bereit — die musst Du EINMAL pro
|
||||
Service im Provider-Dashboard als gueltige Redirect-URI eintragen.
|
||||
Danach kann ARIA per `oauth_authorize`-Tool eine Auth-URL bauen; Stefan klickt,
|
||||
autorisiert, ARIA bekommt den Token automatisch.
|
||||
</div>
|
||||
<div style="font-size:11px;color:#666680;margin-bottom:8px;" id="oauth-callback-hint">
|
||||
Lade Callback-URL...
|
||||
</div>
|
||||
<div class="card" style="max-width:780px;">
|
||||
<div id="oauth-services-list" style="display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="color:#555570;font-style:italic;">Lade Services...</div>
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn secondary" onclick="loadOAuthServices()" style="padding:6px 14px;font-size:12px;">
|
||||
↻ Neu laden
|
||||
</button>
|
||||
<div style="color:#666680;font-size:10px;">
|
||||
client_secret wird verschlüsselt persistiert (file-mode 0600). Nicht in git, nicht im Repo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Whisper (STT) -->
|
||||
<div class="settings-section">
|
||||
<h2>Whisper (Spracherkennung)</h2>
|
||||
@@ -1412,6 +1451,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'agent_stream') {
|
||||
appendAriaStreamEvent(msg.payload || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'voice_preview_audio') {
|
||||
const statusEl = document.getElementById('voice-preview-status');
|
||||
const audio = document.getElementById('voice-preview-audio');
|
||||
@@ -1555,8 +1599,8 @@
|
||||
return;
|
||||
}
|
||||
// core_auth WS-Event entfernt — aria-core ist raus.
|
||||
// Live SSH + Desktop
|
||||
if (msg.type?.startsWith('live_ssh_')) { handleLiveSSH(msg); return; }
|
||||
// SSH-Terminal entfernt — durch ARIA-Live-Mirror ersetzt.
|
||||
// Desktop bleibt.
|
||||
if (msg.type === 'desktop_status') { handleDesktop(msg); return; }
|
||||
|
||||
if (msg.type === 'term_ready') {
|
||||
@@ -1598,26 +1642,26 @@
|
||||
showDockerLogs(msg);
|
||||
return;
|
||||
}
|
||||
// Chat-History (nach F5 / Reconnect)
|
||||
// Chat-History (nach F5 / Reconnect) — IN BEIDE Boxen rendern.
|
||||
// Vorher: nur chatBox bekam die Replay, die Vollbild-Box blieb leer
|
||||
// → bei Reload aus dem FS-Modus sah es so aus als ob die letzten
|
||||
// Bubbles weg waeren. Live-addChat schreibt schon korrekt in beide,
|
||||
// der Reload-Pfad zog nicht mit.
|
||||
if (msg.type === 'chat_history') {
|
||||
chatBox.innerHTML = '';
|
||||
const boxes = [chatBox, document.getElementById('chat-box-fs')].filter(Boolean);
|
||||
for (const b of boxes) b.innerHTML = '';
|
||||
if (msg.messages && msg.messages.length > 0) {
|
||||
for (const m of msg.messages) {
|
||||
if (m.type === 'aria_file') {
|
||||
// ARIA-Datei-Bubble rekonstruieren (statt addAriaFile damit
|
||||
// kein Auto-Scroll-Race waehrend des Bulk-Loads)
|
||||
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size });
|
||||
// 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;
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = `chat-msg ${m.type}`;
|
||||
if (m.ts) el.dataset.ts = String(m.ts);
|
||||
// [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
|
||||
// (gleicher Replace wie in addChat — sonst sieht man nach F5 nur Text-Pfade)
|
||||
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'">`;
|
||||
});
|
||||
@@ -1625,10 +1669,16 @@
|
||||
const trashBtn = m.ts
|
||||
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
|
||||
: '';
|
||||
el.innerHTML = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
|
||||
chatBox.appendChild(el);
|
||||
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);
|
||||
}
|
||||
chatBox.scrollTop = chatBox.scrollHeight;
|
||||
}
|
||||
for (const b of boxes) b.scrollTop = b.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2962,96 +3012,133 @@
|
||||
|
||||
// ── ARIA Live-Ansicht (SSH + Desktop) ──────────────────
|
||||
|
||||
let liveSshTerm = null;
|
||||
let liveSshFit = null;
|
||||
|
||||
function switchLiveTab(tab) {
|
||||
document.getElementById('live-ssh').style.display = tab === 'ssh' ? 'block' : 'none';
|
||||
document.getElementById('live-aria').style.display = tab === 'aria' ? 'flex' : 'none';
|
||||
document.getElementById('live-desktop').style.display = tab === 'desktop' ? 'block' : 'none';
|
||||
document.getElementById('live-tab-ssh').className = 'tab-btn' + (tab === 'ssh' ? ' active' : '');
|
||||
document.getElementById('live-tab-aria').className = 'tab-btn' + (tab === 'aria' ? ' active' : '');
|
||||
document.getElementById('live-tab-desktop').className = 'tab-btn' + (tab === 'desktop' ? ' active' : '');
|
||||
if (tab === 'ssh' && liveSshTerm && liveSshFit) {
|
||||
setTimeout(() => liveSshFit.fit(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function startLiveSSH() {
|
||||
const statusEl = document.getElementById('live-ssh-status');
|
||||
const btn = document.getElementById('btn-live-ssh');
|
||||
|
||||
// Wenn schon verbunden, trennen
|
||||
if (liveSshTerm && liveSshTerm._sshConnected) {
|
||||
send({ action: 'live_ssh_close' });
|
||||
statusEl.textContent = 'Getrennt';
|
||||
statusEl.style.color = '#FF6B6B';
|
||||
btn.textContent = 'Verbinden';
|
||||
liveSshTerm._sshConnected = false;
|
||||
return;
|
||||
// ── ARIA Live (read-only Mirror der Claude-Code-Session) ──────
|
||||
//
|
||||
// Empfaengt agent_stream Events vom RVS (Proxy → Bridge → RVS → wir).
|
||||
// Rendert sie als monospace-Liste — Tool-Calls in cyan, Tool-Results
|
||||
// in grau (truncated), ARIA-Text in weiss, Thinking kursiv. Auto-Scroll
|
||||
// bleibt am unteren Rand kleben solange der User nicht hochgescrollt hat.
|
||||
// Not-Aus killt via Bridge → Proxy-Side-Channel alle Subprocesses.
|
||||
function _ariaStreamEl() { return document.getElementById('live-aria-stream'); }
|
||||
function _ariaStatusEl() { return document.getElementById('live-aria-status'); }
|
||||
function _ariaIsAtBottom() {
|
||||
const el = _ariaStreamEl();
|
||||
if (!el) return true;
|
||||
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 24;
|
||||
}
|
||||
|
||||
statusEl.textContent = 'Verbinde...';
|
||||
statusEl.style.color = '#FFD60A';
|
||||
|
||||
function initSSHTerm() {
|
||||
const container = document.getElementById('live-ssh-term');
|
||||
if (!liveSshTerm) {
|
||||
liveSshTerm = new Terminal({
|
||||
theme: { background: '#080810', foreground: '#E0E0F0', cursor: '#0096FF' },
|
||||
fontFamily: 'Courier New, monospace',
|
||||
fontSize: 12,
|
||||
cursorBlink: true,
|
||||
});
|
||||
liveSshFit = new FitAddon.FitAddon();
|
||||
liveSshTerm.loadAddon(liveSshFit);
|
||||
liveSshTerm.open(container);
|
||||
liveSshFit.fit();
|
||||
liveSshTerm.onData((data) => {
|
||||
send({ action: 'live_ssh_input', data });
|
||||
});
|
||||
function _ariaMaybeScroll() {
|
||||
if (!document.getElementById('live-aria-autoscroll')?.checked) return;
|
||||
const el = _ariaStreamEl();
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
liveSshTerm.clear();
|
||||
send({ action: 'live_ssh_start' });
|
||||
// Truncate UI: groessere Backlogs koennen viele MB werden. Wir halten
|
||||
// max 2000 Zeilen — beim Ueberlauf den oberen Block wegwerfen.
|
||||
const ARIA_MAX_LINES = 2000;
|
||||
function _ariaTrimBacklog() {
|
||||
const el = _ariaStreamEl();
|
||||
if (!el) return;
|
||||
while (el.childElementCount > ARIA_MAX_LINES) {
|
||||
el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
if (typeof Terminal === 'undefined') {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js';
|
||||
s.onload = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js';
|
||||
s2.onload = () => initSSHTerm();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
function _ariaTimePrefix(ts) {
|
||||
try {
|
||||
const d = ts ? new Date(ts) : new Date();
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
function _ariaEsc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function _ariaPushLine(html, color, opts = {}) {
|
||||
const el = _ariaStreamEl();
|
||||
if (!el) return;
|
||||
const wasAtBottom = _ariaIsAtBottom();
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = `color:${color};${opts.style||''}`;
|
||||
row.innerHTML = html;
|
||||
// Erste statische "Sobald ARIA..."-Zeile beim ersten Event entfernen
|
||||
const placeholder = el.querySelector('div[style*="italic"]');
|
||||
if (placeholder && el.childElementCount === 1) el.removeChild(placeholder);
|
||||
el.appendChild(row);
|
||||
_ariaTrimBacklog();
|
||||
if (wasAtBottom) _ariaMaybeScroll();
|
||||
}
|
||||
function appendAriaStreamEvent(p) {
|
||||
const t = _ariaTimePrefix(p.ts);
|
||||
const kind = p.kind || '';
|
||||
if (kind === 'start') {
|
||||
_ariaPushLine(
|
||||
`<span style="color:#444460;">━━━ ${t} session start (${_ariaEsc(p.model || 'unknown')}) ━━━</span>`,
|
||||
'#444460',
|
||||
);
|
||||
const st = _ariaStatusEl(); if (st) { st.textContent = 'ARIA aktiv...'; st.style.color = '#34C759'; }
|
||||
} else if (kind === 'end') {
|
||||
const reason = p.reason || '?';
|
||||
const codePart = (p.code !== undefined && p.code !== null) ? ` code=${_ariaEsc(p.code)}` : '';
|
||||
const errPart = p.error ? ` err=${_ariaEsc(String(p.error).slice(0,120))}` : '';
|
||||
_ariaPushLine(
|
||||
`<span style="color:#444460;">━━━ ${t} session end (${_ariaEsc(reason)}${codePart}${errPart}) ━━━</span>`,
|
||||
'#444460',
|
||||
);
|
||||
const st = _ariaStatusEl(); if (st) { st.textContent = 'Idle'; st.style.color = '#8888AA'; }
|
||||
} else if (kind === 'text') {
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> ${_ariaEsc(p.text || '')}`,
|
||||
'#D0D0E0',
|
||||
{ style: 'white-space:pre-wrap;word-break:break-word;' },
|
||||
);
|
||||
} else if (kind === 'thinking') {
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> <span style="font-style:italic;color:#888866;">💭 ${_ariaEsc(p.text || '')}</span>`,
|
||||
'#888866',
|
||||
{ style: 'white-space:pre-wrap;word-break:break-word;' },
|
||||
);
|
||||
} else if (kind === 'tool_use') {
|
||||
const name = _ariaEsc(p.name || '?');
|
||||
const inp = _ariaEsc(p.input || '');
|
||||
const tail = p.inputTruncatedBytes ? `<span style="color:#777799;"> ...(+${p.inputTruncatedBytes} bytes)</span>` : '';
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> <span style="color:#0096FF;">▶ ${name}</span> <span style="color:#8888AA;">${inp}${tail}</span>`,
|
||||
'#C0C0D0',
|
||||
{ style: 'white-space:pre-wrap;word-break:break-word;' },
|
||||
);
|
||||
} else if (kind === 'tool_result') {
|
||||
const isError = p.isError === true;
|
||||
const head = isError ? '<span style="color:#FF6B6B;">✗ result (ERROR)</span>' : '<span style="color:#34C759;">✓ result</span>';
|
||||
const tail = p.truncatedBytes ? `<span style="color:#777799;"> ...(+${p.truncatedBytes} bytes)</span>` : '';
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> ${head}<br><span style="color:#9090A0;white-space:pre-wrap;display:block;padding-left:14px;border-left:2px solid #2A2A3E;">${_ariaEsc(p.content || '')}${tail}</span>`,
|
||||
'#9090A0',
|
||||
);
|
||||
} else {
|
||||
initSSHTerm();
|
||||
_ariaPushLine(
|
||||
`<span style="color:#777799;">[${t}]</span> <span style="color:#AAAACC;">${_ariaEsc(kind)}: ${_ariaEsc(JSON.stringify(p))}</span>`,
|
||||
'#AAAACC',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLiveSSH(msg) {
|
||||
const statusEl = document.getElementById('live-ssh-status');
|
||||
const btn = document.getElementById('btn-live-ssh');
|
||||
if (msg.type === 'live_ssh_data' && liveSshTerm) {
|
||||
const raw = atob(msg.data);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
liveSshTerm.write(bytes);
|
||||
} else if (msg.type === 'live_ssh_connected') {
|
||||
statusEl.textContent = 'Verbunden mit aria-wohnung';
|
||||
statusEl.style.color = '#34C759';
|
||||
btn.textContent = 'Trennen';
|
||||
if (liveSshTerm) liveSshTerm._sshConnected = true;
|
||||
} else if (msg.type === 'live_ssh_error') {
|
||||
statusEl.textContent = msg.error || 'Fehler';
|
||||
statusEl.style.color = '#FF6B6B';
|
||||
btn.textContent = 'Verbinden';
|
||||
if (liveSshTerm) liveSshTerm._sshConnected = false;
|
||||
} else if (msg.type === 'live_ssh_closed') {
|
||||
statusEl.textContent = 'Getrennt';
|
||||
statusEl.style.color = '#8888AA';
|
||||
btn.textContent = 'Verbinden';
|
||||
if (liveSshTerm) liveSshTerm._sshConnected = false;
|
||||
function clearAriaLive() {
|
||||
const el = _ariaStreamEl();
|
||||
if (el) el.innerHTML = '<div style="color:#555570;font-style:italic;">Geleert.</div>';
|
||||
}
|
||||
function ariaPanicStop() {
|
||||
if (!confirm('Wirklich NOT-AUS? Alle aktiven Claude-Subprocesses werden sofort gekillt.')) return;
|
||||
send({ action: 'aria_panic_stop' });
|
||||
_ariaPushLine(
|
||||
`<span style="color:#FF3B30;font-weight:bold;">━━━ ${_ariaTimePrefix()} ⛔ NOT-AUS ausgeloest ━━━</span>`,
|
||||
'#FF3B30',
|
||||
);
|
||||
}
|
||||
|
||||
function checkDesktop() {
|
||||
@@ -3089,11 +3176,12 @@
|
||||
const oc = b.getAttribute('onclick') || '';
|
||||
if (oc.includes(`'${tab}'`)) b.classList.add('active');
|
||||
});
|
||||
// Einstellungen: Config + QR laden
|
||||
// Einstellungen: Config + QR + OAuth-Apps laden
|
||||
if (tab === 'settings') {
|
||||
send({ action: 'get_voice_config' });
|
||||
loadRuntimeConfig();
|
||||
loadOnboardingQR();
|
||||
loadOAuthServices();
|
||||
} else if (tab === 'brain') {
|
||||
loadBrainStatus();
|
||||
loadBrainMemoryList();
|
||||
@@ -3751,6 +3839,231 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── OAuth-Apps UI ─────────────────────────────────────────
|
||||
//
|
||||
// Stefan traegt pro Service client_id + client_secret ein. RVS hat eine
|
||||
// feste Callback-URL die Stefan im Provider-Dashboard registrieren muss.
|
||||
// Status pro Service: configured / authenticated / expires_in.
|
||||
function _ofmt(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function _oExpiryText(secs) {
|
||||
if (secs == null) return '';
|
||||
if (secs <= 0) return 'abgelaufen (refresh beim naechsten Call)';
|
||||
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`;
|
||||
}
|
||||
async function loadOAuthServices() {
|
||||
const listEl = document.getElementById('oauth-services-list');
|
||||
const hintEl = document.getElementById('oauth-callback-hint');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div style="color:#555570;font-style:italic;">Lade Services...</div>';
|
||||
try {
|
||||
const [svcRes, appsRes, rcRes] = await Promise.all([
|
||||
fetch('/api/brain/oauth/services'),
|
||||
fetch('/api/brain/oauth/apps'),
|
||||
fetch('/api/runtime-config'),
|
||||
]);
|
||||
const svc = await svcRes.json();
|
||||
const apps = await appsRes.json();
|
||||
const rc = await rcRes.json();
|
||||
const host = rc.RVS_HOST || '';
|
||||
const port = rc.RVS_PORT || '443';
|
||||
const tls = String(rc.RVS_TLS) !== 'false';
|
||||
const scheme = tls ? 'https' : 'http';
|
||||
const portPart = ((tls && port === '443') || (!tls && port === '80')) ? '' : ':' + port;
|
||||
const cbBase = host ? `${scheme}://${host}${portPart}/oauth/callback/` : '<RVS_HOST nicht gesetzt>';
|
||||
if (hintEl) {
|
||||
hintEl.innerHTML = host
|
||||
? `<b>Callback-URL pro Service</b> (im Provider-Dashboard eintragen): <code style="color:#0096FF;">${_ofmt(cbBase)}<service></code>`
|
||||
: `⚠ RVS_HOST nicht gesetzt — OAuth-Callbacks koennen nicht funktionieren. Setze RVS_HOST in der .env auf den oeffentlich erreichbaren Hostname.`;
|
||||
}
|
||||
const services = svc.services || [];
|
||||
const appDetails = apps.apps || {};
|
||||
const knownDefaults = apps.defaults || [];
|
||||
// Zusammenfuehren: jeder Service der entweder in services oder Defaults vorkommt
|
||||
const allServices = Array.from(new Set([
|
||||
...services.map(s => s.service),
|
||||
...knownDefaults,
|
||||
])).sort();
|
||||
listEl.innerHTML = '';
|
||||
for (const svcName of allServices) {
|
||||
const s = services.find(x => x.service === svcName) || { service: svcName, configured: false, authenticated: false };
|
||||
const app = appDetails[svcName] || {};
|
||||
const card = document.createElement('div');
|
||||
const statusColor = s.authenticated ? '#34C759' : (s.configured ? '#FFD60A' : '#666680');
|
||||
const statusText = s.authenticated
|
||||
? `✅ verbunden${s.expiresInSec != null ? ` · Token noch ${_oExpiryText(s.expiresInSec)} gueltig` : ''}${s.hasRefresh ? ' · refresh ok' : ' · KEIN refresh_token'}`
|
||||
: (s.configured ? '🟡 konfiguriert, nicht autorisiert' : '⚫ noch nicht konfiguriert');
|
||||
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>
|
||||
<input type="text" id="oauth-cid-${_ofmt(svcName)}" value="${_ofmt(app.client_id || '')}" placeholder="aus dem Provider-Dashboard"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
|
||||
<label style="color:#8888AA;font-size:11px;">client_secret: ${app.has_client_secret ? '<span style="color:#34C759;">(gespeichert — leer lassen zum Behalten)</span>' : '<span style="color:#FF6B6B;">(fehlt)</span>'}</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="password" id="oauth-sec-${_ofmt(svcName)}" placeholder="${app.has_client_secret ? 'leer lassen oder neuen eingeben' : 'aus dem Provider-Dashboard'}"
|
||||
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"' : ''}>
|
||||
Autorisieren ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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) {
|
||||
// (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(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
alert('Speichern fehlgeschlagen: ' + t);
|
||||
return;
|
||||
}
|
||||
loadOAuthServices();
|
||||
} catch (e) {
|
||||
alert('Speichern fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
async function authorizeOAuth(service) {
|
||||
try {
|
||||
const r = await fetch('/api/brain/oauth/authorize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
alert('Authorize fehlgeschlagen: ' + t);
|
||||
return;
|
||||
}
|
||||
const data = await r.json();
|
||||
// Authorize-URL in neuem Tab oeffnen — Stefan kann dann beim Provider zustimmen
|
||||
window.open(data.url, '_blank', 'noopener,noreferrer');
|
||||
// Status nach ein paar Sekunden refreshen — Provider redirect → RVS → Brain
|
||||
setTimeout(loadOAuthServices, 8000);
|
||||
} catch (e) {
|
||||
alert('Authorize fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
async function revokeOAuth(service) {
|
||||
if (!confirm(`Token fuer ${service} wirklich loeschen? ARIA muss danach neu autorisiert werden.`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/brain/oauth/' + service + '/revoke', { method: 'POST' });
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
alert('Revoke fehlgeschlagen: ' + t);
|
||||
return;
|
||||
}
|
||||
loadOAuthServices();
|
||||
} catch (e) {
|
||||
alert('Revoke fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function distillNow() {
|
||||
if (!confirm('Destillat manuell auslösen?\n\nDie ältesten Turns werden zu fact-Memories verdichtet — kostet einen Claude-Call.')) return;
|
||||
try {
|
||||
|
||||
@@ -633,6 +633,11 @@ function connectRVS(forcePlain) {
|
||||
tool: msg.payload?.tool || msg.tool || "",
|
||||
});
|
||||
}
|
||||
} else if (msg.type === "agent_stream") {
|
||||
// Voller Live-Stream der Claude-Code-Session (assistant_text +
|
||||
// tool_use mit Input + tool_result mit truncated Output). Geht
|
||||
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
|
||||
broadcast({ type: "agent_stream", payload: msg.payload });
|
||||
} else if (msg.type === "memory_saved") {
|
||||
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
||||
const m = msg.payload || {};
|
||||
@@ -1887,6 +1892,18 @@ wss.on("connection", (ws) => {
|
||||
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||
} else if (msg.action === "aria_panic_stop") {
|
||||
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
||||
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
||||
log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
// RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
|
||||
// den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
|
||||
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
|
||||
} else if (msg.action === "voice_upload") {
|
||||
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
|
||||
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
|
||||
|
||||
+17
-1
@@ -12,7 +12,7 @@ services:
|
||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||
@@ -67,6 +67,22 @@ services:
|
||||
- QDRANT_PORT=6333
|
||||
- PROXY_URL=http://proxy:3456
|
||||
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
||||
# Read-Timeout fuer den Proxy-Call. Hoch, weil Agent-Loops (Pentests
|
||||
# etc.) auch eine Stunde+ dauern koennen. Der Proxy seinerseits hat
|
||||
# einen Idle-Watchdog (Default 20min Inaktivitaet) der den Subprocess
|
||||
# killt, der dann seinen close-Event sendet — Brain bekommt also
|
||||
# immer was zurueck, auch bei wirklich haengenden Subprozessen.
|
||||
# Connect/Write/Pool sind klein (10/30/10s) damit toter Proxy
|
||||
# schnell erkannt wird (siehe proxy_client.py).
|
||||
- PROXY_TIMEOUT_SEC=${PROXY_TIMEOUT_SEC:-86400}
|
||||
# OAuth-Callback-URL Bestandteile. Brain baut daraus
|
||||
# https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service} als
|
||||
# redirect_uri fuer Provider wie Spotify/Google/etc. RVS_PORT_PUBLIC
|
||||
# ist der nach aussen exposed Port (= TLS-Port hinter Caddy/Nginx),
|
||||
# nicht der interne RVS-Container-Port.
|
||||
- RVS_HOST=${RVS_HOST:-}
|
||||
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
volumes:
|
||||
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
|
||||
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
|
||||
|
||||
+10
-3
@@ -10,9 +10,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch CUDA-Wheels zuerst, damit diffusers nicht CPU-Torch zieht.
|
||||
# Versionsmatrix wie bei f5tts gehalten (cu121, Torch 2.3.1) — gleicher
|
||||
# Treiber-Footprint, gleicher HF-Cache-Pfad.
|
||||
RUN pip3 install --no-cache-dir torch==2.3.1 \
|
||||
# Torch 2.5+ ist Pflicht: aktuelle transformers (4.50+, von diffusers
|
||||
# transitiv reingezogen) registriert in integrations/moe.py einen
|
||||
# custom_op mit String-Forward-References (`input: 'torch.Tensor'`).
|
||||
# Erst torch 2.5's infer_schema kann die aufloesen — 2.4.1 crasht mit
|
||||
# "Parameter input has unsupported type torch.Tensor" beim Import von
|
||||
# diffusers.pipelines.flux.pipeline_flux.
|
||||
# torchvision wird von den CLIP-/Siglip-ImageProcessors verlangt.
|
||||
# cu121 bleibt — passt zum CUDA 12.2 Base-Image.
|
||||
RUN pip3 install --no-cache-dir \
|
||||
torch==2.5.1 torchvision==0.20.1 \
|
||||
--index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -377,6 +377,20 @@ Skills mit Tool-Use.
|
||||
- [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block
|
||||
- [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung
|
||||
|
||||
### Brain-Timeouts + Subprocess-Cleanup
|
||||
|
||||
- [x] **Brain-Timeout nach exakt 20min trotz aktiver ARIA**: `httpx.Client` im `proxy_client.py` hatte einen 1200s-Read-Timeout — der gleiche Wert den wir Tage zuvor am Proxy auf 24h hochgezogen hatten, aber im Brain uebersehen. Bei langen Pentests timed Brain raus obwohl der Proxy-Subprocess noch fleissig Events emittierte. Fix: `PROXY_TIMEOUT_SEC=86400` Env in der Compose, plus split-Timeouts in `httpx.Timeout(connect=10, read=86400, write=30, pool=10)` — toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen 24h laufen
|
||||
- [x] **Verwaiste Claude-Subprocesses nach Brain-Disconnect**: `handleNonStreamingResponse` in `routes.js` hatte keinen `res.on("close")` (nur der Streaming-Branch). Wenn Brain die Verbindung gekappt hat (z.B. nach Timeout), lief der Claude-Subprocess weiter ohne dass noch jemand lauschte — Ressourcen-Leak. Fix: `res.on("close")` mit `isComplete`-Flag, Subprocess wird sofort gekillt bei Client-Disconnect
|
||||
- [x] **Conversation-Inkonsistenz bei Brain-Exception**: `agent.chat()` fuegte den User-Turn ein BEVOR der Proxy-Call lief — bei Exception blieb der User-Turn ohne Assistant-Pair stehen, naechster Brain-Call sah `user → user` als letzte zwei Turns und konnte mit Tool-Calls fehlschlagen. Fix: try/except um den Tool-Loop, bei Exception wird ein Error-Marker (`[Fehler: ...]`) als Assistant-Turn geschrieben — Conversation bleibt konsistent
|
||||
|
||||
### OAuth-Pipeline (Spotify / Google / GitHub / Strava / Microsoft)
|
||||
|
||||
- [x] **Externe OAuth2-Provider per RVS-Callback**: ARIA brauchte Tokens fuer Spotify-Skill — bisher `redirect_uri=http://localhost:...` was vom Handy aus nicht erreichbar war, Stefan musste den Code manuell aus der URL kopieren (fragil, OAuth-Codes sind ~10min gueltig). Loesung: RVS-Server hat jetzt einen HTTP-Listener (selber Port wie WebSocket, hybrid via `http.createServer` + `wss.handleUpgrade`). Provider redirected an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet `oauth_callback`-Message → aria-bridge forwarded an Brain → Brain matched `state` (CSRF-Schutz), tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json` (file-mode 0600). Token-Refresh laeuft automatisch wenn <60s Restzeit
|
||||
- [x] **Brain-Tools fuer ARIA**: `oauth_authorize(service, scopes?)` baut Auth-URL + speichert pending state, `oauth_get_token(service)` liefert aktuelles access_token (refresh wenn noetig), `oauth_revoke(service)` loescht. Skills nutzen diese statt selber Auth-Flow zu machen
|
||||
- [x] **Generische Provider-Configs**: `DEFAULT_PROVIDERS` in `oauth.py` deckt Spotify, Google, GitHub, Strava, Microsoft mit ihren Quirks ab (Basic-Auth vs Body-Auth, Accept-Header fuer GitHub, `access_type=offline` fuer Google, etc.). Custom-Provider via `oauth_apps.json` moeglich
|
||||
- [x] **Diagnostic-UI**: Einstellungen → OAuth-Apps. Pro Service Karte mit Status (verbunden/konfiguriert/leer), `client_id` + `client_secret` (Passwort-Toggle), Speichern + Autorisieren-Buttons. Autorisieren oeffnet Provider-Auth in neuem Tab; nach 8s Auto-Refresh
|
||||
- [x] **Schoene Browser-Antwort vom RVS**: nach Callback bekommt der User eine Dark-Mode-HTML-Seite (✅ "OAuth erfolgreich, du kannst Tab schliessen — ARIA hat den Zugang erhalten") mit 4s Auto-Close — kein nackter JSON-Response
|
||||
|
||||
## Offen
|
||||
|
||||
### App Features
|
||||
@@ -389,3 +403,4 @@ Skills mit Tool-Use.
|
||||
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
|
||||
- [ ] Heartbeat (periodische Selbst-Checks)
|
||||
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
|
||||
- [ ] **Subprocess-Resume nach Kill/Timeout (Variante A — halb-automatisch)**: bei Idle-Timeout oder Brain-Disconnect ist die ARIA-Session weg (in-memory state des Claude-Code-Subprozesses, alle Tool-Outputs, Files-Reads). Stefan muss heute manuell *"weitermachen"* sagen, ARIA improvisiert aus dem Conversation-Window was sie noch weiss. Variante A: agent_stream-Events zusaetzlich in einer JSONL persistieren, beim naechsten Brain-Call die letzten N Events als „Resume-Context" in den System-Prompt einbauen — ARIA weiss dann konkret welche Tool-Calls zuletzt liefen und kann sauber fortsetzen. Aufwand ~1-2h. Nur angehen wenn die 24h-Timeouts (Commit 0887674) wirklich nochmal triggern
|
||||
|
||||
+238
-16
@@ -7,6 +7,10 @@
|
||||
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
|
||||
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
|
||||
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
|
||||
* - Voller Live-Stream (assistant_text, tool_use mit input, tool_result)
|
||||
* geht an ARIA_STREAM_HOOK_URL → Bridge → RVS `agent_stream` → Diagnostic
|
||||
* "ARIA Live"-View (TeamViewer-mäßiger Mirror der Claude-Code-Session).
|
||||
* - Subprocess-Tracking + POST /v1/cancel-all fuer Not-Aus (Hard-Kill).
|
||||
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
|
||||
* der Brain-Call NICHT ab.
|
||||
*
|
||||
@@ -21,42 +25,180 @@ import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js
|
||||
|
||||
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|
||||
|| "http://aria-bridge:8090/internal/agent-activity";
|
||||
const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
|
||||
|| "http://aria-bridge:8090/internal/agent-stream";
|
||||
|
||||
// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
|
||||
// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
|
||||
// "...(N bytes truncated)" Hinweis. Vollstaendiger Output bleibt im Brain
|
||||
// und wird normal verarbeitet, das hier ist NUR fuer den Live-Mirror.
|
||||
const TOOL_RESULT_MAX_CHARS = 4096;
|
||||
const TOOL_INPUT_MAX_CHARS = 2048;
|
||||
|
||||
// Idle-Timeout: Subprocess wird gekillt wenn ueber IDLE_TIMEOUT_MS keine
|
||||
// Aktivitaet (message/content_delta) ankommt. Loest das alte Hard-Timeout-
|
||||
// Problem fuer lange Agent-Sessions (Pentests etc.) — ARIA darf ewig
|
||||
// arbeiten solange sie regelmaessig was emittiert, aber wenn der Subprocess
|
||||
// hartnaeckig haengt, schlaegt der Watchdog trotzdem zu.
|
||||
// Default 20min Idle. Override via env ARIA_IDLE_TIMEOUT_MS.
|
||||
// 0 = deaktiviert (nicht empfohlen).
|
||||
const IDLE_TIMEOUT_MS = parseInt(process.env.ARIA_IDLE_TIMEOUT_MS || "1200000", 10);
|
||||
|
||||
/**
|
||||
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits,
|
||||
* keine Fehler nach oben. Logged Fehler still.
|
||||
* Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
|
||||
* nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
|
||||
*/
|
||||
function _emitToolEvent(toolName) {
|
||||
if (!toolName) return;
|
||||
function _postJson(url, body) {
|
||||
try {
|
||||
const u = new URL(TOOL_HOOK_URL);
|
||||
const body = JSON.stringify({ tool: String(toolName) });
|
||||
const u = new URL(url);
|
||||
const data = JSON.stringify(body);
|
||||
const req = http.request({
|
||||
method: "POST",
|
||||
hostname: u.hostname,
|
||||
port: u.port || 80,
|
||||
path: u.pathname,
|
||||
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
||||
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
|
||||
timeout: 2000,
|
||||
}, (res) => { res.resume(); });
|
||||
req.on("error", () => {});
|
||||
req.on("timeout", () => req.destroy());
|
||||
req.write(body);
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (_) { /* niemals weiterwerfen */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
|
||||
* kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live.
|
||||
* Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
|
||||
*/
|
||||
function _attachToolHook(subprocess) {
|
||||
function _emitToolEvent(toolName) {
|
||||
if (!toolName) return;
|
||||
_postJson(TOOL_HOOK_URL, { tool: String(toolName) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pusht ein Stream-Event an die Bridge (neuer "ARIA Live"-Pfad).
|
||||
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
|
||||
*/
|
||||
function _emitStreamEvent(requestId, kind, fields) {
|
||||
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
|
||||
}
|
||||
|
||||
function _truncate(str, max) {
|
||||
if (typeof str !== "string") str = String(str ?? "");
|
||||
if (str.length <= max) return { text: str, truncatedBytes: 0 };
|
||||
return { text: str.slice(0, max), truncatedBytes: str.length - max };
|
||||
}
|
||||
|
||||
// ── Subprocess-Tracking fuer Not-Aus ──────────────────────────
|
||||
// requestId → ClaudeSubprocess. Eintraege werden beim close/result-Event
|
||||
// wieder entfernt. /v1/cancel-all iteriert und ruft .kill() auf jeden.
|
||||
const _activeSubprocesses = new Map();
|
||||
function _trackSubprocess(requestId, subprocess) {
|
||||
_activeSubprocesses.set(requestId, subprocess);
|
||||
const cleanup = () => _activeSubprocesses.delete(requestId);
|
||||
subprocess.on("close", cleanup);
|
||||
subprocess.on("error", cleanup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle-Watchdog: killt den Subprocess wenn ueber IDLE_TIMEOUT_MS hinweg
|
||||
* keine message/content_delta Events ankommen. Wird beim Start gesetzt,
|
||||
* bei jedem Event reset, bei close/error/result gestoppt.
|
||||
*
|
||||
* Stream-Event 'end' wird durch den normalen close-Listener im Handler
|
||||
* gefeuert — wir muessen hier nichts extra emittieren.
|
||||
*/
|
||||
function _attachIdleWatchdog(subprocess, requestId) {
|
||||
if (!IDLE_TIMEOUT_MS || IDLE_TIMEOUT_MS <= 0) return; // disabled
|
||||
let timer = null;
|
||||
let killed = false;
|
||||
|
||||
function _kill() {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
const mins = Math.round(IDLE_TIMEOUT_MS / 60000);
|
||||
console.warn(`[aria-idle] killing subprocess ${requestId} after ${mins}min idle`);
|
||||
try { subprocess.kill(); } catch (_) {}
|
||||
_emitStreamEvent(requestId, "end", { reason: "idle_timeout", idleMs: IDLE_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
function _reset() {
|
||||
if (killed) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(_kill, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function _stop() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
}
|
||||
|
||||
// Initial-Timer setzen
|
||||
_reset();
|
||||
|
||||
// Jedes Event vom Subprozess zaehlt als Lebenszeichen
|
||||
subprocess.on("message", _reset);
|
||||
subprocess.on("content_delta", _reset);
|
||||
// Result/close/error → endgueltig stop
|
||||
subprocess.on("result", _stop);
|
||||
subprocess.on("close", _stop);
|
||||
subprocess.on("error", _stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hookt assistant + user Events und pusht beides an Bridge:
|
||||
* - Alt-API: nur Tool-Namen an /internal/agent-activity (Gedanken-Stream)
|
||||
* - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
|
||||
*/
|
||||
function _attachToolHook(subprocess, requestId) {
|
||||
subprocess.on("assistant", (message) => {
|
||||
try {
|
||||
const blocks = message?.message?.content || [];
|
||||
for (const b of blocks) {
|
||||
if (b && b.type === "tool_use" && b.name) {
|
||||
_emitToolEvent(b.name);
|
||||
if (!b) continue;
|
||||
if (b.type === "tool_use") {
|
||||
if (b.name) _emitToolEvent(b.name);
|
||||
const inputStr = b.input ? JSON.stringify(b.input) : "";
|
||||
const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
|
||||
_emitStreamEvent(requestId, "tool_use", {
|
||||
id: b.id || null,
|
||||
name: b.name || "",
|
||||
input: inp.text,
|
||||
inputTruncatedBytes: inp.truncatedBytes,
|
||||
});
|
||||
} else if (b.type === "text" && b.text) {
|
||||
_emitStreamEvent(requestId, "text", { text: b.text });
|
||||
} else if (b.type === "thinking" && b.thinking) {
|
||||
// Wenn das Modell Extended Thinking emittiert — selten in
|
||||
// Claude Code CLI, aber moeglich. Markieren wir extra.
|
||||
_emitStreamEvent(requestId, "thinking", { text: b.thinking });
|
||||
}
|
||||
}
|
||||
} catch (_) { /* fail-open */ }
|
||||
});
|
||||
// tool_result Blocks kommen in user-Messages — die werden vom
|
||||
// subprocess-Manager NICHT als 'user'-Event emittiert (gibt's nicht),
|
||||
// sondern nur ueber das generische 'message'-Event mit type:'user'.
|
||||
// 'message' feuert auch fuer assistant/result — wir filtern auf user
|
||||
// damit wir nicht doppelt rendern (assistant geht ueber den eigenen
|
||||
// assistant-Handler oben).
|
||||
subprocess.on("message", (message) => {
|
||||
try {
|
||||
if (message?.type !== "user") return;
|
||||
const blocks = message?.message?.content || [];
|
||||
for (const b of blocks) {
|
||||
if (b && b.type === "tool_result") {
|
||||
let content = "";
|
||||
if (typeof b.content === "string") content = b.content;
|
||||
else if (Array.isArray(b.content)) {
|
||||
content = b.content.map(c => (c && c.type === "text" && c.text) ? c.text : "").join("");
|
||||
}
|
||||
const out = _truncate(content, TOOL_RESULT_MAX_CHARS);
|
||||
_emitStreamEvent(requestId, "tool_result", {
|
||||
id: b.tool_use_id || null,
|
||||
content: out.text,
|
||||
truncatedBytes: out.truncatedBytes,
|
||||
isError: b.is_error === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) { /* fail-open */ }
|
||||
@@ -86,9 +228,17 @@ export async function handleChatCompletions(req, res) {
|
||||
// Convert to CLI input format
|
||||
const cliInput = openaiToCli(body);
|
||||
const subprocess = new ClaudeSubprocess();
|
||||
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
|
||||
// Greift fuer beide Branches (stream + non-stream).
|
||||
_attachToolHook(subprocess);
|
||||
// ARIA-Patch: Tool-Use-Events + voller Live-Stream an die Bridge.
|
||||
// Plus: Subprocess fuer Not-Aus tracken (Hard-Kill via /v1/cancel-all).
|
||||
// Plus: Idle-Watchdog — Subprocess darf ewig laufen solange Events
|
||||
// kommen, wird aber gekillt nach IDLE_TIMEOUT_MS Inaktivitaet.
|
||||
_attachToolHook(subprocess, requestId);
|
||||
_trackSubprocess(requestId, subprocess);
|
||||
_attachIdleWatchdog(subprocess, requestId);
|
||||
_emitStreamEvent(requestId, "start", { model: body.model || null });
|
||||
subprocess.on("result", () => _emitStreamEvent(requestId, "end", { reason: "result" }));
|
||||
subprocess.on("close", (code) => _emitStreamEvent(requestId, "end", { reason: "close", code }));
|
||||
subprocess.on("error", (err) => _emitStreamEvent(requestId, "end", { reason: "error", error: String(err?.message || err) }));
|
||||
if (stream) {
|
||||
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
|
||||
}
|
||||
@@ -217,11 +367,25 @@ async function handleStreamingResponse(req, res, subprocess, cliInput, requestId
|
||||
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
|
||||
return new Promise((resolve) => {
|
||||
let finalResult = null;
|
||||
let isComplete = false;
|
||||
// Client-Disconnect-Handler — wenn Brain die HTTP-Verbindung kappt
|
||||
// (z.B. nach Read-Timeout), den noch laufenden Subprocess killen.
|
||||
// Im Streaming-Branch existiert das schon; non-streaming hatte's
|
||||
// bisher nicht → Subprozess lief verwaist weiter, Ressourcen-Leak.
|
||||
res.on("close", () => {
|
||||
if (!isComplete) {
|
||||
console.warn("[NonStreaming] Client disconnected before result — killing subprocess", requestId);
|
||||
try { subprocess.kill(); } catch (_) {}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
subprocess.on("result", (result) => {
|
||||
finalResult = result;
|
||||
});
|
||||
subprocess.on("error", (error) => {
|
||||
console.error("[NonStreaming] Error:", error.message);
|
||||
isComplete = true;
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message,
|
||||
@@ -229,9 +393,16 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
|
||||
code: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
subprocess.on("close", (code) => {
|
||||
isComplete = true;
|
||||
if (res.writableEnded) {
|
||||
// Client ist eh schon weg — nichts mehr zu senden.
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (finalResult) {
|
||||
res.json(cliResultToOpenai(finalResult, requestId));
|
||||
}
|
||||
@@ -306,4 +477,55 @@ export function handleHealth(_req, res) {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Not-Aus Side-Channel ───────────────────────────────────
|
||||
//
|
||||
// claude-max-api-proxy steuert seine eigene Route-Registrierung — wir
|
||||
// koennen da nicht reinpatchen ohne sed-Operationen am npm-Paket. Saubrer:
|
||||
// ein dedizierter kleiner HTTP-Listener nur fuer den Not-Aus, auf einem
|
||||
// internen Port im aria-net. Bridge ruft den, killt alle aktiven Claude-
|
||||
// Subprocesses. App + Diagnostic sehen den Stream sofort enden.
|
||||
const INTERNAL_PORT = parseInt(process.env.ARIA_PROXY_INTERNAL_PORT || "3457", 10);
|
||||
const INTERNAL_HOST = "0.0.0.0"; // im aria-net erreichbar, nicht nach extern exposed
|
||||
|
||||
function _cancelAll() {
|
||||
const ids = Array.from(_activeSubprocesses.keys());
|
||||
let killed = 0;
|
||||
for (const [id, subp] of _activeSubprocesses) {
|
||||
try {
|
||||
subp.kill();
|
||||
killed++;
|
||||
} catch (e) {
|
||||
console.error("[aria-not-aus] kill failed for", id, e?.message);
|
||||
}
|
||||
}
|
||||
_activeSubprocesses.clear();
|
||||
return { killed, requestIds: ids };
|
||||
}
|
||||
|
||||
try {
|
||||
const internalServer = http.createServer((req, res) => {
|
||||
if (req.method === "POST" && req.url === "/cancel-all") {
|
||||
const result = _cancelAll();
|
||||
console.warn("[aria-not-aus] /cancel-all — killed", result.killed, "subprocess(es)");
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, ...result }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
internalServer.on("error", (err) => {
|
||||
console.error("[aria-not-aus] internal listener error:", err.message);
|
||||
});
|
||||
internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => {
|
||||
console.log("[aria-not-aus] internal listener on", INTERNAL_HOST + ":" + INTERNAL_PORT);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[aria-not-aus] startup failed:", e?.message);
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
@@ -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"
|
||||
- "443: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:
|
||||
|
||||
+129
-5
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const { WebSocketServer } = require("ws");
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
@@ -40,6 +41,8 @@ const ALLOWED_TYPES = new Set([
|
||||
"service_status",
|
||||
"config_request",
|
||||
"flux_request", "flux_response",
|
||||
"agent_stream",
|
||||
"oauth_callback",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
@@ -70,8 +73,17 @@ function cleanupRooms() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket-Server starten ────────────────────────────────────────
|
||||
|
||||
// ── HTTP + WebSocket Server (hybrid) ────────────────────────────────
|
||||
//
|
||||
// Der gleiche Port handelt jetzt sowohl WebSocket-Upgrades (App, Bridges,
|
||||
// Diagnostic) als auch normale HTTP-Requests (OAuth-Callbacks von Spotify,
|
||||
// Google etc.). TLS-Termination passiert wie bisher vor dem RVS-Container
|
||||
// (Caddy/Nginx); RVS selber bleibt plain HTTP. Wichtig fuer OAuth: aus
|
||||
// Provider-Sicht ist die Callback-URL `https://{RVS_HOST}:{PORT_oeffentlich}
|
||||
// /oauth/callback/{service}` — RVS schnappt den ?code=..&state=.., broadcastet
|
||||
// als WS-Message `oauth_callback` und antwortet dem Browser mit einer
|
||||
// schoenen "Tab schliessen"-Seite.
|
||||
//
|
||||
// maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
||||
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
||||
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
|
||||
@@ -79,15 +91,127 @@ function cleanupRooms() {
|
||||
// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB
|
||||
// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig
|
||||
// abgewiesen (siehe file_request-Handler) bevor die WS abreisst.
|
||||
const wss = new WebSocketServer({ port: PORT, maxPayload: 100 * 1024 * 1024 });
|
||||
const httpServer = http.createServer(handleHttpRequest);
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 100 * 1024 * 1024 });
|
||||
|
||||
wss.on("listening", () => {
|
||||
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
|
||||
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
|
||||
httpServer.on("upgrade", (req, socket, head) => {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`);
|
||||
// Beim Start pruefen ob eine APK da ist
|
||||
const apkInfo = getLatestAPK();
|
||||
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
});
|
||||
|
||||
// ── HTTP Route-Handler ──────────────────────────────────────────────
|
||||
|
||||
function handleHttpRequest(req, res) {
|
||||
try {
|
||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// OAuth-Callback: GET /oauth/callback/{service}?code=...&state=...&error=...
|
||||
// Pattern fuer Spotify, Google, Strava, GitHub, ... — alle OAuth2 Auth-Code-Flow.
|
||||
// Wir broadcasten an alle Raeume (App ist nicht im selben Raum wie Bridge,
|
||||
// aber Bridge schon — sie picks-up und forwardet ans Brain).
|
||||
const oauthMatch = pathname.match(/^\/oauth\/callback\/([a-zA-Z0-9_-]+)\/?$/);
|
||||
if (req.method === "GET" && oauthMatch) {
|
||||
const service = oauthMatch[1];
|
||||
const code = url.searchParams.get("code") || "";
|
||||
const state = url.searchParams.get("state") || "";
|
||||
const err = url.searchParams.get("error") || "";
|
||||
const errDesc = url.searchParams.get("error_description") || "";
|
||||
|
||||
log(`OAuth-Callback: service=${service} code=${code.slice(0, 8)}... state=${state.slice(0, 8)}... err=${err}`);
|
||||
|
||||
const payload = { service, code, state };
|
||||
if (err) {
|
||||
payload.error = err;
|
||||
if (errDesc) payload.errorDescription = errDesc;
|
||||
}
|
||||
|
||||
// An alle Clients in allen Raeumen broadcasten — Bridge picks-up.
|
||||
const msg = JSON.stringify({
|
||||
type: "oauth_callback",
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
let receivers = 0;
|
||||
for (const [, room] of rooms) {
|
||||
for (const client of room.clients) {
|
||||
if (client.readyState === 1) {
|
||||
try { client.send(msg); receivers++; } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`OAuth-Callback gebroadcastet an ${receivers} Client(s)`);
|
||||
|
||||
// Browser-Antwort: schoene HTML-Seite (auch bei Error)
|
||||
const ok = !err;
|
||||
const title = ok ? "OAuth erfolgreich" : "OAuth fehlgeschlagen";
|
||||
const bodyColor = ok ? "#34C759" : "#FF3B30";
|
||||
const icon = ok ? "✅" : "❌";
|
||||
const subtitle = ok
|
||||
? "Du kannst dieses Tab schliessen — ARIA hat den Zugang erhalten."
|
||||
: `Fehler: ${escapeHtml(err)} ${errDesc ? "— " + escapeHtml(errDesc) : ""}`;
|
||||
const html = `<!doctype html>
|
||||
<html lang="de"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title} — ${escapeHtml(service)}</title>
|
||||
<style>
|
||||
html,body{margin:0;padding:0;height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0D0D1A;color:#E0E0F0;}
|
||||
body{display:flex;align-items:center;justify-content:center;}
|
||||
.card{background:#1E1E2E;border:1px solid #2A2A3E;border-radius:12px;padding:32px;max-width:420px;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,0.4);}
|
||||
.icon{font-size:64px;line-height:1;margin-bottom:16px;}
|
||||
.title{font-size:20px;font-weight:600;color:${bodyColor};margin-bottom:8px;}
|
||||
.service{font-size:13px;color:#8888AA;margin-bottom:20px;text-transform:uppercase;letter-spacing:0.1em;}
|
||||
.sub{font-size:14px;color:#C0C0D0;line-height:1.5;}
|
||||
.hint{font-size:11px;color:#666680;margin-top:24px;}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="icon">${icon}</div>
|
||||
<div class="title">${title}</div>
|
||||
<div class="service">${escapeHtml(service)}</div>
|
||||
<div class="sub">${subtitle}</div>
|
||||
<div class="hint">Du kannst zur ARIA-App zurueckkehren.</div>
|
||||
</div>
|
||||
<script>setTimeout(()=>{try{window.close();}catch(e){}}, 4000);</script>
|
||||
</body></html>`;
|
||||
res.writeHead(ok ? 200 : 400, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Health-Endpoint
|
||||
if (req.method === "GET" && pathname === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, rooms: rooms.size }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: 404
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not Found\n");
|
||||
} catch (e) {
|
||||
log(`HTTP handler error: ${e.message}`);
|
||||
try { res.writeHead(500).end("Internal Server Error"); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
// Token aus URL-Query lesen: ws://host:port/?token=abc123
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
Reference in New Issue
Block a user