feat(android): provider selection, stop button, adaptive icon, release signing; docs + previews
|
|
@ -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 |