* 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
+865
-97
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(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
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
|
val profileName = modelEditProfileName
|
||||||
if (profileName != null) {
|
if (profileName != null) {
|
||||||
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
|
viewModel.updateModel(profileName, provider, model)
|
||||||
}
|
}
|
||||||
modelEditProfileName = null
|
modelEditProfileName = null
|
||||||
},
|
},
|
||||||
) {
|
onDismiss = { modelEditProfileName = null },
|
||||||
Text(stringResource(R.string.action_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { modelEditProfileName = null },
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user