feat(android): per-session provider chip in chat header
|
|
@ -33,9 +33,11 @@ class MainActivity : ComponentActivity() {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
|
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
|
androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() }
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
|
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
|
||||||
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
|
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
|
||||||
|
onSessionProvider = { vm.setSessionProvider(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("sessions") {
|
composable("sessions") {
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,15 @@ class ByteClawApi(private val settings: ServerSettings) {
|
||||||
suspend fun models(provider: String): List<String> =
|
suspend fun models(provider: String): List<String> =
|
||||||
json.decodeFromString<ModelsResponse>(call(Request.Builder().url(url("/api/providers/$provider/models")).build())).models
|
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. */
|
/** Stops the running answer of a session; the partial answer is kept on the server. */
|
||||||
suspend fun cancel(sessionId: String): Boolean =
|
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
|
json.decodeFromString<CancelResponse>(call(Request.Builder().url(url("/api/sessions/$sessionId/cancel")).post(ByteArray(0).toRequestBody(null)).build())).cancelled
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ data class Session(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
@SerialName("updated_at") val updatedAt: String = "",
|
@SerialName("updated_at") val updatedAt: String = "",
|
||||||
|
val provider: String = "",
|
||||||
|
val model: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,13 @@ data class UiState(
|
||||||
val busy: Boolean = false,
|
val busy: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val provider: ProviderUi = ProviderUi(),
|
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) {
|
class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
private val store = SettingsStore(app)
|
private val store = SettingsStore(app)
|
||||||
|
|
@ -60,7 +66,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
fun newChat() {
|
fun newChat() {
|
||||||
job?.cancel()
|
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) }
|
fun dismissError() = _state.update { it.copy(error = null) }
|
||||||
|
|
@ -85,7 +91,10 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
else -> emptyList()
|
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) } }
|
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +115,12 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
job = viewModelScope.launch {
|
job = viewModelScope.launch {
|
||||||
api().stream(_state.value.sessionId, t).collect { ev ->
|
api().stream(_state.value.sessionId, t).collect { ev ->
|
||||||
when (ev.type) {
|
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)
|
"delta" -> appendToLast(ev.text)
|
||||||
"tool_call" -> _state.update {
|
"tool_call" -> _state.update {
|
||||||
// keep a fresh assistant bubble after the tool line for the follow-up answer
|
// 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)
|
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 {
|
fun loadProviders() = viewModelScope.launch {
|
||||||
runCatching { api().providers() }
|
runCatching { api().providers() }
|
||||||
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
|
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,9 @@ fun ChatScreen(
|
||||||
onSessions: () -> Unit,
|
onSessions: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
onStop: () -> Unit = {},
|
onStop: () -> Unit = {},
|
||||||
|
onSessionProvider: (String) -> Unit = {},
|
||||||
initialInput: String = "",
|
initialInput: String = "",
|
||||||
|
initialMenuOpen: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var input by remember { mutableStateOf(initialInput) }
|
var input by remember { mutableStateOf(initialInput) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
@ -51,7 +53,12 @@ fun ChatScreen(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("ByteClaw") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("ByteClaw")
|
||||||
|
SessionProviderChip(state, onSessionProvider, initialMenuOpen)
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = { IconButton(onClick = onSessions) { Icon(Icons.AutoMirrored.Filled.List, "Sessions") } },
|
navigationIcon = { IconButton(onClick = onSessions) { Icon(Icons.AutoMirrored.Filled.List, "Sessions") } },
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onNewChat) { Icon(Icons.Filled.Add, "Neuer Chat") }
|
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) }
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,14 @@ class ModelsTest {
|
||||||
assertTrue(json.decodeFromString(CancelResponse.serializer(), """{"cancelled":true}""").cancelled)
|
assertTrue(json.decodeFromString(CancelResponse.serializer(), """{"cancelled":true}""").cancelled)
|
||||||
assertTrue(json.decodeFromString(Message.serializer(), """{"role":"assistant","content":"halb","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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
@Test fun sessions() = snap("sessions") {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
state = UiState(
|
state = UiState(
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
BIN
preview/chat_provider_dark.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
preview/chat_provider_light.png
Normal file
|
After Width: | Height: | Size: 30 KiB |