feat(android): provider selection, stop button, adaptive icon, release signing; docs + previews
49
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 <token>`.
|
||||
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
|
||||
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
3
android/.gitignore
vendored
|
|
@ -4,3 +4,6 @@ app/build/
|
|||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
<application
|
||||
android:allowBackup="false"
|
||||
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:theme="@style/Theme.ByteClaw">
|
||||
<activity
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
|||
composable("chat") {
|
||||
ChatScreen(
|
||||
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") {
|
||||
|
|
@ -48,7 +48,8 @@ class MainActivity : ComponentActivity() {
|
|||
composable("settings") {
|
||||
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> 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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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".
|
||||
*/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ data class Message(
|
|||
val content: String = "",
|
||||
@SerialName("tool_calls") val toolCalls: List<ToolCall> = 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<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). */
|
||||
object ProviderPresets {
|
||||
val names = mapOf(
|
||||
|
|
|
|||
|
|
@ -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<ProviderInfo> = emptyList(),
|
||||
val active: ActiveProvider? = null,
|
||||
val models: List<String> = 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<Session> = 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()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
14
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
14
android/app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
7
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 54 KiB |
5
android/keystore.properties.example
Normal 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
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
BIN
preview/icon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
preview/providers_dark.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
preview/providers_light.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 54 KiB |