* 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:
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,
|
||||
)
|
||||
|
||||
@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,
|
||||
|
||||
@@ -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<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")
|
||||
suspend fun getToolsets(): Response<List<Toolset>>
|
||||
|
||||
|
||||
@@ -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<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 newCloneName by remember { mutableStateOf("") }
|
||||
@@ -80,6 +83,16 @@ fun ProfilesScreen(
|
||||
var descEditProfileName by remember { mutableStateOf<String?>(null) }
|
||||
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) }
|
||||
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -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<PinnedModel> = emptyList(),
|
||||
// Profile Builder states
|
||||
val modelProviders: List<ModelProvider> = 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 {
|
||||
|
||||
@@ -397,11 +397,21 @@
|
||||
<string name="profiles_title_set_model">모델 설정: %1$s</string>
|
||||
<string name="profiles_title_clone">프로필 복제: %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_description_input">설명</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_identity">아이덴티티</string>
|
||||
<string name="profiles_builder_step_model">모델 설정</string>
|
||||
|
||||
@@ -572,11 +572,21 @@
|
||||
<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_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_description_input">Description</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_identity">Identity</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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user