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 deleted file mode 100644 index 4f71181c..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt +++ /dev/null @@ -1,64 +0,0 @@ -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 19ee4c7c..2907d8dc 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,7 +4,6 @@ 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 @@ -26,9 +25,6 @@ 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 @@ -289,22 +285,6 @@ 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 8cfef72a..9fd146fe 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,37 +13,21 @@ 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 @@ -59,8 +43,6 @@ 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 @@ -92,7 +74,7 @@ fun ModelScreen( } LaunchedEffect(Unit) { - viewModel.loadAll() + viewModel.loadModelOptions() } ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) @@ -101,7 +83,7 @@ fun ModelScreen( title = { Text(stringResource(R.string.screen_models)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadAll() }, + onRefresh = { viewModel.loadModelOptions() }, ) { paddingValues -> when { state.isLoading && state.providers.isEmpty() -> { @@ -114,7 +96,7 @@ fun ModelScreen( state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: "", - onRetry = { viewModel.loadAll() }, + onRetry = { viewModel.loadModelOptions() }, modifier = Modifier.padding(paddingValues), ) } @@ -123,7 +105,7 @@ fun ModelScreen( EmptyState( title = stringResource(R.string.model_empty_title), subtitle = stringResource(R.string.model_empty_desc), - onAction = { viewModel.loadAll() }, + onAction = { viewModel.loadModelOptions() }, actionLabel = stringResource(R.string.content_desc_refresh), modifier = Modifier.padding(paddingValues), ) @@ -131,33 +113,10 @@ fun ModelScreen( else -> { LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues), + modifier = Modifier.fillMaxSize(), 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, @@ -165,8 +124,6 @@ fun ModelScreen( placeholder = "Search models...", ) } - - // ── Pinned Models ── if (state.pinnedModels.isNotEmpty()) { item { var isPinnedSectionExpanded by rememberSaveable { mutableStateOf(true) } @@ -183,1184 +140,400 @@ fun ModelScreen( } if (filteredPinned.isNotEmpty()) { - 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) }, - ) + Card( + modifier = + Modifier + .fillMaxWidth() + .animateContentSize() + .padding(bottom = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.5f, + ), + ), + onClick = { + isPinnedSectionExpanded = !isPinnedSectionExpanded + }, + ) { + 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 = { + isPinnedSectionExpanded = !isPinnedSectionExpanded + }, + ) { + Icon( + imageVector = + if (isPinnedSectionExpanded) { + Icons.Filled.KeyboardArrowUp + } else { + Icons.Filled.KeyboardArrowDown + }, + contentDescription = + stringResource( + R.string.content_desc_pinned_section_toggle, + ), + ) + } + } + + 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, + ) + }, + 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 = { + 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, + ) + } + } + } + } + } + } + } + } + } } } } - - // ── 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 = { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { 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)"}" - }, - 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)) - } - - 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, - ) - ), - ) - } - }, - 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, - ) - }, - ) - ), - ) - } - }, - 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") - } - } - } - } - }, - ) -} - -private sealed class MoaPickerTarget { - data class Reference(val index: Int) : MoaPickerTarget() - - 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) + if (isCurrent) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) } else { - MaterialTheme.colorScheme.surface - }, - ), - border = - BorderStroke( - width = 1.dp, - color = - if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + MaterialTheme.colorScheme.surfaceVariant }, ), ) { - 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, - ) - } - } - } - } - } - } - } - } - } -} - -// ──────────────────────────────────────────────────────────────────── -// 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) - }, - ), + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), + modifier = Modifier.fillMaxWidth(), 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 + 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 { - 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 = - pinnedModels.any { - it.providerSlug == provider.slug && it.modelName == model - } - IconButton( - onClick = { - if (isPinned) { - onUnpin(provider.slug, model) - } else { - onPin(provider.slug, model) + models.filter { + it.contains(query, ignoreCase = true) } - }, - 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, + } + 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 = + 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) + }, + ) + } + } + } + } + } } } } @@ -1372,9 +545,3 @@ private fun ProviderCard( } } } - -/** 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 45257359..cb065fbd 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,9 +3,6 @@ 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 @@ -23,22 +20,6 @@ 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(), @@ -46,24 +27,6 @@ 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 : @@ -76,69 +39,37 @@ 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 optionsDeferred = + val responseResultDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getModelOptions() } } - val activeProfileDeferred = + val activeProfileNameResResultDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } } - val profilesDeferred = + val profilesResResultDeferred = 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 optionsResult = optionsDeferred.await() - val activeProfileResult = activeProfileDeferred.await() - val profilesResult = profilesDeferred.await() - val auxResult = auxDeferred.await() - val moaResult = moaDeferred.await() + val responseResult = responseResultDeferred.await() + val activeProfileNameResResult = activeProfileNameResResultDeferred.await() + val profilesResResult = profilesResResultDeferred.await() - when (optionsResult) { + when (responseResult) { is NetworkResult.Success -> { val activeName = - if (activeProfileResult is NetworkResult.Success) { - activeProfileResult.data.active + if (activeProfileNameResResult is NetworkResult.Success) { + activeProfileNameResResult.data.active } else { null } val activeProfile = - 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 + if (profilesResResult is NetworkResult.Success && activeName != null) { + profilesResResult.data.profiles.find { it.name == activeName } } else { null } @@ -146,21 +77,18 @@ class ModelViewModel : _uiState.update { it.copy( isLoading = false, - providers = optionsResult.data.providers.orEmpty(), + providers = responseResult.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: ${optionsResult.error.message}", + errorMessage = "Failed to load model options: ${responseResult.error.message}", ) } } @@ -168,273 +96,6 @@ 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, @@ -454,6 +115,7 @@ class ModelViewModel : } return@launch } + is NetworkResult.Success -> { val activeProfileName = activeProfileNameResResult.data.active val updateResResult = @@ -467,13 +129,14 @@ class ModelViewModel : } when (updateResResult) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Successfully set profile model to $modelName") } + _uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") } loadModelOptions() } + is NetworkResult.Failure -> { _uiState.update { it.copy( - toastMessage = "Failed to set profile model: ${updateResResult.error.message}", + toastMessage = "Failed to set 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 b72cd2f9..4255cb85 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -5,7 +5,6 @@ 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 @@ -15,11 +14,8 @@ 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 @@ -1051,25 +1047,6 @@ 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() @@ -1091,7 +1068,7 @@ class E2eIntegrationTest { advanceUntilIdle() coVerify { mockApiService.updateProfileModel("default", any()) } - assertEquals("Successfully set profile model to llama3", viewModel.uiState.value.toastMessage) + assertEquals("Successfully set model to llama3", viewModel.uiState.value.toastMessage) } @Test