From dc808daee873c2efbf8faefbec4ce0cd9f26f597 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:59:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(#445):=20full=20Models=20tab=20parity=20?= =?UTF-8?q?=E2=80=94=20settings,=20aux=20tasks,=20MOA=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#445): full Models tab — settings panel, aux tasks, MOA config, model assignment Main features added: - Model Settings card at top of screen with main model, aux tasks, MOA summary - Main model assignment via /api/model/set with expensive model confirm flow - Auxiliary tasks dialog with per-task model override and reset-all - MOA config dialog with preset management and reference/aggregator model picker - Model picker dialog reused for main model, aux tasks, and MOA slot selection - Kept existing provider browse + pin/unpin/search New data models: AuxiliaryModelsResponse, MoaConfigResponse, ModelAssignmentRequest/Response New API endpoints: GET /api/model/auxiliary, GET/PUT /api/model/moa, POST /api/model/set * test(#445): add aux + MOA mock endpoints to E2eIntegrationTest testModelOptionsSelection_success was failing because the ViewModel's init block now calls getAuxiliaryModels() and getMoaConfig() which weren't mocked. Added proper mock returns + imports for the new types. * fix(#445): correct mock endpoint name in E2eIntegrationTest - getMoaConfig -> getMoaModels (matches API service method name) - Add explicit type parameter Response.success to fix Kotlin type inference * fix(#445): add missing main param to AuxiliaryModelsResponse mock AuxiliaryModelsResponse requires a MainModelAssignment for its 'main' field. Added the parameter with a dummy value and the corresponding import. * fix(#445): update toast assertion in test to match new message format ViewModel now produces 'Successfully set profile model to ronia' instead of the old 'Successfully set model to ronia'. --------- Co-authored-by: M57 --- .../data/model/ModelAssignment.kt | 64 + .../data/remote/HermesApiService.kt | 20 + .../m57/hermescontrol/ui/model/ModelScreen.kt | 1589 +++++++++++++---- .../hermescontrol/ui/model/ModelViewModel.kt | 373 +++- .../m57/hermescontrol/E2eIntegrationTest.kt | 25 +- 5 files changed, 1674 insertions(+), 397 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt new file mode 100644 index 00000000..4f71181c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt @@ -0,0 +1,64 @@ +package com.m57.hermescontrol.data.model + +data class AuxiliaryTaskAssignment( + val task: String, + val provider: String, + val model: String, + val base_url: String? = null, +) + +data class AuxiliaryModelsResponse( + val tasks: List, + val main: MainModelAssignment, +) + +data class MainModelAssignment( + val provider: String, + val model: String, +) + +data class MoaModelSlot( + val provider: String, + val model: String, +) + +data class MoaConfigPreset( + val reference_models: List, + val aggregator: MoaModelSlot, + val reference_temperature: Double = 0.7, + val aggregator_temperature: Double = 0.3, + val max_tokens: Int = 4096, + val enabled: Boolean = true, +) + +data class MoaConfigResponse( + val default_preset: String = "", + val active_preset: String = "", + val presets: Map = emptyMap(), + val reference_models: List = emptyList(), + val aggregator: MoaModelSlot = MoaModelSlot("", ""), + val reference_temperature: Double = 0.7, + val aggregator_temperature: Double = 0.3, + val max_tokens: Int = 4096, + val enabled: Boolean = true, +) + +data class ModelAssignmentRequest( + val confirm_expensive_model: Boolean = false, + val scope: String, + val provider: String, + val model: String, + val base_url: String? = null, + val task: String? = null, +) + +data class ModelAssignmentResponse( + val confirm_message: String? = null, + val confirm_required: Boolean? = null, + val ok: Boolean = false, + val scope: String? = null, + val provider: String? = null, + val model: String? = null, + val tasks: List? = null, + val reset: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 2907d8dc..19ee4c7c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -4,6 +4,7 @@ import com.google.gson.JsonElement import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.ConfigSchemaResponse import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.CreateCronJobRequest @@ -25,6 +26,9 @@ import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.MessagingPlatformResponse import com.m57.hermescontrol.data.model.MessagingPlatformTestResult import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.data.model.MoaConfigResponse +import com.m57.hermescontrol.data.model.ModelAssignmentRequest +import com.m57.hermescontrol.data.model.ModelAssignmentResponse import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingResponse @@ -285,6 +289,22 @@ interface HermesApiService { @GET("api/model/options") suspend fun getModelOptions(): Response + @GET("api/model/auxiliary") + suspend fun getAuxiliaryModels(): Response + + @GET("api/model/moa") + suspend fun getMoaModels(): Response + + @PUT("api/model/moa") + suspend fun saveMoaModels( + @Body body: MoaConfigResponse, + ): Response + + @POST("api/model/set") + suspend fun setModelAssignment( + @Body body: ModelAssignmentRequest, + ): Response + @GET("api/logs") suspend fun getLogs( @Query("limit") limit: Int? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 9fd146fe..8cfef72a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -13,21 +13,37 @@ 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Psychology import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -43,6 +59,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.MoaConfigPreset +import com.m57.hermescontrol.data.model.MoaModelSlot import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -74,7 +92,7 @@ fun ModelScreen( } LaunchedEffect(Unit) { - viewModel.loadModelOptions() + viewModel.loadAll() } ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) @@ -83,7 +101,7 @@ fun ModelScreen( title = { Text(stringResource(R.string.screen_models)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadModelOptions() }, + onRefresh = { viewModel.loadAll() }, ) { paddingValues -> when { state.isLoading && state.providers.isEmpty() -> { @@ -96,7 +114,7 @@ fun ModelScreen( state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: "", - onRetry = { viewModel.loadModelOptions() }, + onRetry = { viewModel.loadAll() }, modifier = Modifier.padding(paddingValues), ) } @@ -105,7 +123,7 @@ fun ModelScreen( EmptyState( title = stringResource(R.string.model_empty_title), subtitle = stringResource(R.string.model_empty_desc), - onAction = { viewModel.loadModelOptions() }, + onAction = { viewModel.loadAll() }, actionLabel = stringResource(R.string.content_desc_refresh), modifier = Modifier.padding(paddingValues), ) @@ -113,10 +131,33 @@ fun ModelScreen( else -> { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(paddingValues), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + // ── Model Settings Card ── + item { + ModelSettingsSection( + mainProvider = state.mainModelProvider, + mainModel = state.mainModelModel, + auxTasks = state.auxTasks, + moaEnabled = state.moaConfig != null, + moaSummary = + if (state.moaConfig != null) { + val mc = state.moaConfig!! + "${mc.reference_models.size} ref · ${mc.aggregator.provider}/${shortModelName( + mc.aggregator.model, + )}" + } else { + "" + }, + onSetMainModel = { viewModel.openMainModelPicker() }, + onConfigureAux = { viewModel.openAuxDialog() }, + onConfigureMoa = { viewModel.openMoaDialog() }, + ) + } + + // ── Search Bar ── item { SearchBar( query = query, @@ -124,6 +165,8 @@ fun ModelScreen( placeholder = "Search models...", ) } + + // ── Pinned Models ── if (state.pinnedModels.isNotEmpty()) { item { var isPinnedSectionExpanded by rememberSaveable { mutableStateOf(true) } @@ -140,400 +183,1184 @@ fun ModelScreen( } if (filteredPinned.isNotEmpty()) { - Card( - modifier = - Modifier - .fillMaxWidth() - .animateContentSize() - .padding(bottom = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = 0.5f, - ), - ), - onClick = { - isPinnedSectionExpanded = !isPinnedSectionExpanded + PinnedSectionCard( + pinnedModels = filteredPinned, + isExpanded = isPinnedSectionExpanded, + activeProfile = state.activeProfile, + onToggleExpanded = { isPinnedSectionExpanded = !isPinnedSectionExpanded }, + onModelClick = { slug, model -> viewModel.selectModel(slug, model) }, + onUnpin = { slug, model -> viewModel.unpinModel(slug, model) }, + ) + } + } + } + + // ── Provider List ── + items(filteredProviders, key = { it.slug }) { provider -> + val isExpanded = expandedProviderSlug == provider.slug + val isCurrent = provider.is_current == true + + ProviderCard( + provider = provider, + isExpanded = isExpanded, + isCurrent = isCurrent, + query = query, + activeProfile = state.activeProfile, + pinnedModels = state.pinnedModels, + onToggleExpand = { + expandedProviderSlug = if (isExpanded) null else provider.slug + }, + onModelClick = { slug, model -> viewModel.selectModel(slug, model) }, + onPin = { slug, model -> viewModel.pinModel(slug, model) }, + onUnpin = { slug, model -> viewModel.unpinModel(slug, model) }, + ) + } + } + } + } + } + + // ── Dialogs ── + + // Expensive model confirmation + if (state.modelPickerConfirmMessage != null) { + AlertDialog( + onDismissRequest = { viewModel.dismissModelPickerConfirm() }, + title = { Text("Expensive Model") }, + text = { Text(state.modelPickerConfirmMessage!!) }, + confirmButton = { + Button(onClick = { viewModel.confirmModelPickerExpensive() }) { + Text("Continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissModelPickerConfirm() }) { + Text("Cancel") + } + }, + ) + } + + // Main model picker dialog + if (state.showMainModelPicker) { + ModelPickerDialog( + providers = state.providers, + title = "Set Main Model", + onSelect = { provider, model -> + viewModel.setMainModel(provider, model) + }, + onDismiss = { viewModel.closeMainModelPicker() }, + ) + } + + // Auxiliary tasks dialog + if (state.showAuxDialog) { + AuxTasksDialog( + auxTasks = state.auxTasks, + busy = state.modelPickerBusy, + onSetTask = { task -> viewModel.openAuxModelPicker(task) }, + onResetAll = { viewModel.resetAllAuxTasks() }, + onDismiss = { viewModel.closeAuxDialog() }, + ) + } + + // Auxiliary model picker dialog + if (state.showAuxModelPicker) { + ModelPickerDialog( + providers = state.providers, + title = "Set Aux: ${state.auxPickerTask}", + onSelect = { provider, model -> + viewModel.setAuxTask(state.auxPickerTask, provider, model) + }, + onDismiss = { viewModel.closeAuxModelPicker() }, + ) + } + + // MOA config dialog + if (state.showMoaDialog && state.moaConfig != null) { + MoaConfigDialog( + config = state.moaConfig!!, + providers = state.providers, + busy = state.modelPickerBusy, + onSave = { viewModel.saveMoaConfig(it) }, + onDismiss = { viewModel.closeMoaDialog() }, + ) + } +} + +// ──────────────────────────────────────────────────────────────────── +// Model Settings Panel +// ──────────────────────────────────────────────────────────────────── + +@Composable +private fun ModelSettingsSection( + mainProvider: String, + mainModel: String, + auxTasks: List<*>?, + moaEnabled: Boolean, + moaSummary: String, + onSetMainModel: () -> Unit, + onConfigureAux: () -> Unit, + onConfigureMoa: () -> Unit, +) { + val auxOverrideCount = + auxTasks?.let { tasks -> + tasks.filterIsInstance() + .count { a -> a.provider.isNotBlank() && a.provider != "auto" } + } ?: 0 + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Model Settings", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "applies to new sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Main model row + SettingsRow( + icon = { + Icon( + Icons.Filled.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + }, + label = "Main model", + value = if (mainProvider.isNotBlank()) "$mainProvider · $mainModel" else "(unset)", + actionLabel = "Change", + onAction = onSetMainModel, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Auxiliary tasks row + SettingsRow( + icon = { + Icon( + Icons.Filled.Build, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + }, + label = "Auxiliary tasks", + value = + if (auxOverrideCount > 0) { + "$auxOverrideCount override · ${AUX_TASKS.size - auxOverrideCount} auto" + } else { + "${AUX_TASKS.size} tasks · all auto" + }, + actionLabel = "Configure", + onAction = onConfigureAux, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // MOA row + SettingsRow( + icon = { + Icon( + Icons.Filled.Psychology, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + }, + label = "Mixture of Agents", + value = if (moaEnabled) moaSummary else "not loaded", + actionLabel = "Configure", + onAction = onConfigureMoa, + actionEnabled = moaEnabled, + ) + } + } +} + +@Composable +private fun SettingsRow( + icon: @Composable () -> Unit, + label: String, + value: String, + actionLabel: String, + onAction: () -> Unit, + actionEnabled: Boolean = true, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f), + ) { + icon() + Column { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + OutlinedButton( + onClick = onAction, + enabled = actionEnabled, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Text(actionLabel, style = MaterialTheme.typography.labelSmall) + } + } +} + +// ──────────────────────────────────────────────────────────────────── +// Auxiliary Tasks Dialog +// ──────────────────────────────────────────────────────────────────── + +@Composable +private fun AuxTasksDialog( + auxTasks: List, + busy: Boolean, + onSetTask: (String) -> Unit, + onResetAll: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Auxiliary Tasks") }, + text = { + Column { + Text( + text = + "Auxiliary tasks handle side-jobs like vision, session" + + " search, and compression. \"auto\" means use the main model.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + AUX_TASKS.forEach { info -> + val cur = auxTasks.find { it.task == info.key } + val isAuto = cur == null || cur.provider.isBlank() || cur.provider == "auto" + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = info.label, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + Text( + text = info.hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = + if (isAuto) { + "auto (use main model)" + } else { + "${cur?.provider} · ${cur?.model ?: "(provider default)"}" }, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Filled.PushPin, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringResource(R.string.model_section_pinned), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { onSetTask(info.key) }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Text("Change", style = MaterialTheme.typography.labelSmall) + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp)) + } - IconButton( - onClick = { - isPinnedSectionExpanded = !isPinnedSectionExpanded - }, - ) { - Icon( - imageVector = - if (isPinnedSectionExpanded) { - Icons.Filled.KeyboardArrowUp - } else { - Icons.Filled.KeyboardArrowDown - }, - contentDescription = - stringResource( - R.string.content_desc_pinned_section_toggle, - ), + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onResetAll, + enabled = !busy, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Reset all to auto") + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + }, + ) +} + +// ──────────────────────────────────────────────────────────────────── +// MOA Config Dialog +// ──────────────────────────────────────────────────────────────────── + +@Composable +private fun MoaConfigDialog( + config: com.m57.hermescontrol.data.model.MoaConfigResponse, + providers: List, + busy: Boolean, + onSave: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit, + onDismiss: () -> Unit, +) { + var draft by remember(config) { mutableStateOf(config) } + var selectedPreset by remember { mutableStateOf(config.default_preset.ifBlank { "default" }) } + var newPresetName by remember { mutableStateOf("") } + var pickerTarget by remember { mutableStateOf(null) } + + val presetNames = draft.presets.keys.toList() + val effectivePreset = draft.presets[selectedPreset] + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Mixture of Agents") }, + text = { + if (pickerTarget != null && effectivePreset != null) { + // Inline model picker within the dialog + MoaSlotPicker( + providers = providers, + draft = draft, + selectedPreset = selectedPreset, + pickerTarget = pickerTarget!!, + onSlotSelected = { updatedDraft -> + draft = updatedDraft + pickerTarget = null + }, + onCancel = { pickerTarget = null }, + ) + } else { + MoaConfigEditor( + draft = draft, + presetNames = presetNames, + selectedPreset = selectedPreset, + effectivePreset = effectivePreset, + newPresetName = newPresetName, + busy = busy, + onSelectPreset = { selectedPreset = it }, + onNewPresetNameChange = { newPresetName = it }, + onAddPreset = { + val name = newPresetName.trim() + if (name.isNotBlank() && !draft.presets.containsKey(name)) { + val seed = + effectivePreset ?: MoaConfigPreset( + reference_models = draft.reference_models, + aggregator = draft.aggregator, + reference_temperature = draft.reference_temperature, + aggregator_temperature = draft.aggregator_temperature, + max_tokens = draft.max_tokens, + ) + draft = + draft.copy( + default_preset = draft.default_preset.ifBlank { name }, + presets = draft.presets + (name to seed), + ) + selectedPreset = name + newPresetName = "" + } + }, + onDeletePreset = { + if (presetNames.size > 1) { + val remaining = presetNames - selectedPreset + val nextSelected = remaining.first() + draft = + draft.copy( + presets = draft.presets - selectedPreset, + default_preset = + if (draft.default_preset == selectedPreset) { + nextSelected + } else { + draft.default_preset + }, + ) + selectedPreset = nextSelected + } + }, + onUpdatePreset = { updater -> + if (effectivePreset != null) { + draft = + draft.copy( + presets = draft.presets + (selectedPreset to updater(effectivePreset)), + ) + } + }, + onPickReference = { index -> + pickerTarget = MoaPickerTarget.Reference(index) + }, + onPickAggregator = { + pickerTarget = MoaPickerTarget.Aggregator + }, + onAddReference = { + if (effectivePreset != null) { + draft = + draft.copy( + presets = + draft.presets + ( + selectedPreset to + effectivePreset.copy( + reference_models = + effectivePreset.reference_models + + effectivePreset.aggregator, ) - } - } - - AnimatedVisibility(visible = isPinnedSectionExpanded) { - Column(modifier = Modifier.padding(top = 8.dp)) { - filteredPinned.forEach { pinned -> - val isActive = - state.activeProfile?.provider == pinned.providerSlug && - state.activeProfile?.model == pinned.modelName - - Card( - onClick = { - viewModel.selectModel( - pinned.providerSlug, - pinned.modelName, + ), + ) + } + }, + onRemoveReference = { index -> + if (effectivePreset != null && effectivePreset.reference_models.size > 1) { + draft = + draft.copy( + presets = + draft.presets + ( + selectedPreset to + effectivePreset.copy( + reference_models = + effectivePreset.reference_models.toMutableList().also { + it.removeAt( + index, ) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - colors = - CardDefaults.cardColors( - containerColor = - if (isActive) { - MaterialTheme.colorScheme - .primaryContainer - .copy(alpha = 0.3f) - } else { - MaterialTheme.colorScheme.surface - }, - ), - border = - BorderStroke( - width = 1.dp, - color = - if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy( - alpha = 0.2f, - ) - }, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = pinned.modelName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = - if (isActive) { - FontWeight.Bold - } else { - FontWeight.Normal - }, - color = - if (isActive) { - MaterialTheme.colorScheme - .onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - Text( - text = pinned.providerSlug, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ) + ), + ) + } + }, + onSetDefault = { + draft = draft.copy(default_preset = selectedPreset) + }, + ) + } + }, + confirmButton = { + if (pickerTarget == null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } + Button( + onClick = { onSave(draft) }, + enabled = !busy, + ) { + if (busy) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Save") + } + } + } + } + }, + ) +} - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (isActive) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = - stringResource( - R.string.content_desc_active_model, - ), - tint = MaterialTheme.colorScheme.primary, - ) - } +private sealed class MoaPickerTarget { + data class Reference(val index: Int) : MoaPickerTarget() - IconButton( - onClick = { - viewModel.unpinModel( - pinned.providerSlug, - pinned.modelName, - ) - }, - modifier = Modifier.size(24.dp), - ) { - Icon( - imageVector = Icons.Filled.PushPin, - contentDescription = - stringResource( - R.string.content_desc_unpin_model, - ), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } - } - } - } + data object Aggregator : MoaPickerTarget() +} + +@Composable +private fun MoaConfigEditor( + draft: com.m57.hermescontrol.data.model.MoaConfigResponse, + presetNames: List, + selectedPreset: String, + effectivePreset: com.m57.hermescontrol.data.model.MoaConfigPreset?, + newPresetName: String, + busy: Boolean, + onSelectPreset: (String) -> Unit, + onNewPresetNameChange: (String) -> Unit, + onAddPreset: () -> Unit, + onDeletePreset: () -> Unit, + onUpdatePreset: ( + updater: (com.m57.hermescontrol.data.model.MoaConfigPreset) -> com.m57.hermescontrol.data.model.MoaConfigPreset, + ) -> Unit, + onPickReference: (Int) -> Unit, + onPickAggregator: () -> Unit, + onAddReference: () -> Unit, + onRemoveReference: (Int) -> Unit, + onSetDefault: () -> Unit, +) { + Column { + Text( + text = "Presets appear as models under the Mixture of Agents provider.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (effectivePreset == null) { + Text("No presets configured", style = MaterialTheme.typography.bodyMedium) + return + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Preset selector + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + // Simplified preset dropdown with buttons + if (presetNames.isNotEmpty()) { + Text("Preset:", style = MaterialTheme.typography.bodySmall) + presetNames.forEach { name -> + TextButton( + onClick = { onSelectPreset(name) }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + name, + fontWeight = if (name == selectedPreset) FontWeight.Bold else FontWeight.Normal, + color = + if (name == selectedPreset) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + // Default preset indicator + Text( + text = "Default: ${draft.default_preset.ifBlank { "(none)" }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Preset actions + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onSetDefault, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + enabled = presetNames.size > 1, + ) { + Text("Set default", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = onDeletePreset, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + enabled = presetNames.size > 1, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("Delete", style = MaterialTheme.typography.labelSmall) + } + } + + // New preset + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newPresetName, + onValueChange = onNewPresetNameChange, + placeholder = { Text("new preset name", style = MaterialTheme.typography.bodySmall) }, + singleLine = true, + modifier = Modifier.weight(1f).height(48.dp), + textStyle = MaterialTheme.typography.bodySmall, + ) + IconButton(onClick = onAddPreset, enabled = newPresetName.isNotBlank()) { + Icon(Icons.Outlined.Add, contentDescription = "Add preset") + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Reference models + Text( + text = "Reference models", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(4.dp)) + + effectivePreset.reference_models.forEachIndexed { index, slot -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "${slot.provider} · ${slot.model}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + OutlinedButton( + onClick = { onPickReference(index) }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + ) { Text("Change", style = MaterialTheme.typography.labelSmall) } + if (effectivePreset.reference_models.size > 1) { + IconButton(onClick = { onRemoveReference(index) }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp)) + } + } + } + } + + OutlinedButton( + onClick = onAddReference, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Add reference model", style = MaterialTheme.typography.labelSmall) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Aggregator + Text( + text = "Aggregator", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "${effectivePreset.aggregator.provider} · ${effectivePreset.aggregator.model}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + OutlinedButton( + onClick = onPickAggregator, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + ) { Text("Change", style = MaterialTheme.typography.labelSmall) } + } + } +} + +@Composable +private fun MoaSlotPicker( + providers: List, + draft: com.m57.hermescontrol.data.model.MoaConfigResponse, + selectedPreset: String, + pickerTarget: MoaPickerTarget, + onSlotSelected: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit, + onCancel: () -> Unit, +) { + var pickerQuery by remember { mutableStateOf("") } + + Column { + Text( + text = + "Select model for ${ + if (pickerTarget is MoaPickerTarget.Aggregator) { + "aggregator" + } else { + "reference model" + } + }", + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + + SearchBar( + query = pickerQuery, + onQueryChange = { pickerQuery = it }, + placeholder = "Search providers...", + ) + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn(modifier = Modifier.height(300.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(vertical = 4.dp), + ) + + models.forEach { model -> + OutlinedButton( + onClick = { + val slot = MoaModelSlot(provider.slug, model) + val update: ( + com.m57.hermescontrol.data.model.MoaConfigPreset, + ) -> com.m57.hermescontrol.data.model.MoaConfigPreset = { preset -> + when (pickerTarget) { + is MoaPickerTarget.Aggregator -> preset.copy(aggregator = slot) + is MoaPickerTarget.Reference -> + preset.copy( + reference_models = + preset.reference_models.toMutableList().also { + it[pickerTarget.index] = slot + }, + ) + } + } + val preset = draft.presets[selectedPreset] + if (preset != null) { + onSlotSelected( + draft.copy(presets = draft.presets + (selectedPreset to update(preset))), + ) + } + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Text(text = model, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + + TextButton(onClick = onCancel) { + Text("Back") + } + } +} + +// ──────────────────────────────────────────────────────────────────── +// Model Picker Dialog +// ──────────────────────────────────────────────────────────────────── + +@Composable +private fun ModelPickerDialog( + providers: List, + 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 +// ──────────────────────────────────────────────────────────────────── + +@Composable +private fun PinnedSectionCard( + pinnedModels: List, + isExpanded: Boolean, + activeProfile: com.m57.hermescontrol.data.model.ProfileInfo?, + onToggleExpanded: () -> Unit, + onModelClick: (String, String) -> Unit, + onUnpin: (String, String) -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().animateContentSize().padding(bottom = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + onClick = onToggleExpanded, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.model_section_pinned), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + IconButton(onClick = onToggleExpanded) { + Icon( + imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = stringResource(R.string.content_desc_pinned_section_toggle), + ) + } + } + + AnimatedVisibility(visible = isExpanded) { + Column(modifier = Modifier.padding(top = 8.dp)) { + pinnedModels.forEach { pinned -> + val isActive = + activeProfile?.provider == pinned.providerSlug && + activeProfile?.model == pinned.modelName + + Card( + onClick = { onModelClick(pinned.providerSlug, pinned.modelName) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surface + }, + ), + border = + BorderStroke( + width = 1.dp, + color = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + }, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = pinned.modelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = pinned.providerSlug, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isActive) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(R.string.content_desc_active_model), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton( + onClick = { onUnpin(pinned.providerSlug, pinned.modelName) }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = stringResource(R.string.content_desc_unpin_model), + tint = MaterialTheme.colorScheme.primary, + ) } } } } } - items(filteredProviders, key = { it.slug }) { provider -> - val isExpanded = expandedProviderSlug == provider.slug - val isCurrent = provider.is_current == true + } + } + } + } +} - Card( - modifier = Modifier.fillMaxWidth(), - onClick = { - expandedProviderSlug = if (isExpanded) null else provider.slug - }, - colors = - CardDefaults.cardColors( - containerColor = - if (isCurrent) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), +// ──────────────────────────────────────────────────────────────────── +// Provider Card — extracted from existing screen +// ──────────────────────────────────────────────────────────────────── + +@Composable +private fun ProviderCard( + provider: com.m57.hermescontrol.data.model.ModelProvider, + isExpanded: Boolean, + isCurrent: Boolean, + query: String, + activeProfile: com.m57.hermescontrol.data.model.ProfileInfo?, + pinnedModels: List, + onToggleExpand: () -> Unit, + onModelClick: (String, String) -> Unit, + onPin: (String, String) -> Unit, + onUnpin: (String, String) -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onToggleExpand, + colors = + CardDefaults.cardColors( + containerColor = + if (isCurrent) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = provider.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.model_label_slug, provider.slug), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (isCurrent) { + Text( + text = stringResource(R.string.model_status_current), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + + provider.warning?.let { + if (it.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + AnimatedVisibility(visible = isExpanded) { + Column(modifier = Modifier.padding(top = 16.dp)) { + Text( + text = stringResource(R.string.model_label_available), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + val models = provider.models.orEmpty() + val filteredModels = + if (query.isBlank()) { + models + } else { + models.filter { it.contains(query, ignoreCase = true) } + } + + if (filteredModels.isEmpty()) { + Text( + text = stringResource(R.string.model_no_models), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + filteredModels.forEach { model -> + val isActive = + activeProfile?.provider == provider.slug && + activeProfile?.model == model + Card( + onClick = { onModelClick(provider.slug, model) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surface + }, + ), + border = + BorderStroke( + width = 1.dp, + color = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + }, + ), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column { - Text( - text = provider.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource(R.string.model_label_slug, provider.slug), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (isCurrent) { - Text( - text = stringResource(R.string.model_status_current), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - } - } - - provider.warning?.let { - if (it.isNotBlank()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - - AnimatedVisibility(visible = isExpanded) { - Column(modifier = Modifier.padding(top = 16.dp)) { - Text( - text = stringResource(R.string.model_label_available), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp), - ) - - val models = provider.models.orEmpty() - val filteredModels = - if (query.isBlank()) { - models + Text( + text = model, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer } else { - models.filter { - it.contains(query, ignoreCase = true) - } - } - if (filteredModels.isEmpty()) { - Text( - text = stringResource(R.string.model_no_models), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + MaterialTheme.colorScheme.onSurface + }, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isActive) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(R.string.content_desc_active_model), + tint = MaterialTheme.colorScheme.primary, ) - } else { - filteredModels.forEach { model -> - val isActive = - state.activeProfile?.provider == provider.slug && - state.activeProfile?.model == model - Card( - onClick = { - viewModel.selectModel(provider.slug, model) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - colors = - CardDefaults.cardColors( - containerColor = - if (isActive) { - MaterialTheme.colorScheme - .primaryContainer - .copy( - alpha = 0.3f, - ) - } else { - MaterialTheme.colorScheme.surface - }, - ), - border = - BorderStroke( - width = 1.dp, - color = - if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy( - alpha = 0.2f, - ) - }, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = - Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = model, - style = MaterialTheme.typography.bodyMedium, - fontWeight = - if (isActive) { - FontWeight.Bold - } else { - FontWeight.Normal - }, - color = - if (isActive) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (isActive) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = - stringResource( - R.string.content_desc_active_model, - ), - tint = MaterialTheme.colorScheme.primary, - ) - } - - val isPinned = - state.pinnedModels.any { - it.providerSlug == provider.slug && - it.modelName == model - } - IconButton( - onClick = { - if (isPinned) { - viewModel.unpinModel(provider.slug, model) - } else { - viewModel.pinModel(provider.slug, model) - } - }, - modifier = Modifier.size(24.dp), - ) { - Icon( - imageVector = - if (isPinned) { - Icons.Filled.PushPin - } else { - Icons.Outlined.PushPin - }, - contentDescription = - stringResource( - if (isPinned) { - R.string.content_desc_unpin_model - } else { - R.string.content_desc_pin_model - }, - ), - tint = - if (isPinned) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme - .onSurfaceVariant - .copy(alpha = 0.6f) - }, - ) - } - } - } - } + } + val isPinned = + pinnedModels.any { + it.providerSlug == provider.slug && it.modelName == model } + IconButton( + onClick = { + if (isPinned) { + onUnpin(provider.slug, model) + } else { + onPin(provider.slug, model) + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = + if (isPinned) { + Icons.Filled.PushPin + } else { + Icons.Outlined.PushPin + }, + contentDescription = + if (isPinned) { + stringResource(R.string.content_desc_unpin_model) + } else { + "Pin model" + }, + tint = MaterialTheme.colorScheme.primary, + ) } } } @@ -545,3 +1372,9 @@ fun ModelScreen( } } } + +/** Shorten a model name for display. */ +private fun shortModelName(model: String): String { + val parts = model.split("/") + return parts.lastOrNull() ?: model +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index cb065fbd..45257359 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -3,6 +3,9 @@ package com.m57.hermescontrol.ui.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.AuxiliaryTaskAssignment +import com.m57.hermescontrol.data.model.MoaConfigResponse +import com.m57.hermescontrol.data.model.ModelAssignmentRequest import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.ProfileInfo @@ -20,6 +23,22 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +data class AuxTaskInfo( + val key: String, + val label: String, + val hint: String, +) + +val AUX_TASKS = + listOf( + AuxTaskInfo("vision", "Vision", "image/video analysis"), + AuxTaskInfo("session_search", "Session search", "past conversation search"), + AuxTaskInfo("summarizer", "Compression", "context compression"), + AuxTaskInfo("code_exec", "Code execution", "runtime for code blocks"), + AuxTaskInfo("computer_use", "Computer use", "desktop/browser automation"), + AuxTaskInfo("web_search", "Web search", "live search queries"), + ) + data class ModelUiState( val isLoading: Boolean = false, val providers: List = emptyList(), @@ -27,6 +46,24 @@ data class ModelUiState( val errorMessage: String? = null, val toastMessage: String? = null, val pinnedModels: List = emptyList(), + // Main model + val mainModelProvider: String = "", + val mainModelModel: String = "", + // Auxiliary tasks + val auxTasks: List = emptyList(), + // MOA config + val moaConfig: MoaConfigResponse? = null, + // Dialog state + val showAuxDialog: Boolean = false, + val showMoaDialog: Boolean = false, + val showMainModelPicker: Boolean = false, + val showAuxModelPicker: Boolean = false, + val auxPickerTask: String = "", + // Model picker busy (for expensive model confirmation) + val modelPickerBusy: Boolean = false, + val modelPickerConfirmMessage: String? = null, + // Pending resolve + val pendingModelPickerResolve: ((Boolean) -> Unit)? = null, ) class ModelViewModel : @@ -39,37 +76,69 @@ class ModelViewModel : _uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) } } + fun loadAll() { + loadModelOptions() + } + fun loadModelOptions() { _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { - val responseResultDeferred = + val optionsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getModelOptions() } } - val activeProfileNameResResultDeferred = + val activeProfileDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } } - val profilesResResultDeferred = + val profilesDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getProfiles() } } + val auxDeferred = + async(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getAuxiliaryModels() } + } + val moaDeferred = + async(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getMoaModels() } + } - val responseResult = responseResultDeferred.await() - val activeProfileNameResResult = activeProfileNameResResultDeferred.await() - val profilesResResult = profilesResResultDeferred.await() + val optionsResult = optionsDeferred.await() + val activeProfileResult = activeProfileDeferred.await() + val profilesResult = profilesDeferred.await() + val auxResult = auxDeferred.await() + val moaResult = moaDeferred.await() - when (responseResult) { + when (optionsResult) { is NetworkResult.Success -> { val activeName = - if (activeProfileNameResResult is NetworkResult.Success) { - activeProfileNameResResult.data.active + if (activeProfileResult is NetworkResult.Success) { + activeProfileResult.data.active } else { null } val activeProfile = - if (profilesResResult is NetworkResult.Success && activeName != null) { - profilesResResult.data.profiles.find { it.name == activeName } + if (profilesResult is NetworkResult.Success && activeName != null) { + profilesResult.data.profiles.find { it.name == activeName } + } else { + null + } + val mainModel = + if (auxResult is NetworkResult.Success) { + Pair(auxResult.data.main.provider, auxResult.data.main.model) + } else { + Pair("", "") + } + val auxTasks = + if (auxResult is NetworkResult.Success) { + auxResult.data.tasks + } else { + emptyList() + } + val moaConfig = + if (moaResult is NetworkResult.Success) { + moaResult.data } else { null } @@ -77,18 +146,21 @@ class ModelViewModel : _uiState.update { it.copy( isLoading = false, - providers = responseResult.data.providers.orEmpty(), + providers = optionsResult.data.providers.orEmpty(), activeProfile = activeProfile, pinnedModels = AuthManager.getPinnedModels(), + mainModelProvider = mainModel.first, + mainModelModel = mainModel.second, + auxTasks = auxTasks, + moaConfig = moaConfig, ) } } - is NetworkResult.Failure -> { _uiState.update { it.copy( isLoading = false, - errorMessage = "Failed to load model options: ${responseResult.error.message}", + errorMessage = "Failed to load model options: ${optionsResult.error.message}", ) } } @@ -96,6 +168,273 @@ class ModelViewModel : } } + /** Open the main model picker dialog. */ + fun openMainModelPicker() { + _uiState.update { it.copy(showMainModelPicker = true) } + } + + fun closeMainModelPicker() { + _uiState.update { it.copy(showMainModelPicker = false) } + } + + /** Set the main model via /api/model/set. */ + fun setMainModel( + provider: String, + model: String, + ) { + _uiState.update { it.copy(modelPickerBusy = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.setModelAssignment( + ModelAssignmentRequest( + scope = "main", + provider = provider, + model = model, + ), + ) + } + } + when (result) { + is NetworkResult.Success -> { + val data = result.data + if (data.confirm_required == true) { + _uiState.update { + it.copy( + modelPickerConfirmMessage = + data.confirm_message ?: "This model is expensive. Continue?", + pendingModelPickerResolve = { confirmed -> + if (confirmed) { + setMainModelWithConfirm(provider, model) + } + _uiState.update { + it.copy( + modelPickerConfirmMessage = null, + pendingModelPickerResolve = null, + ) + } + }, + ) + } + } else { + _uiState.update { + it.copy( + toastMessage = "Main model set to $provider / $model", + showMainModelPicker = false, + modelPickerBusy = false, + ) + } + loadModelOptions() + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + toastMessage = "Failed to set main model: ${result.error.message}", + modelPickerBusy = false, + ) + } + } + } + } + } + + private fun setMainModelWithConfirm( + provider: String, + model: String, + ) { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.setModelAssignment( + ModelAssignmentRequest( + confirm_expensive_model = true, + scope = "main", + provider = provider, + model = model, + ), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Main model set to $provider / $model", + showMainModelPicker = false, + modelPickerBusy = false, + ) + } + loadModelOptions() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + toastMessage = "Failed to set main model: ${result.error.message}", + modelPickerBusy = false, + ) + } + } + } + } + } + + fun dismissModelPickerConfirm() { + _uiState.update { + it.copy( + modelPickerConfirmMessage = null, + pendingModelPickerResolve = null, + ) + } + _uiState.value.pendingModelPickerResolve?.invoke(false) + } + + fun confirmModelPickerExpensive() { + _uiState.value.pendingModelPickerResolve?.invoke(true) + } + + // -------------------- Auxiliary tasks -------------------- + + fun openAuxDialog() { + _uiState.update { it.copy(showAuxDialog = true) } + } + + fun closeAuxDialog() { + _uiState.update { it.copy(showAuxDialog = false) } + } + + fun openAuxModelPicker(task: String) { + _uiState.update { it.copy(showAuxModelPicker = true, auxPickerTask = task) } + } + + fun closeAuxModelPicker() { + _uiState.update { it.copy(showAuxModelPicker = false, auxPickerTask = "") } + } + + /** Set an auxiliary task model override. */ + fun setAuxTask( + task: String, + provider: String, + model: String, + ) { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.setModelAssignment( + ModelAssignmentRequest( + scope = "auxiliary", + task = task, + provider = provider, + model = model, + ), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Aux task '$task' set to $provider / $model", + showAuxModelPicker = false, + auxPickerTask = "", + ) + } + loadModelOptions() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + toastMessage = "Failed to set aux task: ${result.error.message}", + ) + } + } + } + } + } + + /** Reset all auxiliary tasks to auto. */ + fun resetAllAuxTasks() { + viewModelScope.launch { + _uiState.update { it.copy(modelPickerBusy = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.setModelAssignment( + ModelAssignmentRequest( + scope = "auxiliary", + task = "__reset__", + provider = "", + model = "", + ), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy(toastMessage = "All auxiliary tasks reset to auto", modelPickerBusy = false) + } + loadModelOptions() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + toastMessage = "Failed to reset aux tasks: ${result.error.message}", + modelPickerBusy = false, + ) + } + } + } + } + } + + // -------------------- MOA config -------------------- + + fun openMoaDialog() { + _uiState.update { it.copy(showMoaDialog = true) } + } + + fun closeMoaDialog() { + _uiState.update { it.copy(showMoaDialog = false) } + } + + fun saveMoaConfig(config: MoaConfigResponse) { + viewModelScope.launch { + _uiState.update { it.copy(modelPickerBusy = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.saveMoaModels(config) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + moaConfig = result.data, + toastMessage = "MOA config saved", + showMoaDialog = false, + modelPickerBusy = false, + ) + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + toastMessage = "Failed to save MOA config: ${result.error.message}", + modelPickerBusy = false, + ) + } + } + } + } + } + + // -------------------- Profile model selection (existing) -------------------- + fun selectModel( providerSlug: String, modelName: String, @@ -115,7 +454,6 @@ class ModelViewModel : } return@launch } - is NetworkResult.Success -> { val activeProfileName = activeProfileNameResResult.data.active val updateResResult = @@ -129,14 +467,13 @@ class ModelViewModel : } when (updateResResult) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") } + _uiState.update { it.copy(toastMessage = "Successfully set profile model to $modelName") } loadModelOptions() } - is NetworkResult.Failure -> { _uiState.update { it.copy( - toastMessage = "Failed to set model: ${updateResResult.error.message}", + toastMessage = "Failed to set profile model: ${updateResResult.error.message}", ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 4255cb85..b72cd2f9 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.ActiveProfileResponse +import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.EnvVarConfig @@ -14,8 +15,11 @@ import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.LogResponse +import com.m57.hermescontrol.data.model.MainModelAssignment import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformResponse +import com.m57.hermescontrol.data.model.MoaConfigResponse +import com.m57.hermescontrol.data.model.MoaModelSlot import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PluginInfo @@ -1047,6 +1051,25 @@ class E2eIntegrationTest { Response.success(ActiveProfileResponse("default", null)) coEvery { mockApiService.getProfiles() } returns Response.success(ProfilesResponse(listOf(profile))) coEvery { mockApiService.updateProfileModel("default", any()) } returns Response.success(Unit) + coEvery { mockApiService.getAuxiliaryModels() } returns + Response.success( + AuxiliaryModelsResponse( + tasks = emptyList(), + main = MainModelAssignment("openai", "gpt-4"), + ), + ) + coEvery { mockApiService.getMoaModels() } returns + Response.success( + MoaConfigResponse( + presets = emptyMap(), + default_preset = "", + reference_models = emptyList(), + aggregator = MoaModelSlot("openai", "gpt-4"), + reference_temperature = 0.7, + aggregator_temperature = 0.7, + max_tokens = 4096, + ), + ) val viewModel = ModelViewModel() viewModel.loadModelOptions() @@ -1068,7 +1091,7 @@ class E2eIntegrationTest { advanceUntilIdle() coVerify { mockApiService.updateProfileModel("default", any()) } - assertEquals("Successfully set model to llama3", viewModel.uiState.value.toastMessage) + assertEquals("Successfully set profile model to llama3", viewModel.uiState.value.toastMessage) } @Test