feat(android): provider selection, stop button, adaptive icon, release signing; docs + previews

This commit is contained in:
OpenClaw Dev 2026-10-25 04:52:27 +01:00
parent 49fdc5a189
commit 6ff31589be
38 changed files with 468 additions and 41 deletions

View file

@ -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. - 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`. - `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. - 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. - 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. - 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 | | GET | /api/sessions/{id}/messages | Verlauf |
| POST | /api/chat | Nicht-streamend `{"session_id"?, "text"}`| | POST | /api/chat | Nicht-streamend `{"session_id"?, "text"}`|
| GET | /api/ws | WebSocket-Streaming | | 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>`. Alle `/api/*` brauchen `Authorization: Bearer <token>`.
WebSocket: Client sendet `{"type":"message","session_id":"<optional>","text":"..."}`; Server sendet Events WebSocket: Client sendet `{"type":"message","session_id":"<optional>","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.<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 ### Build, Tests, Cross-Builds
@ -124,18 +140,37 @@ Build (JDK 17 + Android SDK, `ANDROID_HOME` oder `local.properties` mit `sdk.dir
cd android cd android
./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk ./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest ./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. 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 ## Nächste Schritte
- Release-Signing, App-Icon, CI (GitHub Actions) für Go + Android - CI (GitHub Actions) für Go + Android
- Session-Kontext zusammenfassen statt nur Fenster der letzten N Nachrichten - Weitere Provider (Anthropic nativ)
- Weitere Provider (Anthropic nativ), Abbrechen laufender Antworten über WebSocket - App: Provider pro Session wählen (API existiert schon)
- Optional: Einrichtung per QR-Code (URL + Token) - Optional: Einrichtung per QR-Code (URL + Token)
## Vorschau ## 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`.

3
android/.gitignore vendored
View file

@ -4,3 +4,6 @@ app/build/
local.properties local.properties
.idea/ .idea/
*.iml *.iml
keystore.properties
*.jks
*.keystore

View file

@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@ -6,6 +8,21 @@ plugins {
alias(libs.plugins.paparazzi) // screenshot tests only (JVM, test source set) 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 { android {
namespace = "dev.byteclaw.client" namespace = "dev.byteclaw.client"
compileSdk = 35 compileSdk = 35
@ -18,8 +35,22 @@ android {
versionName = "0.1.0" 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 { buildTypes {
release { release {
if (hasReleaseSigning) signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }

View file

@ -4,7 +4,8 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@android:drawable/sym_def_app_icon" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.ByteClaw"> android:theme="@style/Theme.ByteClaw">
<activity <activity

View file

@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
composable("chat") { composable("chat") {
ChatScreen( ChatScreen(
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError, state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
) )
} }
composable("sessions") { composable("sessions") {
@ -48,7 +48,8 @@ class MainActivity : ComponentActivity() {
composable("settings") { composable("settings") {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = { SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } } if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
}) }, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
onSelectProvider = { p, m -> vm.selectProvider(p, m) })
} }
} }
} }

View file

@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
@ -51,6 +53,22 @@ class ByteClawApi(private val settings: ServerSettings) {
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build()) 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<String> =
json.decodeFromString<ModelsResponse>(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<CancelResponse>(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". * 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) { override fun onMessage(webSocket: WebSocket, text: String) {
val ev = runCatching { json.decodeFromString(AgentEvent.serializer(), text) }.getOrNull() ?: return val ev = runCatching { json.decodeFromString(AgentEvent.serializer(), text) }.getOrNull() ?: return
trySend(ev) trySend(ev)
if (ev.type == "done" || ev.type == "error") { if (ev.type == "done" || ev.type == "error" || ev.type == "cancelled") {
webSocket.close(1000, null) webSocket.close(1000, null)
close() close()
} }
@ -82,6 +100,7 @@ class ByteClawApi(private val settings: ServerSettings) {
} }
companion object { companion object {
private val JSON = "application/json".toMediaType()
private val shared = OkHttpClient.Builder() private val shared = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // streaming responses can be long .readTimeout(0, TimeUnit.SECONDS) // streaming responses can be long

View file

@ -20,6 +20,7 @@ data class Message(
val content: String = "", val content: String = "",
@SerialName("tool_calls") val toolCalls: List<ToolCall> = emptyList(), @SerialName("tool_calls") val toolCalls: List<ToolCall> = emptyList(),
val name: String = "", val name: String = "",
val cancelled: Boolean = false,
) )
/** Event streamed by the server over the WebSocket. */ /** Event streamed by the server over the WebSocket. */
@ -49,6 +50,28 @@ data class ServerInfo(
@SerialName("base_url") val baseUrl: String = "", @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<ProviderInfo> = emptyList())
@Serializable
data class ModelsResponse(val models: List<String> = emptyList())
@Serializable
data class CancelResponse(val cancelled: Boolean = false)
/** Provider presets known to the server (configured server-side via llm.provider). */ /** Provider presets known to the server (configured server-side via llm.provider). */
object ProviderPresets { object ProviderPresets {
val names = mapOf( val names = mapOf(

View file

@ -3,7 +3,9 @@ package dev.byteclaw.client.ui
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.byteclaw.client.data.ActiveProvider
import dev.byteclaw.client.data.ByteClawApi import dev.byteclaw.client.data.ByteClawApi
import dev.byteclaw.client.data.ProviderInfo
import dev.byteclaw.client.data.ProviderPresets import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ServerSettings import dev.byteclaw.client.data.ServerSettings
import dev.byteclaw.client.data.Session import dev.byteclaw.client.data.Session
@ -14,7 +16,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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<ProviderInfo> = emptyList(),
val active: ActiveProvider? = null,
val models: List<String> = emptyList(),
val modelsFor: String? = null,
val status: String? = null,
)
data class UiState( data class UiState(
val settings: ServerSettings = ServerSettings(), val settings: ServerSettings = ServerSettings(),
@ -23,6 +34,7 @@ data class UiState(
val sessions: List<Session> = emptyList(), val sessions: List<Session> = emptyList(),
val busy: Boolean = false, val busy: Boolean = false,
val error: String? = null, val error: String? = null,
val provider: ProviderUi = ProviderUi(),
) )
class ChatViewModel(app: Application) : AndroidViewModel(app) { class ChatViewModel(app: Application) : AndroidViewModel(app) {
@ -67,7 +79,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
when (m.role) { when (m.role) {
"user" -> listOf(ChatLine("user", m.content)) "user" -> listOf(ChatLine("user", m.content))
"assistant" -> buildList { "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)) } m.toolCalls.forEach { add(ChatLine("tool", it.arguments, it.name)) }
} }
else -> emptyList() else -> emptyList()
@ -104,6 +116,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
"done" -> _state.update { s -> "done" -> _state.update { s ->
s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
} }
"cancelled" -> markCancelled()
"error" -> _state.update { s -> "error" -> _state.update { s ->
s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) 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 -> private fun appendToLast(delta: String) = _state.update { s ->
val lines = s.lines.toMutableList() val lines = s.lines.toMutableList()
val last = lines.lastOrNull() val last = lines.lastOrNull()

View file

@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.byteclaw.client.data.ByteClawApi import dev.byteclaw.client.data.ByteClawApi
import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ServerSettings import dev.byteclaw.client.data.ServerSettings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -37,6 +40,7 @@ fun ChatScreen(
onDismissError: () -> Unit, onDismissError: () -> Unit,
onSessions: () -> Unit, onSessions: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onStop: () -> Unit = {},
initialInput: String = "", initialInput: String = "",
) { ) {
var input by remember { mutableStateOf(initialInput) } var input by remember { mutableStateOf(initialInput) }
@ -78,10 +82,20 @@ fun ChatScreen(
placeholder = { Text("Nachricht…") }, maxLines = 5, placeholder = { Text("Nachricht…") }, maxLines = 5,
enabled = state.settings.isConfigured, enabled = state.settings.isConfigured,
) )
IconButton( if (state.busy) {
onClick = { onSend(input); input = "" }, FilledTonalIconButton(
enabled = !state.busy && input.isNotBlank() && state.settings.isConfigured, onClick = onStop,
) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") } 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 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( Text(
line.text.ifEmpty { "…" }, line.text.ifEmpty { "…" },
Modifier.widthIn(max = 320.dp) Modifier.widthIn(max = 320.dp)
@ -108,6 +122,10 @@ private fun Bubble(line: ChatLine) {
RoundedCornerShape(14.dp), RoundedCornerShape(14.dp),
).padding(horizontal = 12.dp, vertical = 8.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()) { LazyColumn(Modifier.padding(pad).fillMaxSize()) {
items(state.sessions, key = { it.id }) { s -> items(state.sessions, key = { it.id }) { s ->
ListItem( 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") }, supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } }, trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
modifier = Modifier.clickable { onOpen(s.id) }, modifier = Modifier.clickable { onOpen(s.id) },
@ -149,38 +167,108 @@ fun SettingsScreen(
onSave: (ServerSettings) -> Unit, onSave: (ServerSettings) -> Unit,
onTest: (ServerSettings, (String) -> Unit) -> Unit, onTest: (ServerSettings, (String) -> Unit) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onLoadProviders: () -> Unit = {},
onLoadModels: (String) -> Unit = {},
onSelectProvider: (String, String) -> Unit = { _, _ -> },
initialStatus: String? = null, initialStatus: String? = null,
) { ) {
var url by remember { mutableStateOf(state.settings.serverUrl) } var url by remember { mutableStateOf(state.settings.serverUrl) }
var token by remember { mutableStateOf(state.settings.token) } var token by remember { mutableStateOf(state.settings.token) }
var status by remember { mutableStateOf(initialStatus) } var status by remember { mutableStateOf(initialStatus) }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url) val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") }, TopAppBar(title = { Text("Einstellungen") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } }) navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
}) { pad -> }) { pad ->
Column(Modifier.padding(pad).padding(16.dp).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") }, item {
placeholder = { Text("https://claw.example.org") }, singleLine = true, OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
isError = urlError != null, supportingText = { urlError?.let { Text(it) } }, placeholder = { Text("https://claw.example.org") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) isError = urlError != null, supportingText = { urlError?.let { Text(it) } },
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
visualTransformation = PasswordVisualTransformation(), }
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) item {
Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.", OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true,
style = MaterialTheme.typography.bodySmall) visualTransformation = PasswordVisualTransformation(),
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = { }
onSave(ServerSettings(url, token)); onBack() item {
}) { Text("Speichern") } Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.",
OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = { style = MaterialTheme.typography.bodySmall)
status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it } }
}) { Text("Verbindung testen") } 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) }
}
}

View file

@ -4,8 +4,27 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable 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 @Composable
fun ByteClawTheme(dark: Boolean, content: @Composable () -> Unit) { 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)
} }

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="@color/brand_ink" android:pathData="M0,0h108v108h-108z" />
<!-- subtle byte grid -->
<path android:fillColor="#1D2C3D" android:pathData="M0,27h108v1h-108zM0,54h108v1h-108zM0,81h108v1h-108zM27,0h1v108h-1zM54,0h1v108h-1zM81,0h1v108h-1z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<!-- three claw scratches -->
<path android:strokeColor="@color/brand_claw" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M45,33 Q38,53 33,74" />
<path android:strokeColor="@color/brand_claw" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M57,30 Q50,53 45,78" />
<path android:strokeColor="@color/brand_claw" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M69,33 Q62,53 57,74" />
<!-- byte bits -->
<path android:fillColor="@color/brand_byte" android:pathData="M70,52h7v7h-7z M70,64h7v7h-7z" />
<path android:strokeColor="@color/brand_byte" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M70.8,40.8h5.4v5.4h-5.4z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<!-- three claw scratches -->
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M45,33 Q38,53 33,74" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M57,30 Q50,53 45,78" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="7" android:strokeLineCap="round" android:fillColor="#00000000"
android:pathData="M69,33 Q62,53 57,74" />
<!-- byte bits -->
<path android:fillColor="#FFFFFFFF" android:pathData="M70,52h7v7h-7z M70,64h7v7h-7z" />
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M70.8,40.8h5.4v5.4h-5.4z" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand colours, kept in sync with ui/Theme.kt (Brand) -->
<color name="brand_claw">#FF6A3D</color>
<color name="brand_ink">#14202E</color>
<color name="brand_byte">#39D3C3</color>
</resources>

View file

@ -3,6 +3,11 @@ package dev.byteclaw.client
import dev.byteclaw.client.data.AgentEvent import dev.byteclaw.client.data.AgentEvent
import dev.byteclaw.client.data.Message import dev.byteclaw.client.data.Message
import dev.byteclaw.client.data.ProviderPresets 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 dev.byteclaw.client.data.ServerInfo
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -28,4 +33,17 @@ class ModelsTest {
assertEquals("Dahl (dahl.global)", ProviderPresets.label("dahl")) assertEquals("Dahl (dahl.global)", ProviderPresets.label("dahl"))
assertEquals("x", ProviderPresets.label("x")) 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)
}
} }

View file

@ -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)
}
}
}
}

View file

@ -11,6 +11,9 @@ import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.UiState 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 androidx.compose.runtime.Composable
import org.junit.Rule import org.junit.Rule
import org.junit.Test 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("2", "[DEMO] Einkaufsliste für das Wochenende", "2026-10-24T18:05:00Z"),
Session("3", "[DEMO] Zusammenfassung README.md", "2026-10-23T09:41: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("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 = {}, 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]", 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 = {},
)
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

View file

@ -0,0 +1,5 @@
# Copy to keystore.properties (gitignored) – never commit real values.
storeFile=~/byteclaw-keys/byteclaw-release.jks
storePassword=CHANGE_ME
keyAlias=byteclaw
keyPassword=CHANGE_ME

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

BIN
preview/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
preview/providers_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
preview/providers_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After