From 80be1688016e646a95212873ce66b78908ea4b4e Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:59:30 +0300 Subject: [PATCH] feat(profiles): delete, rename, auto-describe and setup command (issue #781) (#818) * feat(profiles): delete, rename, auto-describe and setup command (issue #781) * feat(profiles): use shared ModelPickerDialog for set model (issue #781) * fix(profiles): move overflow menu to card header so it can't be clipped (issue #781) --------- Co-authored-by: M57 --- .../m57/hermescontrol/data/model/Profile.kt | 23 + .../data/remote/HermesApiService.kt | 26 ++ .../ui/profiles/ProfilesScreen.kt | 424 ++++++++++++++---- .../ui/profiles/ProfilesViewModel.kt | 202 +++++++++ app/src/main/res/values-ko/strings.xml | 14 +- app/src/main/res/values/strings.xml | 14 +- .../ui/profiles/ProfilesViewModelTest.kt | 265 +++++++++++ 7 files changed, 868 insertions(+), 100 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index dd550a1f..53248d6e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -57,6 +57,29 @@ data class UpdateProfileDescriptionRequest( val description: String, ) +@Serializable +data class RenameProfileRequest( + val new_name: String, +) + +@Serializable +data class ProfileDescribeAutoRequest( + val overwrite: Boolean = false, +) + +@Serializable +data class ProfileDescribeAutoResponse( + val ok: Boolean, + val reason: String? = null, + val description: String? = null, + val description_auto: Boolean? = null, +) + +@Serializable +data class ProfileSetupCommandResponse( + val command: String, +) + @Serializable data class CreateProfileRequest( val name: String, 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 6ef64a83..2307845b 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 @@ -69,11 +69,15 @@ import com.m57.hermescontrol.data.model.PairingRevokeRequest import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.PortalResponse +import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest +import com.m57.hermescontrol.data.model.ProfileDescribeAutoResponse +import com.m57.hermescontrol.data.model.ProfileSetupCommandResponse import com.m57.hermescontrol.data.model.ProfileSoulResponse import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.RawConfigResponse import com.m57.hermescontrol.data.model.RecentUnlock +import com.m57.hermescontrol.data.model.RenameProfileRequest import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.ScanStatus import com.m57.hermescontrol.data.model.SessionDetailResponse @@ -358,6 +362,28 @@ interface HermesApiService { @Body body: UpdateProfileDescriptionRequest, ): Response + @PATCH("api/profiles/{name}") + suspend fun renameProfile( + @Path("name") name: String, + @Body body: RenameProfileRequest, + ): Response + + @DELETE("api/profiles/{name}") + suspend fun deleteProfile( + @Path("name") name: String, + ): Response + + @POST("api/profiles/{name}/describe-auto") + suspend fun describeProfileAuto( + @Path("name") name: String, + @Body body: ProfileDescribeAutoRequest, + ): Response + + @GET("api/profiles/{name}/setup-command") + suspend fun getProfileSetupCommand( + @Path("name") name: String, + ): Response + @GET("api/tools/toolsets") suspend fun getToolsets(): Response> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 8dd0c128..6045eed7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem 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 @@ -44,7 +45,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -57,6 +61,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.ui.profiles.components.ProfileBuilderView import com.m57.hermescontrol.ui.profiles.components.validateProfileName @@ -71,8 +76,6 @@ fun ProfilesScreen( var soulEditProfileName by remember { mutableStateOf(null) } var modelEditProfileName by remember { mutableStateOf(null) } - var tempModelProvider by remember { mutableStateOf("") } - var tempModelName by remember { mutableStateOf("") } var cloneProfileName by remember { mutableStateOf(null) } var newCloneName by remember { mutableStateOf("") } @@ -80,6 +83,16 @@ fun ProfilesScreen( var descEditProfileName by remember { mutableStateOf(null) } var tempDescription by remember { mutableStateOf("") } + var renameProfileName by remember { mutableStateOf(null) } + var newRenameName by remember { mutableStateOf("") } + + var deleteProfileName by remember { mutableStateOf(null) } + + var setupCmdProfileName by remember { mutableStateOf(null) } + var copiedSetupCmd by remember { mutableStateOf(false) } + + val clipboardManager = LocalClipboardManager.current + var isBuildingProfile by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -271,6 +284,107 @@ fun ProfilesScreen( modifier = Modifier.padding(start = 8.dp), ) } + + var showMenu by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More options", + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + if (profile.is_default != true) { + DropdownMenuItem( + text = { + Text( + stringResource( + R.string.profiles_action_rename, + ), + ) + }, + onClick = { + showMenu = false + renameProfileName = profile.name + newRenameName = profile.name + }, + ) + } + DropdownMenuItem( + text = { + Text( + stringResource( + R.string.profiles_action_auto_describe, + ), + ) + }, + enabled = !state.isAutoDescribing, + onClick = { + showMenu = false + viewModel.autoDescribeProfile(profile.name) + }, + ) + DropdownMenuItem( + text = { + Text( + stringResource( + R.string.profiles_action_edit_description, + ), + ) + }, + onClick = { + showMenu = false + descEditProfileName = profile.name + tempDescription = profile.description ?: "" + }, + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.profiles_action_clone), + ) + }, + onClick = { + showMenu = false + cloneProfileName = profile.name + newCloneName = "" + }, + ) + DropdownMenuItem( + text = { + Text( + stringResource( + R.string.profiles_action_setup_command, + ), + ) + }, + onClick = { + showMenu = false + setupCmdProfileName = profile.name + }, + ) + if (profile.is_default != true) { + HorizontalDivider() + DropdownMenuItem( + text = { + Text( + stringResource(R.string.action_delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showMenu = false + deleteProfileName = profile.name + }, + ) + } + } + } } Spacer(modifier = Modifier.height(12.dp)) @@ -358,56 +472,12 @@ fun ProfilesScreen( Button( onClick = { modelEditProfileName = profile.name - tempModelProvider = profile.provider ?: "" - tempModelName = profile.model ?: "" + viewModel.loadModelOptions() }, modifier = Modifier.padding(end = 8.dp), ) { Text(stringResource(R.string.profiles_action_set_model)) } - - var showMenu by remember { mutableStateOf(false) } - - Box { - IconButton(onClick = { showMenu = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "More options", - ) - } - - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { - Text( - stringResource( - R.string.profiles_action_edit_description, - ), - ) - }, - onClick = { - showMenu = false - descEditProfileName = profile.name - tempDescription = profile.description ?: "" - }, - ) - DropdownMenuItem( - text = { - Text( - stringResource(R.string.profiles_action_clone), - ) - }, - onClick = { - showMenu = false - cloneProfileName = profile.name - newCloneName = "" - }, - ) - } - } } } } @@ -486,57 +556,20 @@ fun ProfilesScreen( } if (modelEditProfileName != null) { - AlertDialog( - onDismissRequest = { modelEditProfileName = null }, - title = { - Text( - text = - stringResource( - R.string.profiles_title_set_model, - modelEditProfileName.orEmpty(), - ), - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = tempModelProvider, - onValueChange = { tempModelProvider = it }, - label = { Text(stringResource(R.string.profiles_label_provider_input)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - OutlinedTextField( - value = tempModelName, - onValueChange = { tempModelName = it }, - label = { Text(stringResource(R.string.profiles_label_model_input)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } - }, - confirmButton = { - TextButton( - onClick = { - val profileName = modelEditProfileName - if (profileName != null) { - viewModel.updateModel(profileName, tempModelProvider, tempModelName) - } - modelEditProfileName = null - }, - ) { - Text(stringResource(R.string.action_ok)) - } - }, - dismissButton = { - TextButton( - onClick = { modelEditProfileName = null }, - ) { - Text(stringResource(R.string.action_cancel)) + ModelPickerDialog( + providers = state.modelProviders, + title = stringResource(R.string.profiles_title_set_model, modelEditProfileName.orEmpty()), + isLoading = state.isLoadingBuilderData && state.modelProviders.isEmpty(), + pinnedModels = state.modelPickerPinned, + onPinToggle = viewModel::togglePinModel, + onSelect = { provider, model -> + val profileName = modelEditProfileName + if (profileName != null) { + viewModel.updateModel(profileName, provider, model) } + modelEditProfileName = null }, + onDismiss = { modelEditProfileName = null }, ) } @@ -633,6 +666,205 @@ fun ProfilesScreen( }, ) } + + if (renameProfileName != null) { + AlertDialog( + onDismissRequest = { renameProfileName = null }, + title = { + Text( + text = + stringResource( + R.string.profiles_title_rename, + renameProfileName.orEmpty(), + ), + ) + }, + text = { + val errorMsg = if (newRenameName.isNotEmpty()) validateProfileName(newRenameName) else null + OutlinedTextField( + value = newRenameName, + onValueChange = { newRenameName = it }, + label = { Text(stringResource(R.string.profiles_label_new_name_input)) }, + isError = errorMsg != null, + supportingText = errorMsg?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { + val oldName = renameProfileName + if (oldName != null) { + viewModel.renameProfile(oldName, newRenameName.trim()) + } + renameProfileName = null + }, + enabled = + newRenameName.isNotBlank() && + newRenameName != renameProfileName && + validateProfileName(newRenameName) == null, + ) { + Text(stringResource(R.string.profiles_action_rename)) + } + }, + dismissButton = { + TextButton( + onClick = { renameProfileName = null }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (deleteProfileName != null) { + val profileToDelete = state.profiles.firstOrNull { it.name == deleteProfileName } + AlertDialog( + onDismissRequest = { deleteProfileName = null }, + title = { + Text( + text = + stringResource( + R.string.profiles_title_delete, + deleteProfileName.orEmpty(), + ), + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = + stringResource( + R.string.profiles_delete_warning, + deleteProfileName.orEmpty(), + ), + ) + profileToDelete?.path?.let { + Text( + text = stringResource(R.string.profiles_delete_path, it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (profileToDelete?.gateway_running == true) { + Text( + text = stringResource(R.string.profiles_delete_gateway_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val name = deleteProfileName + if (name != null) { + viewModel.deleteProfile(name) + } + deleteProfileName = null + }, + ) { + Text( + text = stringResource(R.string.action_delete), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton( + onClick = { deleteProfileName = null }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (setupCmdProfileName != null) { + LaunchedEffect(setupCmdProfileName) { + copiedSetupCmd = false + setupCmdProfileName?.let { viewModel.fetchSetupCommand(it) } + } + AlertDialog( + onDismissRequest = { setupCmdProfileName = null }, + title = { + Text( + text = + stringResource( + R.string.profiles_title_setup_command, + setupCmdProfileName.orEmpty(), + ), + ) + }, + text = { + if (state.isLoadingSetupCommand) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(80.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.profiles_setup_command_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + ) { + Text( + text = state.setupCommand.orEmpty(), + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + } + } + }, + confirmButton = { + if (!state.isLoadingSetupCommand && state.setupCommand != null) { + Button( + onClick = { + clipboardManager.setText(AnnotatedString(state.setupCommand.orEmpty())) + copiedSetupCmd = true + }, + ) { + Text( + text = + stringResource( + if (copiedSetupCmd) { + R.string.profiles_action_copied + } else { + R.string.profiles_action_copy + }, + ), + ) + } + } + }, + dismissButton = { + TextButton( + onClick = { setupCmdProfileName = null }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } } // --------------------------------------------------------------------- diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index 64a560b5..ae0d78aa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -2,11 +2,15 @@ package com.m57.hermescontrol.ui.profiles import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.CloneProfileRequest import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.data.model.RenameProfileRequest import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest @@ -34,6 +38,12 @@ data class ProfilesUiState( val isLoadingSoul: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, + // Rename / delete / auto-describe / setup-command states + val isAutoDescribing: Boolean = false, + val setupCommand: String? = null, + val isLoadingSetupCommand: Boolean = false, + // Model picker (issue #781 — Set Model uses the shared ModelPickerDialog) + val modelPickerPinned: List = emptyList(), // Profile Builder states val modelProviders: List = emptyList(), val isLoadingBuilderData: Boolean = false, @@ -296,6 +306,198 @@ class ProfilesViewModel : } } + fun renameProfile( + oldName: String, + newName: String, + ) { + if (oldName == newName) return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.renameProfile( + oldName, + RenameProfileRequest(newName), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Profile '$oldName' renamed to '$newName'", + ) + } + loadProfiles() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to rename profile '$oldName': ${result.error.message}", + ) + } + } + } + } + } + + fun deleteProfile(name: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.deleteProfile(name) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + toastMessage = "Profile '$name' deleted", + ) + } + loadProfiles() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to delete profile '$name': ${result.error.message}", + ) + } + } + } + } + } + + fun autoDescribeProfile(name: String) { + _uiState.update { it.copy(isAutoDescribing = true) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.describeProfileAuto( + name, + ProfileDescribeAutoRequest(overwrite = true), + ) + } + } + when (result) { + is NetworkResult.Success -> { + val body = result.data + if (body.ok) { + _uiState.update { + it.copy( + isAutoDescribing = false, + toastMessage = "Auto-described profile '$name'", + ) + } + loadProfiles() + } else { + // Backend returns generation failures as ok:false + reason, + // NOT an HTTP error — surface the reason to the user. + _uiState.update { + it.copy( + isAutoDescribing = false, + toastMessage = + "Auto-describe failed for '$name': " + + (body.reason ?: "unknown reason"), + ) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isAutoDescribing = false, + toastMessage = "Failed to auto-describe profile '$name': ${result.error.message}", + ) + } + } + } + } + } + + fun fetchSetupCommand(name: String) { + _uiState.update { it.copy(isLoadingSetupCommand = true, setupCommand = null) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getProfileSetupCommand(name) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isLoadingSetupCommand = false, + setupCommand = result.data.command, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoadingSetupCommand = false, + toastMessage = "Failed to fetch setup command: ${result.error.message}", + ) + } + } + } + } + } + + fun loadModelOptions() { + _uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getModelOptions() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isLoadingBuilderData = false, + modelProviders = result.data.providers, + modelPickerPinned = AuthManager.getPinnedModels(), + ) + } + } + + is NetworkResult.Failure -> { + // Toast, not errorMessage — a picker-load failure must NOT + // blank the profiles list with the ErrorState branch. + _uiState.update { + it.copy( + isLoadingBuilderData = false, + toastMessage = "Failed to load models: ${result.error.message}", + ) + } + } + } + } + } + + fun togglePinModel( + providerSlug: String, + modelName: String, + ) { + val target = PinnedModel(providerSlug, modelName) + val current = AuthManager.getPinnedModels().toMutableList() + if (target in current) { + current.remove(target) + } else { + current.add(target) + } + AuthManager.savePinnedModels(current) + _uiState.update { it.copy(modelPickerPinned = current) } + } + fun loadBuilderData() { _uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) } viewModelScope.launch { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1f2839a1..84998fae 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -397,11 +397,21 @@ 모델 설정: %1$s 프로필 복제: %1$s 설명 편집: %1$s - 제공자 - 모델명 새 프로필명 설명 설명이 없습니다. 이 프로필의 역할을 설명해주세요. + 이름 변경 + 자동 설명 + 설정 명령 + 복사 + 복사됨 ✓ + 프로필 이름 변경: %1$s + 프로필 삭제: %1$s + 설정 명령: %1$s + 프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다. + 경로: %1$s + 이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다. + Hermes CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요. %1$d / 5 단계 아이덴티티 모델 설정 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf05afb4..09b038c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -572,11 +572,21 @@ Set Model: %1$s Clone Profile: %1$s Edit Description: %1$s - Provider - Model Name New Profile Name Description No description provided. Describe this profile\'s role. + Rename + Auto-Describe + Setup Command + Copy + Copied ✓ + Rename Profile: %1$s + Delete Profile: %1$s + Setup Command: %1$s + This permanently deletes the profile \'%1$s\' and its data. + Path: %1$s + This profile has a running gateway — deleting it will stop it. + Run this command in the Hermes CLI to configure this profile. Step %1$d of 5 Identity Model Configuration diff --git a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt new file mode 100644 index 00000000..6ee5f3fc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt @@ -0,0 +1,265 @@ +package com.m57.hermescontrol.ui.profiles + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.ActiveProfileResponse +import com.m57.hermescontrol.data.model.ModelOptionsResponse +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest +import com.m57.hermescontrol.data.model.ProfileDescribeAutoResponse +import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.data.model.ProfileSetupCommandResponse +import com.m57.hermescontrol.data.model.ProfilesResponse +import com.m57.hermescontrol.data.model.RenameProfileRequest +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +/** + * Issue #781 — Profiles screen delete/rename/auto-describe/setup-command. + * Contracts verified against hermes-agent web_routers/profiles.py: + * - PATCH /api/profiles/{name} {new_name} -> {ok, name, path} + * - DELETE /api/profiles/{name} -> {ok, path} + * - POST /api/profiles/{name}/describe-auto {overwrite} -> {ok, reason, description, description_auto} + * (generation failures come back as ok:false + reason, NOT an HTTP error) + * - GET /api/profiles/{name}/setup-command -> {command} + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfilesViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockApi: HermesApiService + private var storedPinnedModels: MutableList = mutableListOf() + + private fun stubProfilesLoad() { + coEvery { mockApi.getProfiles() } returns + Response.success(ProfilesResponse(listOf(ProfileInfo(name = "default", is_default = true)))) + coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default")) + } + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + val testMainDispatcher = Dispatchers.Main + + mockkStatic(Dispatchers::class) + every { Dispatchers.IO } returns testDispatcher + every { Dispatchers.Main } returns testMainDispatcher + + mockkObject(AuthManager) + storedPinnedModels = mutableListOf() + every { AuthManager.getPinnedModels() } answers { storedPinnedModels.toList() } + every { AuthManager.savePinnedModels(any()) } answers { + storedPinnedModels = firstArg>().toMutableList() + } + + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + + stubProfilesLoad() + } + + private fun errorResponse(code: Int): Response = Response.error(code, "{}".toResponseBody(null)) + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + private fun createViewModel(): ProfilesViewModel { + val vm = ProfilesViewModel() + testDispatcher.scheduler.advanceUntilIdle() + return vm + } + + @Test + fun `renameProfile success calls backend and reloads`() { + coEvery { mockApi.renameProfile(any(), any()) } returns Response.success(Unit) + + val vm = createViewModel() + vm.renameProfile("work", "play") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockApi.renameProfile("work", RenameProfileRequest("play")) } + assertTrue(vm.uiState.value.toastMessage!!.contains("renamed")) + // reload happened + coVerify { mockApi.getProfiles() } + } + + @Test + fun `renameProfile to same name is a no-op`() { + // If the VM ever fired the API with an unchanged name, this stub throws + // and the test fails — no fragile exactly=0 verification needed. + coEvery { mockApi.renameProfile(any(), any()) } throws IllegalStateException("should not be called") + + val vm = createViewModel() + vm.renameProfile("work", "work") + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(vm.uiState.value.toastMessage) + } + + @Test + fun `renameProfile failure surfaces toast and stops loading`() { + coEvery { mockApi.renameProfile(any(), any()) } returns errorResponse(404) + + val vm = createViewModel() + vm.renameProfile("work", "play") + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to rename")) + assertFalse(vm.uiState.value.isLoading) + } + + @Test + fun `deleteProfile success calls backend and reloads`() { + coEvery { mockApi.deleteProfile(any()) } returns Response.success(Unit) + + val vm = createViewModel() + vm.deleteProfile("work") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockApi.deleteProfile("work") } + assertTrue(vm.uiState.value.toastMessage!!.contains("deleted")) + coVerify { mockApi.getProfiles() } + } + + @Test + fun `deleteProfile failure surfaces toast`() { + coEvery { mockApi.deleteProfile(any()) } returns errorResponse(404) + + val vm = createViewModel() + vm.deleteProfile("work") + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to delete")) + } + + @Test + fun `autoDescribeProfile success with ok true reloads`() { + coEvery { mockApi.describeProfileAuto(any(), any()) } returns + Response.success(ProfileDescribeAutoResponse(ok = true, description = "desc", description_auto = true)) + + val vm = createViewModel() + vm.autoDescribeProfile("work") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockApi.describeProfileAuto("work", ProfileDescribeAutoRequest(overwrite = true)) } + assertTrue(vm.uiState.value.toastMessage!!.contains("Auto-described")) + assertFalse(vm.uiState.value.isAutoDescribing) + coVerify { mockApi.getProfiles() } + } + + @Test + fun `autoDescribeProfile ok false surfaces backend reason`() { + coEvery { mockApi.describeProfileAuto(any(), any()) } returns + Response.success(ProfileDescribeAutoResponse(ok = false, reason = "no aux client configured")) + + val vm = createViewModel() + vm.autoDescribeProfile("work") + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.toastMessage!!.contains("no aux client configured")) + assertFalse(vm.uiState.value.isAutoDescribing) + } + + @Test + fun `fetchSetupCommand success stores command`() { + coEvery { mockApi.getProfileSetupCommand(any()) } returns + Response.success(ProfileSetupCommandResponse(command = "work setup")) + + val vm = createViewModel() + vm.fetchSetupCommand("work") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockApi.getProfileSetupCommand("work") } + assertEquals("work setup", vm.uiState.value.setupCommand) + assertFalse(vm.uiState.value.isLoadingSetupCommand) + } + + @Test + fun `fetchSetupCommand failure leaves command null and toasts`() { + coEvery { mockApi.getProfileSetupCommand(any()) } returns errorResponse(404) + + val vm = createViewModel() + vm.fetchSetupCommand("work") + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(vm.uiState.value.setupCommand) + assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to fetch setup command")) + } + + @Test + fun `loadModelOptions success populates providers and pins`() { + storedPinnedModels.add(PinnedModel("openai", "gpt-4")) + coEvery { mockApi.getModelOptions() } returns + Response.success( + ModelOptionsResponse( + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4o", "gpt-4o-mini"), + ), + ), + ), + ) + + val vm = createViewModel() + vm.loadModelOptions() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, vm.uiState.value.modelProviders.size) + assertEquals("gpt-4o", vm.uiState.value.modelProviders[0].models!![0]) + assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned) + assertFalse(vm.uiState.value.isLoadingBuilderData) + } + + @Test + fun `loadModelOptions failure toasts without blanking errorMessage`() { + coEvery { mockApi.getModelOptions() } returns errorResponse(500) + + val vm = createViewModel() + vm.loadModelOptions() + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to load models")) + assertNull(vm.uiState.value.errorMessage) + assertFalse(vm.uiState.value.isLoadingBuilderData) + } + + @Test + fun `togglePinModel adds then removes pinned model`() { + val vm = createViewModel() + + vm.togglePinModel("openai", "gpt-4") + assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned) + assertEquals(listOf(PinnedModel("openai", "gpt-4")), storedPinnedModels) + + vm.togglePinModel("openai", "gpt-4") + assertTrue(vm.uiState.value.modelPickerPinned.isEmpty()) + assertTrue(storedPinnedModels.isEmpty()) + } +}