From 6ff31589be0db0781dcd47ee3b766c6d99b19ab5 Mon Sep 17 00:00:00 2001 From: OpenClaw Dev Date: Sun, 25 Oct 2026 04:52:27 +0100 Subject: [PATCH] feat(android): provider selection, stop button, adaptive icon, release signing; docs + previews --- README.md | 49 +++++- android/.gitignore | 3 + android/app/build.gradle.kts | 31 ++++ android/app/src/main/AndroidManifest.xml | 3 +- .../java/dev/byteclaw/client/MainActivity.kt | 5 +- .../dev/byteclaw/client/data/ByteClawApi.kt | 21 ++- .../java/dev/byteclaw/client/data/Models.kt | 23 +++ .../dev/byteclaw/client/ui/ChatViewModel.kt | 59 +++++++- .../java/dev/byteclaw/client/ui/Screens.kt | 142 ++++++++++++++---- .../main/java/dev/byteclaw/client/ui/Theme.kt | 21 ++- .../res/drawable/ic_launcher_background.xml | 7 + .../res/drawable/ic_launcher_foreground.xml | 14 ++ .../res/drawable/ic_launcher_monochrome.xml | 14 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + android/app/src/main/res/values/colors.xml | 7 + .../java/dev/byteclaw/client/ModelsTest.kt | 18 +++ .../byteclaw/client/preview/IconScreenshot.kt | 51 +++++++ .../client/preview/PreviewScreenshots.kt | 24 +++ ...lient.preview_IconScreenshot_icon_icon.png | Bin 0 -> 17924 bytes ..._PreviewScreenshots_chat[0]_chat_light.png | Bin 43486 -> 43959 bytes ...w_PreviewScreenshots_chat[1]_chat_dark.png | Bin 42864 -> 44117 bytes ...reenshots_providers[0]_providers_light.png | Bin 0 -> 79463 bytes ...creenshots_providers[1]_providers_dark.png | Bin 0 -> 79461 bytes ...Screenshots_sessions[0]_sessions_light.png | Bin 34513 -> 43356 bytes ...wScreenshots_sessions[1]_sessions_dark.png | Bin 34440 -> 43227 bytes ...Screenshots_settings[0]_settings_light.png | Bin 51056 -> 55593 bytes ...wScreenshots_settings[1]_settings_dark.png | Bin 51457 -> 55762 bytes android/keystore.properties.example | 5 + preview/chat_dark.png | Bin 42864 -> 44117 bytes preview/chat_light.png | Bin 43486 -> 43959 bytes preview/icon.png | Bin 0 -> 15798 bytes preview/providers_dark.png | Bin 0 -> 79461 bytes preview/providers_light.png | Bin 0 -> 79463 bytes preview/sessions_dark.png | Bin 34440 -> 43227 bytes preview/sessions_light.png | Bin 34513 -> 43356 bytes preview/settings_dark.png | Bin 51457 -> 55762 bytes preview/settings_light.png | Bin 51056 -> 55593 bytes 38 files changed, 468 insertions(+), 41 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/test/java/dev/byteclaw/client/preview/IconScreenshot.kt create mode 100644 android/app/src/test/snapshots/images/dev.byteclaw.client.preview_IconScreenshot_icon_icon.png create mode 100644 android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png create mode 100644 android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png create mode 100644 android/keystore.properties.example create mode 100644 preview/icon.png create mode 100644 preview/providers_dark.png create mode 100644 preview/providers_light.png diff --git a/README.md b/README.md index 2c4d630..243aba5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github. - 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. -- Kein Shell-Tool. +- 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. @@ -79,10 +79,26 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" -d '{"text":"Wie spät ist | 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 `. WebSocket: Client sendet `{"type":"message","session_id":"","text":"..."}`; Server sendet Events -`session`, `delta`, `tool_call`, `tool_result`, `done` oder `error` (`{"type","text","tool","args","call_id","session_id"}`). `{"type":"ping"}` → `pong`. +`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..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 @@ -124,18 +140,37 @@ Build (JDK 17 + Android SDK, `ANDROID_HOME` oder `local.properties` mit `sdk.dir cd android ./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk ./gradlew testDebugUnitTest -./gradlew assembleRelease # unsigniert; Signing-Config fehlt noch +./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. ## Nächste Schritte -- Release-Signing, App-Icon, CI (GitHub Actions) für Go + Android -- Session-Kontext zusammenfassen statt nur Fenster der letzten N Nachrichten -- Weitere Provider (Anthropic nativ), Abbrechen laufender Antworten über WebSocket +- 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). Neu erzeugen: `cd android && ./gradlew recordPaparazziDebug`. +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`. diff --git a/android/.gitignore b/android/.gitignore index 2b7860c..b212d3b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -4,3 +4,6 @@ app/build/ local.properties .idea/ *.iml +keystore.properties +*.jks +*.keystore diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index daeb647..edad330 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -6,6 +8,21 @@ plugins { alias(libs.plugins.paparazzi) // screenshot tests only (JVM, test source set) } +// Release signing: values come from env (BYTECLAW_KEYSTORE_FILE/_PASSWORD, BYTECLAW_KEY_ALIAS/_PASSWORD) +// or from android/keystore.properties (storeFile, storePassword, keyAlias, keyPassword). +// keystore.properties and *.jks/*.keystore are gitignored. Without them the release APK stays unsigned. +val keystoreProps = Properties().apply { + val f = rootProject.file("keystore.properties") + if (f.isFile) f.inputStream().use { load(it) } +} +fun signingValue(env: String, prop: String): String? = + System.getenv(env)?.takeIf { it.isNotBlank() } ?: keystoreProps.getProperty(prop)?.takeIf { it.isNotBlank() } +val releaseStoreFile = signingValue("BYTECLAW_KEYSTORE_FILE", "storeFile") +val releaseStorePassword = signingValue("BYTECLAW_KEYSTORE_PASSWORD", "storePassword") +val releaseKeyAlias = signingValue("BYTECLAW_KEY_ALIAS", "keyAlias") +val releaseKeyPassword = signingValue("BYTECLAW_KEY_PASSWORD", "keyPassword") +val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { it != null } + android { namespace = "dev.byteclaw.client" compileSdk = 35 @@ -18,8 +35,22 @@ android { versionName = "0.1.0" } + signingConfigs { + if (hasReleaseSigning) { + create("release") { + storeFile = file(releaseStoreFile!!.replaceFirst(Regex("^~"), System.getProperty("user.home"))) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + enableV2Signing = true + enableV3Signing = true + } + } + } + buildTypes { release { + if (hasReleaseSigning) signingConfig = signingConfigs.getByName("release") isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5c4b2d6..eb63783 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ vm.testConnection(s, cb) }, onBack = { if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } } - }) + }, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) }, + onSelectProvider = { p, m -> vm.selectProvider(p, m) }) } } } diff --git a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt index 0bd892d..9e7fbb1 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt @@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket @@ -51,6 +53,22 @@ class ByteClawApi(private val settings: ServerSettings) { call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build()) } + suspend fun providers(): ProvidersResponse = + json.decodeFromString(call(Request.Builder().url(url("/api/providers")).build())) + + suspend fun setActiveProvider(provider: String, model: String): ActiveProvider { + val body = json.encodeToString(ActiveProvider.serializer(), ActiveProvider(provider, model)).toRequestBody(JSON) + return json.decodeFromString(call(Request.Builder().url(url("/api/providers/active")).put(body).build())) + } + + /** Model ids from the provider's /v1/models, fetched by the server with its own key. */ + suspend fun models(provider: String): List = + json.decodeFromString(call(Request.Builder().url(url("/api/providers/$provider/models")).build())).models + + /** Stops the running answer of a session; the partial answer is kept on the server. */ + suspend fun cancel(sessionId: String): Boolean = + json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/cancel")).post(ByteArray(0).toRequestBody(null)).build())).cancelled + /** * Opens a WebSocket, sends one message and emits streamed events until "done"/"error". */ @@ -64,7 +82,7 @@ class ByteClawApi(private val settings: ServerSettings) { override fun onMessage(webSocket: WebSocket, text: String) { val ev = runCatching { json.decodeFromString(AgentEvent.serializer(), text) }.getOrNull() ?: return trySend(ev) - if (ev.type == "done" || ev.type == "error") { + if (ev.type == "done" || ev.type == "error" || ev.type == "cancelled") { webSocket.close(1000, null) close() } @@ -82,6 +100,7 @@ class ByteClawApi(private val settings: ServerSettings) { } companion object { + private val JSON = "application/json".toMediaType() private val shared = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // streaming responses can be long diff --git a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt index 39865f6..cf1dffa 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt @@ -20,6 +20,7 @@ data class Message( val content: String = "", @SerialName("tool_calls") val toolCalls: List = emptyList(), val name: String = "", + val cancelled: Boolean = false, ) /** Event streamed by the server over the WebSocket. */ @@ -49,6 +50,28 @@ data class ServerInfo( @SerialName("base_url") val baseUrl: String = "", ) +/** GET /api/providers entry. API keys never leave the server; only "configured" is reported. */ +@Serializable +data class ProviderInfo( + val name: String, + @SerialName("base_url") val baseUrl: String = "", + @SerialName("default_model") val defaultModel: String = "", + val configured: Boolean = false, + val active: Boolean = false, +) + +@Serializable +data class ActiveProvider(val provider: String = "", val model: String = "") + +@Serializable +data class ProvidersResponse(val active: ActiveProvider = ActiveProvider(), val providers: List = emptyList()) + +@Serializable +data class ModelsResponse(val models: List = emptyList()) + +@Serializable +data class CancelResponse(val cancelled: Boolean = false) + /** Provider presets known to the server (configured server-side via llm.provider). */ object ProviderPresets { val names = mapOf( diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt index cee1601..2ccc2e9 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt @@ -3,7 +3,9 @@ package dev.byteclaw.client.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import dev.byteclaw.client.data.ActiveProvider import dev.byteclaw.client.data.ByteClawApi +import dev.byteclaw.client.data.ProviderInfo import dev.byteclaw.client.data.ProviderPresets import dev.byteclaw.client.data.ServerSettings import dev.byteclaw.client.data.Session @@ -14,7 +16,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -data class ChatLine(val role: String, val text: String, val tool: String = "") +data class ChatLine(val role: String, val text: String, val tool: String = "", val cancelled: Boolean = false) + +/** Provider selection shown in the settings (loaded from the server, never contains keys). */ +data class ProviderUi( + val providers: List = emptyList(), + val active: ActiveProvider? = null, + val models: List = emptyList(), + val modelsFor: String? = null, + val status: String? = null, +) data class UiState( val settings: ServerSettings = ServerSettings(), @@ -23,6 +34,7 @@ data class UiState( val sessions: List = emptyList(), val busy: Boolean = false, val error: String? = null, + val provider: ProviderUi = ProviderUi(), ) class ChatViewModel(app: Application) : AndroidViewModel(app) { @@ -67,7 +79,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { when (m.role) { "user" -> listOf(ChatLine("user", m.content)) "assistant" -> buildList { - if (m.content.isNotBlank()) add(ChatLine("assistant", m.content)) + if (m.content.isNotBlank()) add(ChatLine("assistant", m.content, cancelled = m.cancelled)) m.toolCalls.forEach { add(ChatLine("tool", it.arguments, it.name)) } } else -> emptyList() @@ -104,6 +116,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { "done" -> _state.update { s -> s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) } + "cancelled" -> markCancelled() "error" -> _state.update { s -> s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) } @@ -113,6 +126,48 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { } } + /** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */ + fun cancel() { + if (!_state.value.busy) return + val sid = _state.value.sessionId + viewModelScope.launch { + val ok = sid != null && runCatching { api().cancel(sid) }.getOrDefault(false) + if (!ok) { + job?.cancel() + markCancelled() + } + } + } + + private fun markCancelled() = _state.update { s -> + val lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }.toMutableList() + val i = lines.indexOfLast { it.role == "assistant" } + if (i >= 0 && i == lines.lastIndex) lines[i] = lines[i].copy(cancelled = true) + s.copy(busy = false, lines = lines) + } + + fun loadProviders() = viewModelScope.launch { + runCatching { api().providers() } + .onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } } + .onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } } + } + + fun loadModels(name: String) = viewModelScope.launch { + _state.update { it.copy(provider = it.provider.copy(models = emptyList(), modelsFor = name, status = "Lade Modelle…")) } + runCatching { api().models(name) } + .onSuccess { ids -> _state.update { it.copy(provider = it.provider.copy(models = ids, modelsFor = name, status = "${ids.size} Modelle verfügbar")) } } + .onFailure { _ -> _state.update { it.copy(provider = it.provider.copy(status = "Keine Modellliste verfügbar – Modell manuell eintragen")) } } + } + + fun selectProvider(name: String, model: String) = viewModelScope.launch { + runCatching { api().setActiveProvider(name, model.trim()) } + .onSuccess { a -> + _state.update { it.copy(provider = it.provider.copy(active = a, status = "Aktiv: ${ProviderPresets.label(a.provider)} · ${a.model}")) } + loadProviders() + } + .onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Fehler: ${e.message}")) } } + } + private fun appendToLast(delta: String) = _state.update { s -> val lines = s.lines.toMutableList() val last = lines.lastOrNull() diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index fed97a9..61c5b2a 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -24,8 +25,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.byteclaw.client.data.ByteClawApi +import dev.byteclaw.client.data.ProviderPresets import dev.byteclaw.client.data.ServerSettings @OptIn(ExperimentalMaterial3Api::class) @@ -37,6 +40,7 @@ fun ChatScreen( onDismissError: () -> Unit, onSessions: () -> Unit, onSettings: () -> Unit, + onStop: () -> Unit = {}, initialInput: String = "", ) { var input by remember { mutableStateOf(initialInput) } @@ -78,10 +82,20 @@ fun ChatScreen( placeholder = { Text("Nachricht…") }, maxLines = 5, enabled = state.settings.isConfigured, ) - IconButton( - onClick = { onSend(input); input = "" }, - enabled = !state.busy && input.isNotBlank() && state.settings.isConfigured, - ) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") } + if (state.busy) { + FilledTonalIconButton( + onClick = onStop, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { Icon(Icons.Filled.Stop, "Antwort stoppen") } + } else { + IconButton( + onClick = { onSend(input); input = "" }, + enabled = input.isNotBlank() && state.settings.isConfigured, + ) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") } + } } } } @@ -99,7 +113,7 @@ private fun Bubble(line: ChatLine) { } return } - Box(Modifier.fillMaxWidth().padding(vertical = 4.dp), contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart) { + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalAlignment = if (isUser) Alignment.End else Alignment.Start) { Text( line.text.ifEmpty { "…" }, Modifier.widthIn(max = 320.dp) @@ -108,6 +122,10 @@ private fun Bubble(line: ChatLine) { RoundedCornerShape(14.dp), ).padding(horizontal = 12.dp, vertical = 8.dp), ) + if (line.cancelled) { + Text("⏹ abgebrochen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(start = 8.dp, top = 2.dp)) + } } } @@ -131,7 +149,7 @@ fun SessionsScreen( LazyColumn(Modifier.padding(pad).fillMaxSize()) { items(state.sessions, key = { it.id }) { s -> ListItem( - headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1) }, + headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1, overflow = TextOverflow.Ellipsis) }, supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") }, trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } }, modifier = Modifier.clickable { onOpen(s.id) }, @@ -149,38 +167,108 @@ fun SettingsScreen( onSave: (ServerSettings) -> Unit, onTest: (ServerSettings, (String) -> Unit) -> Unit, onBack: () -> Unit, + onLoadProviders: () -> Unit = {}, + onLoadModels: (String) -> Unit = {}, + onSelectProvider: (String, String) -> Unit = { _, _ -> }, initialStatus: String? = null, ) { var url by remember { mutableStateOf(state.settings.serverUrl) } var token by remember { mutableStateOf(state.settings.token) } var status by remember { mutableStateOf(initialStatus) } val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url) + LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() } Scaffold(topBar = { TopAppBar(title = { Text("Einstellungen") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } }) }) { pad -> - Column(Modifier.padding(pad).padding(16.dp).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") }, - placeholder = { Text("https://claw.example.org") }, singleLine = true, - isError = urlError != null, supportingText = { urlError?.let { Text(it) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) - OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) - Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.", - style = MaterialTheme.typography.bodySmall) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = { - onSave(ServerSettings(url, token)); onBack() - }) { Text("Speichern") } - OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = { - status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it } - }) { Text("Verbindung testen") } + LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { + OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") }, + placeholder = { Text("https://claw.example.org") }, singleLine = true, + isError = urlError != null, supportingText = { urlError?.let { Text(it) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) + } + item { + OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) + } + item { + Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.", + style = MaterialTheme.typography.bodySmall) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = { + onSave(ServerSettings(url, token)); onBack() + }) { Text("Speichern") } + OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = { + status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it } + }) { Text("Verbindung testen") } + } + } + status?.let { item { Text(it) } } + if (state.settings.isConfigured) { + item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } } - status?.let { Text(it) } - Text("Der LLM-Provider (z. B. Dahl, GonkaRouter) wird am Server über llm.provider gewählt. " + - "Verbindung testen zeigt den aktiven Provider und das Modell.", - style = MaterialTheme.typography.bodySmall) } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProviderSection( + p: ProviderUi, + onLoadProviders: () -> Unit, + onLoadModels: (String) -> Unit, + onSelectProvider: (String, String) -> Unit, +) { + var selected by remember(p.active) { mutableStateOf(p.active?.provider.orEmpty()) } + var model by remember(p.active) { mutableStateOf(p.active?.model.orEmpty()) } + var expanded by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider() + Row(verticalAlignment = Alignment.CenterVertically) { + Text("LLM-Provider", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + TextButton(onClick = onLoadProviders) { Text("Aktualisieren") } + } + Text("API-Keys bleiben auf dem Server. Nicht konfigurierte Provider sind ausgegraut.", + style = MaterialTheme.typography.bodySmall) + p.providers.forEach { info -> + Row( + Modifier.fillMaxWidth().clickable(enabled = info.configured) { + selected = info.name; model = info.defaultModel; onLoadModels(info.name) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected == info.name, enabled = info.configured, onClick = { + selected = info.name; model = info.defaultModel; onLoadModels(info.name) + }) + Column(Modifier.weight(1f)) { + Text(ProviderPresets.label(info.name) + if (info.active) " · aktiv" else "", + color = if (info.configured) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline) + Text(if (info.configured) info.baseUrl else "kein API-Key am Server", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + val models = if (p.modelsFor == selected) p.models else emptyList() + ExposedDropdownMenuBox(expanded = expanded && models.isNotEmpty(), onExpandedChange = { expanded = it }) { + OutlinedTextField( + model, { model = it }, Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable), + label = { Text("Modell") }, singleLine = true, + trailingIcon = { if (models.isNotEmpty()) ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + ) + ExposedDropdownMenu(expanded = expanded && models.isNotEmpty(), onDismissRequest = { expanded = false }) { + models.take(200).forEach { id -> + DropdownMenuItem(text = { Text(id, maxLines = 1, overflow = TextOverflow.Ellipsis) }, onClick = { model = id; expanded = false }) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(enabled = selected.isNotBlank() && model.isNotBlank(), onClick = { onSelectProvider(selected, model) }) { Text("Übernehmen") } + OutlinedButton(enabled = selected.isNotBlank(), onClick = { onLoadModels(selected) }) { Text("Modelle laden") } + } + p.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } +} diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt index 253eaa7..d16682d 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt @@ -4,8 +4,27 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** ByteClaw brand colours (also used by the launcher icon, see res/values/colors.xml). */ +object Brand { + val Claw = Color(0xFFFF6A3D) // claw orange + val ClawDark = Color(0xFFB8401C) + val Ink = Color(0xFF14202E) // deep navy background + val Byte = Color(0xFF39D3C3) // byte teal accent +} + +private val Light = lightColorScheme( + primary = Brand.ClawDark, onPrimary = Color.White, primaryContainer = Color(0xFFFFDBCF), onPrimaryContainer = Color(0xFF3A0B00), + secondary = Color(0xFF006A62), secondaryContainer = Color(0xFFB3F0E7), +) +private val Dark = darkColorScheme( + primary = Brand.Claw, onPrimary = Color(0xFF3A0B00), primaryContainer = Color(0xFF7A2A10), onPrimaryContainer = Color(0xFFFFDBCF), + secondary = Brand.Byte, secondaryContainer = Color(0xFF00504A), + background = Color(0xFF0F1822), surface = Color(0xFF0F1822), surfaceVariant = Color(0xFF243243), +) @Composable fun ByteClawTheme(dark: Boolean, content: @Composable () -> Unit) { - MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme(), content = content) + MaterialTheme(colorScheme = if (dark) Dark else Light, content = content) } diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..213ddc1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ee04151 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..fac8865 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..8fde456 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..8fde456 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8d52a41 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #FF6A3D + #14202E + #39D3C3 + diff --git a/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt b/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt index 482a2cb..ba997a5 100644 --- a/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt +++ b/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt @@ -3,6 +3,11 @@ package dev.byteclaw.client import dev.byteclaw.client.data.AgentEvent import dev.byteclaw.client.data.Message import dev.byteclaw.client.data.ProviderPresets +import dev.byteclaw.client.data.ProvidersResponse +import dev.byteclaw.client.data.ModelsResponse +import dev.byteclaw.client.data.CancelResponse +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse import dev.byteclaw.client.data.ServerInfo import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals @@ -28,4 +33,17 @@ class ModelsTest { assertEquals("Dahl (dahl.global)", ProviderPresets.label("dahl")) assertEquals("x", ProviderPresets.label("x")) } + + @Test fun decodesProvidersWithoutSecrets() { + val r = json.decodeFromString(ProvidersResponse.serializer(), + """{"active":{"provider":"dahl","model":"MiniMaxAI/MiniMax-M2.7"},"providers":[{"name":"dahl","base_url":"https://inference.dahl.global/v1","default_model":"MiniMaxAI/MiniMax-M2.7","configured":true,"active":true},{"name":"openrouter","configured":false}]}""") + assertEquals("dahl", r.active.provider) + assertTrue(r.providers[0].active); assertFalse(r.providers[1].configured) + } + + @Test fun decodesModelsCancelAndCancelledMessage() { + assertEquals(listOf("a", "b"), json.decodeFromString(ModelsResponse.serializer(), """{"models":["a","b"]}""").models) + assertTrue(json.decodeFromString(CancelResponse.serializer(), """{"cancelled":true}""").cancelled) + assertTrue(json.decodeFromString(Message.serializer(), """{"role":"assistant","content":"halb","cancelled":true}""").cancelled) + } } diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/IconScreenshot.kt b/android/app/src/test/java/dev/byteclaw/client/preview/IconScreenshot.kt new file mode 100644 index 0000000..d8f314b --- /dev/null +++ b/android/app/src/test/java/dev/byteclaw/client/preview/IconScreenshot.kt @@ -0,0 +1,51 @@ +package dev.byteclaw.client.preview + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import dev.byteclaw.client.R +import org.junit.Rule +import org.junit.Test + +/** Renders the adaptive launcher icon layers: round mask, squircle mask and Android 13 monochrome (themed). */ +class IconScreenshot { + @get:Rule + val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6, maxPercentDifference = 0.1) + + @androidx.compose.runtime.Composable + private fun Layered(shape: Shape, mono: Boolean = false) { + Box(Modifier.padding(12.dp).size(112.dp).clip(shape).background(if (mono) Color(0xFFE3E8EF) else Color.Unspecified)) { + if (!mono) Image(painterResource(R.drawable.ic_launcher_background), null, Modifier.fillMaxSize()) + Image( + painterResource(if (mono) R.drawable.ic_launcher_monochrome else R.drawable.ic_launcher_foreground), null, Modifier.fillMaxSize(), + colorFilter = if (mono) ColorFilter.tint(Color(0xFF2C3E50)) else null, + ) + } + } + + @Test fun icon() = paparazzi.snapshot("icon") { + Box(Modifier.fillMaxSize().background(Color.White), contentAlignment = Alignment.Center) { + Row { + Layered(CircleShape) + Layered(RoundedCornerShape(30)) + Layered(CircleShape, mono = true) + } + } + } +} diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt index d584b34..721b4fc 100644 --- a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt +++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt @@ -11,6 +11,9 @@ import dev.byteclaw.client.ui.ChatScreen import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SettingsScreen import dev.byteclaw.client.ui.UiState +import dev.byteclaw.client.ui.ProviderUi +import dev.byteclaw.client.data.ProviderInfo +import dev.byteclaw.client.data.ActiveProvider import androidx.compose.runtime.Composable import org.junit.Rule import org.junit.Test @@ -62,6 +65,7 @@ class PreviewScreenshots(private val dark: Boolean) { Session("2", "[DEMO] Einkaufsliste für das Wochenende", "2026-10-24T18:05:00Z"), Session("3", "[DEMO] Zusammenfassung README.md", "2026-10-23T09:41:00Z"), Session("4", "[DEMO] Ideen für den Android-Client", "2026-10-21T20:15:00Z"), + Session("5", "[DEMO] Sehr langer Session-Titel, der in der Liste sauber mit Auslassungspunkten abgeschnitten wird", "2026-10-20T08:00:00Z"), ), ), onRefresh = {}, onOpen = {}, onDelete = {}, onBack = {}, @@ -74,4 +78,24 @@ class PreviewScreenshots(private val dark: Boolean) { initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]", ) } + + @Test fun providers() = snap("providers") { + SettingsScreen( + state = UiState( + settings = settings, + provider = ProviderUi( + active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"), + providers = listOf( + ProviderInfo("dahl", "https://inference.dahl.global/v1", "MiniMaxAI/MiniMax-M2.7", configured = true, active = true), + ProviderInfo("gonkarouter", "https://api.gonkarouter.io/v1", "MiniMaxAI/MiniMax-M2.7", configured = true), + ProviderInfo("ollama", "http://127.0.0.1:11434/v1", "llama3.1", configured = true), + ProviderInfo("openai", "https://api.openai.com/v1", "gpt-4o-mini", configured = false), + ProviderInfo("openrouter", "https://openrouter.ai/api/v1", "openai/gpt-4o-mini", configured = false), + ), + status = "[DEMO] Aktiv: Dahl (dahl.global) · MiniMaxAI/MiniMax-M2.7", + ), + ), + onSave = {}, onTest = { _, _ -> }, onBack = {}, + ) + } } diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_IconScreenshot_icon_icon.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_IconScreenshot_icon_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a5d483373bfc5ae7432f641774316cfa836609d3 GIT binary patch literal 17924 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