* fix(#589): show in-session model picker when user sends /model Typing a bare '/model' in the chat composer now opens the model picker so the user can hot-swap the CURRENT session's model via the existing slash-command path, instead of hand-typing the provider/model string. - ChatViewModel.preloadModelOptions() caches GET /api/model/options at GatewayReady so the picker opens instantly; falls back to on-demand refresh if opened before preload finishes. - sendMessage() intercepts a bare '/model' (no arg) -> showModelPicker=true instead of dispatching; a typed '/model provider/model' still dispatches via handleSlashCommand. - sendSlashModel() routes through command.dispatch with session_id, so the swap is session-scoped (not the global POST /api/model/set). - Extracted ModelPickerDialog into ui/model/components and reused by both ModelScreen and ChatScreen (adds loading + empty states). - Added a top-bar model chip (current session model label) that opens the same picker. - Added unit tests: bare /model opens picker, picker selection hot-swaps via slash dispatch with session_id, typed /model with arg dispatches directly. Note: CI shows 2 pre-existing test failures (ChatViewModelTest.testToggleSearch, HermesWsClientTest.testAutoReconnect) that also fail on a clean main full suite (MockK static-mock leakage between classes). Unrelated to this change; compile + ktlint + the 3 new tests pass. * fix(#589): use backend-valid session-scoped /model command syntax The model picker now dispatches the canonical backend command form '/model <model> --provider <slug> --session' instead of the unsupported 'provider/model' slash form. The '--session' flag keeps the switch scoped to the current chat only (per-session override) and never writes the global model config, which matches the backend model-switch contract. - sendSlashModel builds '/model $model --provider $provider --session'. - Updated unit tests to assert the corrected arg payload ('gpt-4o --provider openai --session') and the fully-typed dispatch path. * fix(#589): add pinned-models section to in-session model picker - ModelPickerDialog now renders a Pinned section (mirrors global model screen) - search filter matches model names too, not just provider name/slug - ChatUiState gains modelPickerPinned, fed from AuthManager.getPinnedModels() - top-bar model chip removed; picker opens on /model only - test mock for getPinnedModels() added * fix(#589): route /model via prompt.submit instead of command.dispatch (resolves 4018) /model is a real backend slash command handled in the backend's message pipeline (_handle_message -> _handle_model_command), so it must arrive as a normal prompt.submit message, not the command.dispatch RPC (which only knows quick/plugin/bundle/skill commands and 4018s on /model). - SlashCommandDispatcher: add SlashResult.ModelSwitch, map /model -> ModelSwitch - ChatViewModel: handle ModelSwitch -> handleModelSwitch() sends via WsMethods.PROMPT_SUBMIT with {session_id, text} - Tests: rewrite the two /model tests to assert prompt.submit (previously asserted the buggy command.dispatch path); add /model -> ModelSwitch test Independent agy review: APPROVE, no blocking issues. * fix(#589): route /model via slash.exec (correct transport; prompt.submit made LLM treat it as text) Earlier attempt routed /model through prompt.submit, but the TUI gateway's prompt.submit handler does NOT parse slash commands — it runs the text as a normal agent turn, so the LLM received '/model ...' as a user message (the exact reported bug). The correct RPC is slash.exec, which runs the command through the slash worker and _mirror_slash_side_effects -> _apply_model_switch to hot-swap the live session model. command.dispatch is also wrong (only knows quick/plugin/bundle/skill commands -> 4018). - WsMethods: add SLASH_EXEC = "slash.exec" - ChatViewModel.handleModelSwitch: send slash.exec with {command, session_id} (previously prompt.submit with {text}); corrected doc comment - SlashCommandDispatcher: corrected comments (slash.exec, not prompt.submit) - ChatViewModelTest: rewrite both /model tests to assert slash.exec with the /model <model> --provider <slug> --session command string - SlashCommandDispatcherTest: /model -> ModelSwitch already covered Verified against hermes-agent tui_gateway/server.py: slash.exec (L13190) -> worker.run + _mirror_slash_side_effects (L13299) -> _apply_model_switch (L13118); parse_model_flags accepts --provider/--session (hermes_cli/model_switch.py:355). Independent agy review of the prior attempt: APPROVE (the wrong-transport assumption was the author's, not agy's). * fix(#589): route /model via config.set key=model (slash.exec 4001'd; prompt.submit made LLM treat it as text) Third transport attempt, now verified against the gateway source: - prompt.submit: gateway runs text as a normal agent turn -> LLM sees '/model' as a message (the original reported bug). WRONG. - slash.exec: gateway returns 4001 'session not found'. The slash worker path resolves the session differently / requires agent-built state the mobile session isn't in at switch time. WRONG. - config.set key=model: gateway (server.py config.set L10253) routes key=='model' straight to _apply_model_switch using the SAME _sessions.get(session_id) lookup that the working command.dispatch uses. CORRECT. sendSlashModel still builds '/model <model> --provider <slug> --session'; parse_model_flags (hermes_cli/model_switch.py:355) extracts model/provider/ session from it. handleModelSwitch now sends config.set {key:model, value: command, session_id}. - WsMethods: add CONFIG_SET = 'config.set' - ChatViewModel.handleModelSwitch: config.set instead of slash.exec - SlashCommandDispatcher: comments updated (config.set, not slash.exec) - ChatViewModelTest: both /model tests assert config.set key=model + command - also fixes the MockK testToggleSearch flake by preloading byte-buddy agent via JAVA_TOOL_OPTIONS locally (container blocks JVM self-attach) * fix(#589): strip /model prefix before config.set key=model (5001 'model names can't have spaces') config.set key=model passes the value DIRECTLY to parse_model_flags — it does NOT strip a leading '/model' the way slash.exec / prompt.submit do. Sending '/model <model> ...' made parse_model_flags treat '/model <model>' as the model name (contains a space) -> 5001. handleModelSwitch now strips the '/model' prefix and sends the bare spec '<model> --provider <slug> --session', matching the TUI client's modelValueForConfigSet (ui-tui session.ts:23) used by useMainApp.ts:123. - ChatViewModel.handleModelSwitch: spec = command.removePrefix('/model').trim() - ChatViewModelTest: assert bare spec 'gpt-4o --provider openai --session' - comment updated * fix(#589): strip /model prefix case-insensitively before config.set SlashCommandDispatcher and isModelPickerCommand match /model case-insensitively, but handleModelSwitch used removePrefix("/model") (case-sensitive). A typed /MODEL ... forwarded the literal slash prefix to config.set and the backend's parse_model_flags wouldn't recognize it — silent hot-swap failure. Use startsWith("/model", ignoreCase=true) + substring(6) so any casing is stripped. Added a regression test asserting /MODEL does not forward a leading slash. * fix(test): inject searchDispatcher to fix MockKException in testToggleSearch --------- Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
c543eb6470
commit
48f5ffbe52
8 files changed
+565
-66
No files matched your search
@@ -25,6 +25,8 @@ object WsMethods {
|
||||
const val COMMANDS_CATALOG = "commands.catalog"
|
||||
const val COMMAND_DISPATCH = "command.dispatch"
|
||||
const val COMMAND_RESOLVE = "command.resolve"
|
||||
const val SLASH_EXEC = "slash.exec"
|
||||
const val CONFIG_SET = "config.set"
|
||||
|
||||
// ── Attachments ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||
import com.m57.hermescontrol.ui.providers.ProvidersScreen
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -552,6 +553,20 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// In-session model picker (issue #589) — opens on "/model".
|
||||
if (state.showModelPicker) {
|
||||
ModelPickerDialog(
|
||||
providers = state.modelPickerProviders,
|
||||
title = "Switch model (this chat)",
|
||||
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
|
||||
pinnedModels = state.modelPickerPinned,
|
||||
onSelect = { provider, model ->
|
||||
viewModel.sendSlashModel(provider, model)
|
||||
},
|
||||
onDismiss = { viewModel.closeModelPicker() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
@@ -78,6 +80,15 @@ data class ChatUiState(
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
// Commands catalog
|
||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||
// In-session model picker (issue #589) — surfaced when the user types /model
|
||||
// (or taps the top-bar model chip). Mirror of the global model screen's
|
||||
// picker, but the selection hot-swaps the CURRENT session via the slash path.
|
||||
val showModelPicker: Boolean = false,
|
||||
val modelPickerProviders: List<ModelProvider> = emptyList(),
|
||||
val modelPickerPinned: List<PinnedModel> = emptyList(),
|
||||
val modelPickerLoading: Boolean = false,
|
||||
// Current session's active model label (provider/model), shown in the chip
|
||||
val currentSessionModel: String? = null,
|
||||
// Attachment state
|
||||
val pendingAttachments: List<Attachment> = emptyList(),
|
||||
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
||||
@@ -135,6 +146,7 @@ class ChatViewModel(
|
||||
ChatPersistenceRepository(
|
||||
HermesDatabase.get(application).chatMessageDao(),
|
||||
),
|
||||
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
) : AndroidViewModel(application) {
|
||||
constructor(application: Application) : this(application, startCleanup = true)
|
||||
|
||||
@@ -164,9 +176,17 @@ class ChatViewModel(
|
||||
ChatSearchDelegate(
|
||||
scope = viewModelScope,
|
||||
uiState = _uiState,
|
||||
dispatcher = searchDispatcher,
|
||||
)
|
||||
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
|
||||
|
||||
/**
|
||||
* Model options cached from GET /api/model/options so the in-session model
|
||||
* picker (issue #589) opens instantly when the user types /model or taps the
|
||||
* top-bar chip. Preloaded at GatewayReady; refreshed on open if empty.
|
||||
*/
|
||||
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
||||
|
||||
private val streamingController =
|
||||
ChatStreamingController(
|
||||
scope = viewModelScope,
|
||||
@@ -278,6 +298,7 @@ class ChatViewModel(
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
fetchCommandCatalog()
|
||||
preloadModelOptions()
|
||||
val currentId = _uiState.value.currentSessionId
|
||||
if (currentId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -653,6 +674,12 @@ class ChatViewModel(
|
||||
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.startsWith("/", ignoreCase = true)) {
|
||||
// Issue #589: a bare "/model" (no argument) opens the picker instead
|
||||
// of requiring the user to hand-type the provider/model.
|
||||
if (isModelPickerCommand(trimmed)) {
|
||||
openModelPicker()
|
||||
return
|
||||
}
|
||||
handleSlashCommand(trimmed)
|
||||
return
|
||||
}
|
||||
@@ -841,6 +868,10 @@ class ChatViewModel(
|
||||
branchSession(command)
|
||||
}
|
||||
|
||||
is SlashResult.ModelSwitch -> {
|
||||
handleModelSwitch(command)
|
||||
}
|
||||
|
||||
is SlashResult.RpcDispatch -> {
|
||||
dispatchViaRpc(command)
|
||||
}
|
||||
@@ -889,6 +920,51 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-swap the current session's model via the backend's model-switch
|
||||
* mechanism (issue #589).
|
||||
*
|
||||
* The TUI gateway's `prompt.submit` does NOT parse slash commands (it would
|
||||
* make the LLM treat "/model ..." as a chat message), and `command.dispatch`
|
||||
* only knows quick/plugin/bundle/skill commands (4018s on /model). The
|
||||
* correct RPC is `config.set` with `key="model"` — the gateway (server.py
|
||||
* `config.set`, L10253) routes `key=="model"` straight to `_apply_model_switch`
|
||||
* using the same `_sessions.get(session_id)` lookup that the working
|
||||
* `command.dispatch` uses.
|
||||
*
|
||||
* IMPORTANT: `config.set` key=model passes the value DIRECTLY to
|
||||
* `parse_model_flags` (it does NOT strip a leading "/model" like slash.exec /
|
||||
* prompt.submit do). So we strip the "/model" prefix here and send the bare
|
||||
* spec `parse_model_flags` understands:
|
||||
* `<model> --provider <slug> --session`
|
||||
* (matching the TUI client's `modelValueForConfigSet`).
|
||||
*/
|
||||
private fun handleModelSwitch(command: String) {
|
||||
val sessionId = runtimeSessionId
|
||||
if (sessionId == null) {
|
||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||
return
|
||||
}
|
||||
// Strip a leading "/model" (and any following whitespace) — config.set
|
||||
// key=model expects the bare spec, not a slash command. Match the
|
||||
// dispatcher's case-insensitive "/model" detection so a typed "/MODEL"
|
||||
// (or any casing) doesn't forward the literal slash prefix to the
|
||||
// backend, where parse_model_flags wouldn't recognize it.
|
||||
val spec =
|
||||
if (command.startsWith("/model", ignoreCase = true)) {
|
||||
command.substring(6).trim()
|
||||
} else {
|
||||
command.trim()
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.CONFIG_SET,
|
||||
mapOf("key" to "model", "value" to spec, "session_id" to sessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits [text] as a prompt to the current session via WS, without
|
||||
* adding a duplicate user message. Used by [handleDispatchResult] when
|
||||
@@ -994,6 +1070,118 @@ class ChatViewModel(
|
||||
null
|
||||
}
|
||||
|
||||
// ── In-session model picker (issue #589) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether [command] should open the in-session model picker instead of being
|
||||
* dispatched as a normal slash command. True for a bare `/model` (with no
|
||||
* trailing model argument) — the picker supplies the argument interactively.
|
||||
* A fully-typed `/model provider/model` is forwarded straight to the backend.
|
||||
*/
|
||||
private fun isModelPickerCommand(command: String): Boolean {
|
||||
val trimmed = command.trim()
|
||||
if (!trimmed.startsWith("/", ignoreCase = true)) return false
|
||||
val body = trimmed.removePrefix("/").trimStart()
|
||||
// Must be exactly "model" with no argument (or just whitespace).
|
||||
return body.equals("model", ignoreCase = true) ||
|
||||
body.startsWith("model ", ignoreCase = true) &&
|
||||
body.substringAfter("model").trim().isEmpty()
|
||||
}
|
||||
|
||||
/** Preload model options so the picker opens instantly (no spinner on /model). */
|
||||
private fun preloadModelOptions() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getModelOptions(refresh = false)
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
_uiState.update { it.copy(modelPickerPinned = AuthManager.getPinnedModels()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the in-session model picker. Uses the preloaded options if available
|
||||
* (instant open); otherwise shows a loading state and fetches them. The
|
||||
* `/model` slash command is the supported session hot-swap mechanism per the
|
||||
* backend contract (issue #589).
|
||||
*/
|
||||
fun openModelPicker() {
|
||||
val hasCached = cachedModelOptions.isNotEmpty()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = true,
|
||||
modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(),
|
||||
modelPickerPinned = AuthManager.getPinnedModels(),
|
||||
modelPickerLoading = !hasCached,
|
||||
)
|
||||
}
|
||||
if (!hasCached) {
|
||||
refreshModelOptions()
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetch options (pull-to-refresh style) when the picker is already open. */
|
||||
fun refreshModelOptions() {
|
||||
_uiState.update { it.copy(modelPickerLoading = true) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getModelOptions(refresh = true)
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = cachedModelOptions,
|
||||
modelPickerLoading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
modelPickerLoading = false,
|
||||
errorMessage = "Failed to load models: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeModelPicker() {
|
||||
_uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-swap the CURRENT session's model via the /model slash command.
|
||||
*
|
||||
* Builds the backend-valid command form `/model <model> --provider <slug>
|
||||
* --session`. The `--session` flag keeps the switch scoped to this chat
|
||||
* only (it writes a per-session override and never touches the global
|
||||
* model config), per the backend model-switch contract.
|
||||
*/
|
||||
fun sendSlashModel(
|
||||
provider: String,
|
||||
model: String,
|
||||
) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = false,
|
||||
modelPickerLoading = false,
|
||||
// Optimistic: reflect the chosen model in the top-bar chip until
|
||||
// the next session sync confirms the backend hot-swap.
|
||||
currentSessionModel = "$provider/$model",
|
||||
)
|
||||
}
|
||||
handleSlashCommand("/model $model --provider $provider --session")
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@ package com.m57.hermescontrol.ui.chat
|
||||
* Pure logic — no I/O, no Android dependencies.
|
||||
*
|
||||
* Only commands that MUST be handled client-side (immediate UX) are here.
|
||||
* Everything else is forwarded to the backend via [SlashResult.RpcDispatch].
|
||||
* `/fork` and `/model` are NOT sent via [SlashResult.RpcDispatch] (which maps
|
||||
* to the `command.dispatch` RPC — that RPC only knows quick/plugin/bundle/skill
|
||||
* commands and 4018s on everything else). They are real backend commands that
|
||||
* get their own results: `/fork` goes via the `session.branch` RPC and `/model`
|
||||
* via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the
|
||||
* TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as
|
||||
* a normal prompt makes the LLM treat it as text). Everything else is forwarded
|
||||
* to the backend via [SlashResult.RpcDispatch].
|
||||
*/
|
||||
class SlashCommandDispatcher {
|
||||
fun dispatch(command: String): SlashResult {
|
||||
@@ -18,6 +25,7 @@ class SlashCommandDispatcher {
|
||||
"/stop", "/interrupt" -> SlashResult.Interrupt
|
||||
"/new" -> SlashResult.NewSession
|
||||
"/fork", "/branch" -> SlashResult.SessionBranch
|
||||
"/model" -> SlashResult.ModelSwitch
|
||||
else -> SlashResult.RpcDispatch
|
||||
}
|
||||
}
|
||||
@@ -38,4 +46,13 @@ sealed class SlashResult {
|
||||
|
||||
/** Fork the active conversation via the session.branch WebSocket RPC. */
|
||||
data object SessionBranch : SlashResult()
|
||||
|
||||
/**
|
||||
* Hot-swap the current session's model via the backend `config.set` RPC
|
||||
* (key="model" → `_apply_model_switch`). NOT command.dispatch (4018s on
|
||||
* /model) and NOT prompt.submit (LLM would treat it as text). The value
|
||||
* string carries the flags `parse_model_flags` understands:
|
||||
* `/model <model> --provider <slug> --session`.
|
||||
*/
|
||||
data object ModelSwitch : SlashResult()
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SearchBar
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
|
||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -241,6 +242,7 @@ fun ModelScreen(
|
||||
ModelPickerDialog(
|
||||
providers = state.providers,
|
||||
title = "Set Main Model",
|
||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||
onSelect = { provider, model ->
|
||||
viewModel.setMainModel(provider, model)
|
||||
},
|
||||
@@ -264,6 +266,7 @@ fun ModelScreen(
|
||||
ModelPickerDialog(
|
||||
providers = state.providers,
|
||||
title = "Set Aux: ${state.auxPickerTask}",
|
||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||
onSelect = { provider, model ->
|
||||
viewModel.setAuxTask(state.auxPickerTask, provider, model)
|
||||
},
|
||||
@@ -542,70 +545,6 @@ private fun AuxTasksDialog(
|
||||
// Model Picker Dialog
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ModelPickerDialog(
|
||||
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
|
||||
title: String,
|
||||
onSelect: (provider: String, model: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var pickerQuery by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
SearchBar(
|
||||
query = pickerQuery,
|
||||
onQueryChange = { pickerQuery = it },
|
||||
placeholder = "Search providers...",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.height(400.dp)) {
|
||||
items(
|
||||
providers.filter {
|
||||
pickerQuery.isBlank() ||
|
||||
it.name.contains(pickerQuery, ignoreCase = true) ||
|
||||
it.slug.contains(pickerQuery, ignoreCase = true)
|
||||
},
|
||||
key = { it.slug },
|
||||
) { provider ->
|
||||
val models =
|
||||
provider.models.orEmpty().filter {
|
||||
pickerQuery.isBlank() ||
|
||||
it.contains(pickerQuery, ignoreCase = true)
|
||||
}
|
||||
|
||||
if (models.isNotEmpty()) {
|
||||
Text(
|
||||
text = provider.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
|
||||
models.forEach { model ->
|
||||
OutlinedButton(
|
||||
onClick = { onSelect(provider.slug, model) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(text = model, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Pinned Section Card — extracted from existing screen
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.m57.hermescontrol.ui.model.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.ui.common.SearchBar
|
||||
|
||||
/**
|
||||
* Reusable model picker used by both the global model screen and the
|
||||
* in-session `/model` hot-swap (issue #589). Selecting a provider/model pair
|
||||
* invokes [onSelect]; the consumer decides what to do with it (global
|
||||
* `config.yaml` assignment vs. a session-scoped `/model` slash command).
|
||||
*
|
||||
* [pinnedModels] renders a pinned section at the top (mirrors the global model
|
||||
* screen's pin section) so frequently-used models are one tap away.
|
||||
*/
|
||||
@Composable
|
||||
fun ModelPickerDialog(
|
||||
providers: List<ModelProvider>,
|
||||
title: String,
|
||||
isLoading: Boolean = false,
|
||||
pinnedModels: List<PinnedModel> = emptyList(),
|
||||
onSelect: (provider: String, model: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var pickerQuery by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
if (isLoading) {
|
||||
LoadingState(
|
||||
subtitle = "Loading models…",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (providers.isEmpty() && pinnedModels.isEmpty()) {
|
||||
Text(
|
||||
text = "No models available.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
val filteredPinned =
|
||||
remember(pickerQuery, pinnedModels) {
|
||||
if (pickerQuery.isBlank()) {
|
||||
pinnedModels
|
||||
} else {
|
||||
pinnedModels.filter {
|
||||
it.modelName.contains(pickerQuery, ignoreCase = true) ||
|
||||
it.providerSlug.contains(pickerQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
query = pickerQuery,
|
||||
onQueryChange = { pickerQuery = it },
|
||||
placeholder = "Search models and providers...",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.height(400.dp)) {
|
||||
// ── Pinned section ──
|
||||
if (filteredPinned.isNotEmpty()) {
|
||||
item(key = "pinned-header") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PushPin,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Pinned",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
filteredPinned,
|
||||
key = { "pinned:${it.providerSlug}:${it.modelName}" },
|
||||
) { pinned ->
|
||||
OutlinedButton(
|
||||
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "${pinned.modelName} · ${pinned.providerSlug}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── All providers / models ──
|
||||
items(
|
||||
providers.filter { provider ->
|
||||
pickerQuery.isBlank() ||
|
||||
provider.name.contains(pickerQuery, ignoreCase = true) ||
|
||||
provider.slug.contains(pickerQuery, ignoreCase = true) ||
|
||||
provider.models.orEmpty().any {
|
||||
it.contains(pickerQuery, ignoreCase = true)
|
||||
}
|
||||
},
|
||||
key = { it.slug },
|
||||
) { provider ->
|
||||
val models =
|
||||
provider.models.orEmpty().filter {
|
||||
pickerQuery.isBlank() ||
|
||||
it.contains(pickerQuery, ignoreCase = true)
|
||||
}
|
||||
|
||||
if (models.isNotEmpty()) {
|
||||
Text(
|
||||
text = provider.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
|
||||
models.forEach { model ->
|
||||
OutlinedButton(
|
||||
onClick = { onSelect(provider.slug, model) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(text = model, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user