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 0000000..a5d4833 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_IconScreenshot_icon_icon.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[0]_chat_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[0]_chat_light.png index bbe3ac8..e0efc22 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[0]_chat_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[0]_chat_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[1]_chat_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[1]_chat_dark.png index d6dc432..9bb8241 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[1]_chat_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[1]_chat_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png new file mode 100644 index 0000000..f50dc3c Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png new file mode 100644 index 0000000..3e99d6c Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png index 57c026b..a374911 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png index fc60aa4..f5a79b6 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png index 5dd66b5..e93b138 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png index 250a66e..d6e7320 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png differ diff --git a/android/keystore.properties.example b/android/keystore.properties.example new file mode 100644 index 0000000..6a9bff9 --- /dev/null +++ b/android/keystore.properties.example @@ -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 diff --git a/preview/chat_dark.png b/preview/chat_dark.png index d6dc432..9bb8241 100644 Binary files a/preview/chat_dark.png and b/preview/chat_dark.png differ diff --git a/preview/chat_light.png b/preview/chat_light.png index bbe3ac8..e0efc22 100644 Binary files a/preview/chat_light.png and b/preview/chat_light.png differ diff --git a/preview/icon.png b/preview/icon.png new file mode 100644 index 0000000..c3afcb7 Binary files /dev/null and b/preview/icon.png differ diff --git a/preview/providers_dark.png b/preview/providers_dark.png new file mode 100644 index 0000000..3e99d6c Binary files /dev/null and b/preview/providers_dark.png differ diff --git a/preview/providers_light.png b/preview/providers_light.png new file mode 100644 index 0000000..f50dc3c Binary files /dev/null and b/preview/providers_light.png differ diff --git a/preview/sessions_dark.png b/preview/sessions_dark.png index fc60aa4..f5a79b6 100644 Binary files a/preview/sessions_dark.png and b/preview/sessions_dark.png differ diff --git a/preview/sessions_light.png b/preview/sessions_light.png index 57c026b..a374911 100644 Binary files a/preview/sessions_light.png and b/preview/sessions_light.png differ diff --git a/preview/settings_dark.png b/preview/settings_dark.png index 250a66e..d6e7320 100644 Binary files a/preview/settings_dark.png and b/preview/settings_dark.png differ diff --git a/preview/settings_light.png b/preview/settings_light.png index 5dd66b5..e93b138 100644 Binary files a/preview/settings_light.png and b/preview/settings_light.png differ