feat(android): per-session provider chip in chat header
This commit is contained in:
parent
6ff31589be
commit
46d98502dd
15 changed files with 123 additions and 5 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) } }
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
Loading…
Add table
Add a link
Reference in a new issue