178 lines
12 KiB
Markdown
178 lines
12 KiB
Markdown
# ByteClaw
|
||
|
||
Schlanker, selbst gehosteter KI-Assistent: **Go-Backend** (ein statisches Binary, kein CGO) + **nativer Android-Client** (Kotlin, Jetpack Compose).
|
||
|
||
## Architektur
|
||
|
||
```
|
||
Android-App (Compose) Go-Backend (cmd/byteclaw)
|
||
├─ Einstellungen (EncryptedSharedPrefs) ├─ internal/server HTTP-API + WebSocket, Bearer-Token, Origin-Check
|
||
├─ Chat ──── WebSocket /api/ws ───────▶ ├─ internal/agent Agent-Loop mit Tool-Calling, Lock pro Session
|
||
└─ Sessions ─ REST /api/sessions ──────▶ ├─ internal/llm Provider-Interface + OpenAI-kompatibler Streaming-Provider
|
||
├─ internal/tools time_now, web_fetch, file_read/_write/_list
|
||
├─ internal/store SQLite (modernc.org/sqlite, pure Go)
|
||
└─ internal/config YAML/JSON + Umgebungsvariablen
|
||
```
|
||
|
||
Ablauf einer Nachricht: Client → `/api/ws` → Agent speichert User-Nachricht → LLM (Streaming) → bei Tool-Calls werden Tools ausgeführt, Ergebnisse gespeichert und das LLM erneut gefragt (max. `agent.max_iterations`) → Tokens werden live als `delta`-Events gestreamt, am Ende `done`.
|
||
|
||
Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github.com/coder/websocket`.
|
||
|
||
## Sichere Defaults
|
||
|
||
- Konten: `POST /api/auth/register|login|logout`, `GET /api/auth/me`; Passwörter als bcrypt-Hash, Tokens zufällig + gehasht gespeichert, 30 Tage gültig, Logout widerruft. Sessions sind pro Nutzer getrennt. Login-Rate-Limit pro IP. `allow_registration` (Standard an). Optionaler statischer `auth_token` (mind. 16 Zeichen) = Admin, sieht alle Sessions; Vergleich in konstanter Zeit.
|
||
- Lauscht standardmäßig nur auf `127.0.0.1:8787`.
|
||
- Browser-Origins: nur gleicher Host oder explizit in `allowed_origins`; `*` muss bewusst gesetzt werden. Native Clients (ohne `Origin`-Header) brauchen trotzdem das Token.
|
||
- TLS-Zertifikatsprüfung ist überall an (LLM-Provider, `web_fetch`); es gibt keinen Schalter zum Abschalten.
|
||
- `web_fetch`: nur http(s), Timeout, Größenlimit, max. 5 Redirects, blockiert Loopback/Private/Link-Local/CGNAT-Adressen beim Verbindungsaufbau (schützt auch gegen DNS-Rebinding und Redirect-Tricks). Abschaltbar per `allow_private`.
|
||
- Datei-Tools sind per `os.Root` auf den Workspace beschränkt (kein `..`, keine absoluten Pfade, keine Symlink-Ausbrüche) und größenbegrenzt.
|
||
- Shell-Tool standardmäßig AUS (`tools.shell.enabled`). Wenn an: nur Befehle aus `allowed_commands` (Interpreter/Netzwerk-/Shell-Programme wie `sh`, `bash`, `env`, `xargs`, `python`, `curl`, `git` sind nie erlaubt), jedes Glied von `|`, `&&`, `||`, `;` wird geprüft, `$`, Backticks, `<( )`, Umleitungen, `&`, Klammern werden abgelehnt, keine Pfade außerhalb des Workspace (`/`, `~`, `..`), Ausführung ohne `sh -c` direkt per exec im Workspace, Timeout, Output-Limit, bereinigte Umgebung (nur `PATH`, `HOME`=Workspace, `LANG`, `TERM` – keine API-Keys/Token).
|
||
- Docker: distroless, User `65532` (nonroot), Go-Version fixiert.
|
||
- Android: Release-Builds erlauben nur HTTPS/WSS; Klartext (http/ws) nur im Debug-Build für lokale Entwicklung. Token/URL verschlüsselt per Android Keystore.
|
||
|
||
## Quickstart Backend
|
||
|
||
```bash
|
||
cp config.example.yaml config.yaml
|
||
export BYTECLAW_AUTH_TOKEN=$(openssl rand -hex 32)
|
||
export BYTECLAW_LLM_API_KEY=sk-... # OpenAI / OpenRouter; für Ollama leer lassen
|
||
make run # = go build + ./bin/byteclaw -config config.yaml
|
||
```
|
||
|
||
Provider-Presets (`llm.provider` bzw. `BYTECLAW_LLM_PROVIDER`). Ein Preset füllt `base_url` und `model`, wenn sie leer sind; explizite Werte haben Vorrang. Der API-Key fällt auf die genannte Umgebungsvariable zurück. Alle nutzen `Authorization: Bearer <key>` und `/chat/completions`.
|
||
|
||
| provider | base_url | Default-Modell | Key-Env | Quelle |
|
||
|---------------|-----------------------------------|--------------------------|-----------------------|--------|
|
||
| `openai` | https://api.openai.com/v1 | gpt-4o-mini | OPENAI_API_KEY | platform.openai.com |
|
||
| `openrouter` | https://openrouter.ai/api/v1 | openai/gpt-4o-mini | OPENROUTER_API_KEY | openrouter.ai/docs |
|
||
| `ollama` | http://localhost:11434/v1 | llama3.1 | – | ollama docs |
|
||
| `dahl` | https://inference.dahl.global/v1 | MiniMaxAI/MiniMax-M2.7 | DAHL_API_KEY | https://docs.dahl.global/ |
|
||
| `gonkarouter` | https://api.gonkarouter.io/v1 | MiniMaxAI/MiniMax-M2.7 | GONKAROUTER_API_KEY | https://gonkarouter.io/docs |
|
||
| `custom` | (Pflicht) | (Pflicht) | – | – |
|
||
|
||
**Dahl** (dahl.global) und **GonkaRouter** sind OpenAI-kompatible Inferenz-Dienste für offene Modelle auf dem dezentralen Gonka-GPU-Netz. Weitere Modelle (Stand 25.10.2026): `zai-org/GLM-5.3-Flash`, `deepseek-ai/DeepSeek-V4-Flash-0731`. Aktuelle IDs immer über `GET <base_url>/models` prüfen. Bei Dahl braucht der Key ein zugewiesenes Token-Guthaben (sonst HTTP 402).
|
||
|
||
```yaml
|
||
llm:
|
||
provider: dahl # oder gonkarouter
|
||
model: zai-org/GLM-5.3-Flash # optional
|
||
```
|
||
|
||
`GET /api/info` liefert Provider, Modell und base_url des Servers (ohne Key); die Android-App zeigt das bei „Verbindung testen" an.
|
||
|
||
Test:
|
||
|
||
```bash
|
||
curl localhost:8787/healthz
|
||
curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" -d '{"text":"Wie spät ist es in Berlin?"}' localhost:8787/api/chat
|
||
```
|
||
|
||
### API
|
||
|
||
| Methode | Pfad | Zweck |
|
||
|---------|------------------------------------|------------------------------------------|
|
||
| GET | /healthz | Health (ohne Auth) |
|
||
| GET | /api/info | Version, Provider, Modell (ohne Secrets) |
|
||
| GET | /api/sessions | Sessions (neueste zuerst) |
|
||
| POST | /api/sessions | Session anlegen `{"title":""}` |
|
||
| GET/PATCH/DELETE | /api/sessions/{id} | Lesen / umbenennen / löschen |
|
||
| GET | /api/sessions/{id}/messages | Verlauf |
|
||
| POST | /api/chat | Nicht-streamend `{"session_id"?, "text"}`|
|
||
| GET | /api/ws | WebSocket-Streaming |
|
||
| POST | /api/sessions/{id}/cancel | Laufende Antwort abbrechen |
|
||
| POST | /api/sessions/{id}/summarize | Zusammenfassung manuell auslösen |
|
||
| GET | /api/sessions/{id}/summary | Letzte Zusammenfassung |
|
||
| GET | /api/providers | Provider-Liste + aktiv (ohne Keys) |
|
||
| PUT | /api/providers/active | Aktiven Provider/Modell setzen `{"provider","model"?}` |
|
||
| GET | /api/providers/{name}/models | Modellliste vom Provider (`/v1/models`) |
|
||
| PUT | /api/sessions/{id}/provider | Provider nur für diese Session (`{"provider":""}` = Standard) |
|
||
|
||
Alle `/api/*` brauchen `Authorization: Bearer <token>`.
|
||
WebSocket: Client sendet `{"type":"message","session_id":"<optional>","text":"..."}`; Server sendet Events
|
||
`session`, `summarized`, `delta`, `tool_call`, `tool_result`, `done`, `cancelled` oder `error` (`{"type","text","tool","args","call_id","session_id"}`). `{"type":"ping"}` → `pong`.
|
||
Abbrechen: `{"type":"cancel","session_id":"…"}` über denselben WebSocket oder `POST /api/sessions/{id}/cancel`. Der Abbruch läuft per Context bis in den HTTP-Request zum Provider; die bis dahin gestreamte Teilantwort wird als Assistant-Nachricht mit `"cancelled":true` gespeichert.
|
||
|
||
### Provider umschalten
|
||
|
||
Alle Presets (openai, openrouter, ollama, dahl, gonkarouter) plus der in `llm.*` konfigurierte Provider stehen zur Auswahl. Keys kommen nur aus Env (`DAHL_API_KEY`, `GONKAROUTER_API_KEY`, …) oder `llm.providers.<name>.api_key` und verlassen den Server nie; die App sieht nur `configured: true/false`. Die Auswahl wird in SQLite gespeichert und überlebt Neustarts; pro Session kann ein anderer Provider gesetzt werden.
|
||
|
||
### Lange Sessions
|
||
|
||
`agent.summarize`: ab `max_messages` (Standard 40) oder ~`max_tokens` (12000, Zeichen/4) noch nicht zusammengefasster Nachrichten fasst das aktive LLM die älteren Nachrichten zusammen (die neuesten `keep_recent` bleiben wörtlich, Schnitt immer vor einer User-Nachricht). Die Zusammenfassung liegt in der Tabelle `summaries`, die Originalnachrichten bleiben erhalten.
|
||
|
||
### Build, Tests, Cross-Builds
|
||
|
||
```bash
|
||
make # vet + test + build
|
||
make race # Tests mit Race-Detector (benötigt CGO)
|
||
make cross # dist/: linux-amd64, linux-arm64, linux-armv7, termux-arm64, termux-armv7
|
||
make docker
|
||
```
|
||
|
||
Termux: `byteclaw-termux-arm64` ist ein `GOOS=android`-Build; für armv7 wird ein statisches `linux/arm`-Binary gebaut, da `android/arm` ohne NDK/CGO nicht linkbar ist. In Termux z. B. nach `$PREFIX/bin` kopieren, `chmod +x` und starten.
|
||
|
||
### Docker
|
||
|
||
```bash
|
||
docker build -t byteclaw .
|
||
docker run -d -p 127.0.0.1:8787:8787 -v byteclaw-data:/data \
|
||
-e BYTECLAW_AUTH_TOKEN=... -e BYTECLAW_LLM_API_KEY=... byteclaw
|
||
```
|
||
|
||
Im Container lauscht der Server auf `0.0.0.0:8787`; nach außen nur hinter einem TLS-Reverse-Proxy (Caddy, Traefik) oder in einem VPN (z. B. Tailscale) freigeben.
|
||
|
||
## Android-Client
|
||
|
||
```
|
||
android/
|
||
├─ app/src/main/java/dev/byteclaw/client/
|
||
│ ├─ MainActivity.kt Navigation (Chat, Sessions, Einstellungen)
|
||
│ ├─ data/ByteClawApi.kt OkHttp REST + WebSocket-Flow
|
||
│ ├─ data/SettingsStore.kt EncryptedSharedPreferences
|
||
│ ├─ data/Models.kt kotlinx.serialization
|
||
│ └─ ui/ ChatViewModel, Compose-Screens
|
||
└─ gradle/libs.versions.toml Versionskatalog (AGP 8.7.3, Kotlin 2.0.21, compileSdk 35, minSdk 26)
|
||
```
|
||
|
||
Build (JDK 17 + Android SDK, `ANDROID_HOME` oder `local.properties` mit `sdk.dir`):
|
||
|
||
```bash
|
||
cd android
|
||
./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
|
||
./gradlew testDebugUnitTest
|
||
./gradlew assembleRelease # signiert, wenn Keystore-Daten vorhanden sind (sonst unsigniert)
|
||
```
|
||
|
||
### Release-Signing
|
||
|
||
Die `signingConfig` liest Keystore-Daten aus Umgebungsvariablen (`BYTECLAW_KEYSTORE_FILE`, `BYTECLAW_KEYSTORE_PASSWORD`, `BYTECLAW_KEY_ALIAS`, `BYTECLAW_KEY_PASSWORD`) oder aus `android/keystore.properties` (Vorlage: `keystore.properties.example`). `keystore.properties`, `*.jks` und `*.keystore` sind gitignored – Keystore niemals ins Repo.
|
||
|
||
```bash
|
||
mkdir -p ~/byteclaw-keys && chmod 700 ~/byteclaw-keys
|
||
keytool -genkeypair -keystore ~/byteclaw-keys/byteclaw-release.jks -storetype PKCS12 \
|
||
-alias byteclaw -keyalg RSA -keysize 3072 -validity 10000 -dname "CN=ByteClaw, C=DE"
|
||
cp android/keystore.properties.example android/keystore.properties # Passwörter eintragen
|
||
cd android && ./gradlew assembleRelease
|
||
$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk
|
||
```
|
||
|
||
Keystore und Passwörter sicher sichern: Ohne denselben Schlüssel lassen sich Updates nicht installieren.
|
||
|
||
### App-Icon
|
||
|
||
Adaptive Icon als Vektor (`res/drawable/ic_launcher_{background,foreground,monochrome}.xml`, `mipmap-anydpi-v26`): drei Krallen-Kratzer in Claw-Orange plus Byte-Bits in Türkis auf dunklem Navy – dieselben Markenfarben wie im Compose-Theme (`ui/Theme.kt`, `res/values/colors.xml`). Da minSdk 26 ist, deckt der Vektor alle Dichten ab; die Monochrom-Ebene sorgt für Themed Icons ab Android 13. Vorschau: `preview/icon.png`.
|
||
|
||
In der App unter Einstellungen Server-URL (`https://…`; im Debug-Build auch `http://10.0.2.2:8787` für den Emulator) und Token eintragen, „Verbindung testen", speichern.
|
||
|
||
Öffentlicher Server: Die App schlägt standardmäßig `https://byteclaw.jodabreit-ai.de` vor (Registrierung offen). Hinter einem Reverse-Proxy dessen IP/CIDR in `trusted_proxies` eintragen, damit das Login-Rate-Limit die echte Client-IP aus `X-Forwarded-For` nutzt.
|
||
|
||
## Nächste Schritte
|
||
|
||
- CI (GitHub Actions) für Go + Android
|
||
- Weitere Provider (Anthropic nativ)
|
||
- App: Provider pro Session wählen (API existiert schon)
|
||
- Optional: Einrichtung per QR-Code (URL + Token)
|
||
|
||
## Vorschau
|
||
|
||
Screenshots mit Demo-Daten (Paparazzi, ohne Emulator) liegen in `preview/` (hell/dunkel): Chat mit Stopp-Button, Sessions, Einstellungen, Provider-Auswahl, `icon.png`. Neu erzeugen: `cd android && ./gradlew recordPaparazziDebug`.
|