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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-05 17:59:30 +03:00
1 parent 19c053b3fa
commit 80be168801
7 files changed
+868 -100

No files matched your search

@@ -57,6 +57,29 @@ data class UpdateProfileDescriptionRequest(
val description: String, 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 @Serializable
data class CreateProfileRequest( data class CreateProfileRequest(
val name: String, val name: String,
@@ -69,11 +69,15 @@ import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.PortalResponse 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.ProfileSoulResponse
import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.PruneRequest
import com.m57.hermescontrol.data.model.RawConfigResponse import com.m57.hermescontrol.data.model.RawConfigResponse
import com.m57.hermescontrol.data.model.RecentUnlock 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.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.ScanStatus import com.m57.hermescontrol.data.model.ScanStatus
import com.m57.hermescontrol.data.model.SessionDetailResponse import com.m57.hermescontrol.data.model.SessionDetailResponse
@@ -358,6 +362,28 @@ interface HermesApiService {
@Body body: UpdateProfileDescriptionRequest, @Body body: UpdateProfileDescriptionRequest,
): Response<Unit> ): Response<Unit>
@PATCH("api/profiles/{name}")
suspend fun renameProfile(
@Path("name") name: String,
@Body body: RenameProfileRequest,
): Response<Unit>
@DELETE("api/profiles/{name}")
suspend fun deleteProfile(
@Path("name") name: String,
): Response<Unit>
@POST("api/profiles/{name}/describe-auto")
suspend fun describeProfileAuto(
@Path("name") name: String,
@Body body: ProfileDescribeAutoRequest,
): Response<ProfileDescribeAutoResponse>
@GET("api/profiles/{name}/setup-command")
suspend fun getProfileSetupCommand(
@Path("name") name: String,
): Response<ProfileSetupCommandResponse>
@GET("api/tools/toolsets") @GET("api/tools/toolsets")
suspend fun getToolsets(): Response<List<Toolset>> suspend fun getToolsets(): Response<List<Toolset>>
@@ -28,6 +28,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -44,7 +45,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource 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.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect 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.ProfileBuilderView
import com.m57.hermescontrol.ui.profiles.components.validateProfileName import com.m57.hermescontrol.ui.profiles.components.validateProfileName
@@ -71,8 +76,6 @@ fun ProfilesScreen(
var soulEditProfileName by remember { mutableStateOf<String?>(null) } var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) } var modelEditProfileName by remember { mutableStateOf<String?>(null) }
var tempModelProvider by remember { mutableStateOf("") }
var tempModelName by remember { mutableStateOf("") }
var cloneProfileName by remember { mutableStateOf<String?>(null) } var cloneProfileName by remember { mutableStateOf<String?>(null) }
var newCloneName by remember { mutableStateOf("") } var newCloneName by remember { mutableStateOf("") }
@@ -80,6 +83,16 @@ fun ProfilesScreen(
var descEditProfileName by remember { mutableStateOf<String?>(null) } var descEditProfileName by remember { mutableStateOf<String?>(null) }
var tempDescription by remember { mutableStateOf("") } var tempDescription by remember { mutableStateOf("") }
var renameProfileName by remember { mutableStateOf<String?>(null) }
var newRenameName by remember { mutableStateOf("") }
var deleteProfileName by remember { mutableStateOf<String?>(null) }
var setupCmdProfileName by remember { mutableStateOf<String?>(null) }
var copiedSetupCmd by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
var isBuildingProfile by remember { mutableStateOf(false) } var isBuildingProfile by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -271,6 +284,107 @@ fun ProfilesScreen(
modifier = Modifier.padding(start = 8.dp), 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)) Spacer(modifier = Modifier.height(12.dp))
@@ -358,56 +472,12 @@ fun ProfilesScreen(
Button( Button(
onClick = { onClick = {
modelEditProfileName = profile.name modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: "" viewModel.loadModelOptions()
tempModelName = profile.model ?: ""
}, },
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
) { ) {
Text(stringResource(R.string.profiles_action_set_model)) 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) { if (modelEditProfileName != null) {
AlertDialog( ModelPickerDialog(
onDismissRequest = { modelEditProfileName = null }, providers = state.modelProviders,
title = { title = stringResource(R.string.profiles_title_set_model, modelEditProfileName.orEmpty()),
Text( isLoading = state.isLoadingBuilderData && state.modelProviders.isEmpty(),
text = pinnedModels = state.modelPickerPinned,
stringResource( onPinToggle = viewModel::togglePinModel,
R.string.profiles_title_set_model, onSelect = { provider, model ->
modelEditProfileName.orEmpty(), val profileName = modelEditProfileName
), if (profileName != null) {
) viewModel.updateModel(profileName, provider, model)
},
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))
} }
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))
}
},
)
}
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -2,11 +2,15 @@ package com.m57.hermescontrol.ui.profiles
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CloneProfileRequest import com.m57.hermescontrol.data.model.CloneProfileRequest
import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.ModelProvider 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.ProfileInfo
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
@@ -34,6 +38,12 @@ data class ProfilesUiState(
val isLoadingSoul: Boolean = false, val isLoadingSoul: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: 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<PinnedModel> = emptyList(),
// Profile Builder states // Profile Builder states
val modelProviders: List<ModelProvider> = emptyList(), val modelProviders: List<ModelProvider> = emptyList(),
val isLoadingBuilderData: Boolean = false, 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() { fun loadBuilderData() {
_uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) } _uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
+12 -2
View File
@@ -397,11 +397,21 @@
<string name="profiles_title_set_model">모델 설정: %1$s</string> <string name="profiles_title_set_model">모델 설정: %1$s</string>
<string name="profiles_title_clone">프로필 복제: %1$s</string> <string name="profiles_title_clone">프로필 복제: %1$s</string>
<string name="profiles_title_edit_description">설명 편집: %1$s</string> <string name="profiles_title_edit_description">설명 편집: %1$s</string>
<string name="profiles_label_provider_input">제공자</string>
<string name="profiles_label_model_input">모델명</string>
<string name="profiles_label_new_name_input">새 프로필명</string> <string name="profiles_label_new_name_input">새 프로필명</string>
<string name="profiles_label_description_input">설명</string> <string name="profiles_label_description_input">설명</string>
<string name="profiles_description_placeholder">설명이 없습니다. 이 프로필의 역할을 설명해주세요.</string> <string name="profiles_description_placeholder">설명이 없습니다. 이 프로필의 역할을 설명해주세요.</string>
<string name="profiles_action_rename">이름 변경</string>
<string name="profiles_action_auto_describe">자동 설명</string>
<string name="profiles_action_setup_command">설정 명령</string>
<string name="profiles_action_copy">복사</string>
<string name="profiles_action_copied">복사됨 ✓</string>
<string name="profiles_title_rename">프로필 이름 변경: %1$s</string>
<string name="profiles_title_delete">프로필 삭제: %1$s</string>
<string name="profiles_title_setup_command">설정 명령: %1$s</string>
<string name="profiles_delete_warning">프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다.</string>
<string name="profiles_delete_path">경로: %1$s</string>
<string name="profiles_delete_gateway_warning">이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다.</string>
<string name="profiles_setup_command_hint">Hermes CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string>
<string name="profiles_builder_step_indicator">%1$d / 5 단계</string> <string name="profiles_builder_step_indicator">%1$d / 5 단계</string>
<string name="profiles_builder_step_identity">아이덴티티</string> <string name="profiles_builder_step_identity">아이덴티티</string>
<string name="profiles_builder_step_model">모델 설정</string> <string name="profiles_builder_step_model">모델 설정</string>
+12 -2
View File
@@ -572,11 +572,21 @@
<string name="profiles_title_set_model">Set Model: %1$s</string> <string name="profiles_title_set_model">Set Model: %1$s</string>
<string name="profiles_title_clone">Clone Profile: %1$s</string> <string name="profiles_title_clone">Clone Profile: %1$s</string>
<string name="profiles_title_edit_description">Edit Description: %1$s</string> <string name="profiles_title_edit_description">Edit Description: %1$s</string>
<string name="profiles_label_provider_input">Provider</string>
<string name="profiles_label_model_input">Model Name</string>
<string name="profiles_label_new_name_input">New Profile Name</string> <string name="profiles_label_new_name_input">New Profile Name</string>
<string name="profiles_label_description_input">Description</string> <string name="profiles_label_description_input">Description</string>
<string name="profiles_description_placeholder">No description provided. Describe this profile\'s role.</string> <string name="profiles_description_placeholder">No description provided. Describe this profile\'s role.</string>
<string name="profiles_action_rename">Rename</string>
<string name="profiles_action_auto_describe">Auto-Describe</string>
<string name="profiles_action_setup_command">Setup Command</string>
<string name="profiles_action_copy">Copy</string>
<string name="profiles_action_copied">Copied ✓</string>
<string name="profiles_title_rename">Rename Profile: %1$s</string>
<string name="profiles_title_delete">Delete Profile: %1$s</string>
<string name="profiles_title_setup_command">Setup Command: %1$s</string>
<string name="profiles_delete_warning">This permanently deletes the profile \'%1$s\' and its data.</string>
<string name="profiles_delete_path">Path: %1$s</string>
<string name="profiles_delete_gateway_warning">This profile has a running gateway — deleting it will stop it.</string>
<string name="profiles_setup_command_hint">Run this command in the Hermes CLI to configure this profile.</string>
<string name="profiles_builder_step_indicator">Step %1$d of 5</string> <string name="profiles_builder_step_indicator">Step %1$d of 5</string>
<string name="profiles_builder_step_identity">Identity</string> <string name="profiles_builder_step_identity">Identity</string>
<string name="profiles_builder_step_model">Model Configuration</string> <string name="profiles_builder_step_model">Model Configuration</string>
@@ -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<PinnedModel> = 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<List<PinnedModel>>().toMutableList()
}
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
stubProfilesLoad()
}
private fun <T> errorResponse(code: Int): Response<T> = 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())
}
}