diff --git a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt index 253b5a3..47ee831 100644 --- a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt +++ b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt @@ -33,9 +33,11 @@ class MainActivity : ComponentActivity() { val nav = rememberNavController() NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") { composable("chat") { + androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() } ChatScreen( state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError, onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel, + onSessionProvider = { vm.setSessionProvider(it) }, ) } composable("sessions") { 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 9e7fbb1..bd592a8 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 @@ -65,6 +65,15 @@ class ByteClawApi(private val settings: ServerSettings) { suspend fun models(provider: String): List = json.decodeFromString(call(Request.Builder().url(url("/api/providers/$provider/models")).build())).models + suspend fun session(sessionId: String): Session = + json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId")).build())) + + /** Per-session provider override; empty provider resets to the global default. */ + suspend fun setSessionProvider(sessionId: String, 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/sessions/$sessionId/provider")).put(body).build())) + } + /** 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 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 cf1dffa..b01dd62 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 @@ -8,6 +8,8 @@ data class Session( val id: String, val title: String = "", @SerialName("updated_at") val updatedAt: String = "", + val provider: String = "", + val model: String = "", ) @Serializable 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 2ccc2e9..cdd9058 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 @@ -35,7 +35,13 @@ data class UiState( val busy: Boolean = false, val error: String? = null, val provider: ProviderUi = ProviderUi(), -) + /** Provider override of the current session (null = global default). */ + val sessionProvider: ActiveProvider? = null, +) { + /** What the current chat effectively uses: session override, else the global active provider. */ + val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active + val usesSessionProvider: Boolean get() = sessionProvider != null +} class ChatViewModel(app: Application) : AndroidViewModel(app) { private val store = SettingsStore(app) @@ -60,7 +66,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { fun newChat() { job?.cancel() - _state.update { it.copy(sessionId = null, lines = emptyList(), busy = false, error = null) } + _state.update { it.copy(sessionId = null, lines = emptyList(), busy = false, error = null, sessionProvider = null) } } fun dismissError() = _state.update { it.copy(error = null) } @@ -85,7 +91,10 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { else -> emptyList() } } - _state.update { it.copy(sessionId = id, lines = lines, busy = false) } + _state.update { it.copy(sessionId = id, lines = lines, busy = false, sessionProvider = null) } + runCatching { api().session(id) }.onSuccess { ss -> + _state.update { it.copy(sessionProvider = ss.provider.takeIf { p -> p.isNotBlank() }?.let { p -> ActiveProvider(p, ss.model) }) } + } } .onFailure { e -> _state.update { it.copy(error = e.message) } } } @@ -106,7 +115,12 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { job = viewModelScope.launch { api().stream(_state.value.sessionId, t).collect { ev -> when (ev.type) { - "session" -> _state.update { it.copy(sessionId = ev.sessionId) } + "session" -> { + val pending = _state.value.sessionProvider.takeIf { _state.value.sessionId == null } + _state.update { it.copy(sessionId = ev.sessionId) } + // The first answer already runs with the global provider; the pick applies from the next message on. + pending?.let { p -> launch { runCatching { api().setSessionProvider(ev.sessionId, p.provider, p.model) } } } + } "delta" -> appendToLast(ev.text) "tool_call" -> _state.update { // keep a fresh assistant bubble after the tool line for the follow-up answer @@ -146,6 +160,24 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { s.copy(busy = false, lines = lines) } + /** + * Picks the provider for the current session ("" = back to the global default). Before the first message + * there is no session yet, so the choice is kept locally and applied when the server creates the session. + */ + fun setSessionProvider(name: String, model: String = "") { + val sid = _state.value.sessionId + if (sid == null) { + val def = _state.value.provider.providers.firstOrNull { it.name == name }?.defaultModel.orEmpty() + _state.update { it.copy(sessionProvider = if (name.isBlank()) null else ActiveProvider(name, model.ifBlank { def })) } + return + } + viewModelScope.launch { + runCatching { api().setSessionProvider(sid, name, model) } + .onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } } + .onFailure { e -> _state.update { it.copy(error = e.message) } } + } + } + 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)) } } 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 61c5b2a..c290103 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 @@ -41,7 +41,9 @@ fun ChatScreen( onSessions: () -> Unit, onSettings: () -> Unit, onStop: () -> Unit = {}, + onSessionProvider: (String) -> Unit = {}, initialInput: String = "", + initialMenuOpen: Boolean = false, ) { var input by remember { mutableStateOf(initialInput) } val listState = rememberLazyListState() @@ -51,7 +53,12 @@ fun ChatScreen( Scaffold( topBar = { TopAppBar( - title = { Text("ByteClaw") }, + title = { + Column { + Text("ByteClaw") + SessionProviderChip(state, onSessionProvider, initialMenuOpen) + } + }, navigationIcon = { IconButton(onClick = onSessions) { Icon(Icons.AutoMirrored.Filled.List, "Sessions") } }, actions = { IconButton(onClick = onNewChat) { Icon(Icons.Filled.Add, "Neuer Chat") } @@ -272,3 +279,35 @@ private fun ProviderSection( p.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } } + +/** Chip under the title: shows the provider of this chat (session override or global) and lets the user switch it. */ +@Composable +private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initialOpen: Boolean) { + var open by remember { mutableStateOf(initialOpen) } + val eff = state.effectiveProvider + val label = if (eff == null) "Provider…" else ProviderPresets.label(eff.provider) + " · " + eff.model.substringAfterLast('/') + + if (state.usesSessionProvider) "" else " (Standard)" + Box { + AssistChip( + onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) }, + enabled = state.settings.isConfigured && !state.busy, + modifier = Modifier.height(28.dp).widthIn(max = 230.dp), + ) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + val global = state.provider.active + DropdownMenuItem( + text = { Text("Standard" + (global?.let { " – " + ProviderPresets.label(it.provider) } ?: "")) }, + leadingIcon = { RadioButton(selected = !state.usesSessionProvider, onClick = null) }, + onClick = { open = false; onPick("") }, + ) + HorizontalDivider() + state.provider.providers.filter { it.configured }.forEach { p -> + DropdownMenuItem( + text = { Column { Text(ProviderPresets.label(p.name)); Text(p.defaultModel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) } }, + leadingIcon = { RadioButton(selected = state.sessionProvider?.provider == p.name, onClick = null) }, + onClick = { open = false; onPick(p.name) }, + ) + } + } + } +} 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 ba997a5..a1a1fab 100644 --- a/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt +++ b/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt @@ -46,4 +46,14 @@ class ModelsTest { assertTrue(json.decodeFromString(CancelResponse.serializer(), """{"cancelled":true}""").cancelled) assertTrue(json.decodeFromString(Message.serializer(), """{"role":"assistant","content":"halb","cancelled":true}""").cancelled) } + + @Test fun effectiveProviderFallsBackToGlobal() { + val global = dev.byteclaw.client.data.ActiveProvider("dahl", "m1") + val base = dev.byteclaw.client.ui.UiState(provider = dev.byteclaw.client.ui.ProviderUi(active = global)) + assertEquals(global, base.effectiveProvider); assertFalse(base.usesSessionProvider) + val own = base.copy(sessionProvider = dev.byteclaw.client.data.ActiveProvider("gonkarouter", "m2")) + assertEquals("gonkarouter", own.effectiveProvider?.provider); assertTrue(own.usesSessionProvider) + val s = json.decodeFromString(dev.byteclaw.client.data.Session.serializer(), """{"id":"1","provider":"ollama","model":"llama3.1"}""") + assertEquals("ollama", s.provider) + } } 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 721b4fc..79b8f90 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 @@ -56,6 +56,30 @@ class PreviewScreenshots(private val dark: Boolean) { ) } + private val demoProviders = 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), + ), + ) + + @Test fun chatProvider() = snap("chat_provider") { + ChatScreen( + state = UiState( + settings = settings, sessionId = "demo", provider = demoProviders, + sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"), + lines = listOf( + ChatLine("user", "[DEMO] Antworte ab jetzt bitte über GonkaRouter."), + ChatLine("assistant", "[DEMO] Erledigt – diese Session nutzt jetzt GonkaRouter, andere Chats bleiben beim Standard."), + ), + ), + onSend = {}, onNewChat = {}, onDismissError = {}, onSessions = {}, onSettings = {}, + initialMenuOpen = true, + ) + } + @Test fun sessions() = snap("sessions") { SessionsScreen( state = UiState( diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatProvider[0]_chat_provider_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatProvider[0]_chat_provider_light.png new file mode 100644 index 0000000..d8a377f Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatProvider[0]_chat_provider_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatProvider[1]_chat_provider_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatProvider[1]_chat_provider_dark.png new file mode 100644 index 0000000..ab511dd Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chatProvider[1]_chat_provider_dark.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 e0efc22..47a48d0 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 9bb8241..3b89120 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/preview/chat_dark.png b/preview/chat_dark.png index 9bb8241..3b89120 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 e0efc22..47a48d0 100644 Binary files a/preview/chat_light.png and b/preview/chat_light.png differ diff --git a/preview/chat_provider_dark.png b/preview/chat_provider_dark.png new file mode 100644 index 0000000..ab511dd Binary files /dev/null and b/preview/chat_provider_dark.png differ diff --git a/preview/chat_provider_light.png b/preview/chat_provider_light.png new file mode 100644 index 0000000..d8a377f Binary files /dev/null and b/preview/chat_provider_light.png differ