feat(android): per-session provider chip in chat header

This commit is contained in:
OpenClaw Dev 2026-10-25 05:36:47 +01:00
parent 6ff31589be
commit 46d98502dd
15 changed files with 123 additions and 5 deletions

View file

@ -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") {

View file

@ -65,6 +65,15 @@ class ByteClawApi(private val settings: ServerSettings) {
suspend fun models(provider: String): List<String> =
json.decodeFromString<ModelsResponse>(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<CancelResponse>(call(Request.Builder().url(url("/api/sessions/$sessionId/cancel")).post(ByteArray(0).toRequestBody(null)).build())).cancelled

View file

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

View file

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

View file

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

View file

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

View file

@ -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(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB