diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 9e91a1b2..60342471 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -911,6 +912,25 @@ fun ChatScreen( ) } + // Expensive / Data-policy model confirmation dialog (issue #589 follow-up) + if (state.modelSwitchConfirmMessage != null) { + AlertDialog( + onDismissRequest = { viewModel.dismissModelSwitchConfirm() }, + title = { Text(stringResource(R.string.model_expensive_title)) }, + text = { Text(state.modelSwitchConfirmMessage!!) }, + confirmButton = { + Button(onClick = { viewModel.confirmModelSwitchExpensive() }) { + Text(stringResource(R.string.model_confirm_continue)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissModelSwitchConfirm() }) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } + if (showContextSheet && state.contextBreakdown != null) { ContextDetailSheet( breakdown = state.contextBreakdown!!, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 19a29724..10d63fa8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -327,6 +327,7 @@ data class ChatUiState( val modelPickerProviders: List = emptyList(), val modelPickerPinned: List = emptyList(), val modelPickerLoading: Boolean = false, + val modelSwitchConfirmMessage: String? = null, // Current session's active model label (provider/model), shown in the chip val currentSessionModel: String? = null, // Per-model reasoning capabilities for the current session's model @@ -463,6 +464,15 @@ class ChatViewModel( private val sessionRequestById = ConcurrentHashMap() private var sessionGeneration = 0L + + private data class ActiveModelSwitch( + val spec: String, + val previousModel: String?, + ) + + private val pendingModelSwitchRequests = ConcurrentHashMap() + private var activeModelSwitchConfirmation: ActiveModelSwitch? = null + private var optimisticPreviousModel: String? = null private var resumeRequestSequence = 0L private var activeResumeRequestSequence = 0L private var hydrationRequestSequence = 0L @@ -1208,6 +1218,25 @@ class ChatViewModel( addSystemMessage("Approval submitted") } } + + WsMethods.CONFIG_SET -> { + val pending = pendingModelSwitchRequests.remove(id) + val map = result as? Map<*, *> ?: return + val key = map["key"] as? String + if (key == "model") { + val confirmRequired = map["confirm_required"] as? Boolean ?: false + if (confirmRequired) { + val confirmMessage = + (map["confirm_message"] as? String) + ?: (map["warning"] as? String) + ?: "This model requires confirmation to switch. Continue?" + activeModelSwitchConfirmation = pending + _uiState.update { + it.copy(modelSwitchConfirmMessage = confirmMessage) + } + } + } + } } } @@ -1889,7 +1918,10 @@ class ChatViewModel( * ` --provider --session` * (matching the TUI client's `modelValueForConfigSet`). */ - private fun handleModelSwitch(command: String) { + private fun handleModelSwitch( + command: String, + confirmExpensive: Boolean = false, + ) { val sessionId = runtimeSessionId if (sessionId == null) { addAssistantMessage("No active session. Use `/new` to create one.") @@ -1906,11 +1938,25 @@ class ChatViewModel( } else { command.trim() } + val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel + optimisticPreviousModel = null + val params = + mutableMapOf( + "key" to "model", + "value" to spec, + "session_id" to sessionId, + ) + if (confirmExpensive) { + params["confirm_expensive_model"] = true + } viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.CONFIG_SET, - mapOf("key" to "model", "value" to spec, "session_id" to sessionId), - onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) }, + params, + onSent = { id -> + trackRequest(id, WsMethods.CONFIG_SET) + pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel) + }, ) } } @@ -2265,6 +2311,7 @@ class ChatViewModel( provider: String, model: String, ) { + optimisticPreviousModel = _uiState.value.currentSessionModel _uiState.update { it.copy( showModelPicker = false, @@ -2280,6 +2327,28 @@ class ChatViewModel( handleSlashCommand("/model $model --provider $provider --session") } + fun dismissModelSwitchConfirm() { + val pending = activeModelSwitchConfirmation + activeModelSwitchConfirmation = null + _uiState.update { + it.copy( + modelSwitchConfirmMessage = null, + currentSessionModel = pending?.previousModel ?: it.currentSessionModel, + ) + } + syncCurrentModelCapabilities() + fetchContextUsage() + } + + fun confirmModelSwitchExpensive() { + val pending = activeModelSwitchConfirmation + activeModelSwitchConfirmation = null + _uiState.update { it.copy(modelSwitchConfirmMessage = null) } + if (pending != null) { + handleModelSwitch(pending.spec, confirmExpensive = true) + } + } + /** * Set the reasoning effort level for the current session. * @@ -2575,6 +2644,7 @@ class ChatViewModel( showSessionPicker = false, showModelPicker = false, modelPickerLoading = false, + modelSwitchConfirmMessage = null, currentSessionModel = null, currentModelCapabilities = null, reasoningLevel = null, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6a82b593..431a54a4 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1078,7 +1078,8 @@ مزيج الوكلاء (Mixture of Agents) المهام المساعدة المهام المساعدة -نموذج عالي التكلفة +نموذج مرتفع التكلفة +متابعة النموذج الرئيسي مزيج الوكلاء (Mixture of Agents) ينطبق على الجلسات الجديدة diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9dd5cf74..ba813670 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -387,6 +387,7 @@ 보고된 모델이 없거나 인증이 필요합니다. 활성 모델 고정된 모델 + 계속 모델 고정 해제 고정 섹션 표시 전환 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index bdff2ba1..ebd07c23 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1069,6 +1069,7 @@ 多 Agent 混合 辅助任务 辅助任务 + 继续 高成本模型 主模型 多 Agent 混合 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3113b56..6930444a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1198,6 +1198,7 @@ Mixture of Agents Auxiliary tasks Auxiliary Tasks + Continue Expensive Model Main model Mixture of Agents diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 3df66a2b..c6c3f9ea 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -793,6 +793,109 @@ class ChatViewModelTest { assertEquals(sessionId, call.third) } + @Test + fun testModelSwitch_confirmRequired_triggersConfirmationDialogAndReSends() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + var lastReqId = "" + val capturedParams = mutableListOf>() + every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + val params = arg>(1) + capturedParams.add(params) + val id = "req-confirm-${capturedParams.size}" + lastReqId = id + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // User sends model switch command that triggers privacy/expensive guard + viewModel.sendMessage("/model muse-spark-1.3-contributor-free --provider opencode-free --session") + advanceUntilIdle() + + assertEquals(1, capturedParams.size) + assertNull(capturedParams[0]["confirm_expensive_model"]) + + // Backend returns confirm_required = true + mockEventsFlow.emit( + WsEvent.RpcResult( + id = lastReqId, + result = + mapOf( + "key" to "model", + "value" to "muse-spark-1.3-contributor-free", + "confirm_required" to true, + "confirm_message" to "Meta training warning", + ), + ), + ) + advanceUntilIdle() + + // UI state now shows confirmation dialog message + assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage) + + // User confirms + viewModel.confirmModelSwitchExpensive() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.modelSwitchConfirmMessage) + assertEquals(2, capturedParams.size) + assertEquals(true, capturedParams[1]["confirm_expensive_model"]) + assertEquals( + "muse-spark-1.3-contributor-free --provider opencode-free --session", + capturedParams[1]["value"], + ) + assertEquals(sessionId, capturedParams[1]["session_id"]) + } + + @Test + fun testModelSwitch_dismissConfirmation_revertsModel() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + var lastReqId = "" + every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + val id = "req-cfg-dismiss" + lastReqId = id + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // Set an initial model + viewModel.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel) + + // Switch to expensive model + viewModel.sendSlashModel("opencode-free", "muse-spark-1.3-contributor-free") + advanceUntilIdle() + assertEquals("opencode-free/muse-spark-1.3-contributor-free", viewModel.uiState.value.currentSessionModel) + + // Backend requires confirmation + mockEventsFlow.emit( + WsEvent.RpcResult( + id = lastReqId, + result = + mapOf( + "key" to "model", + "value" to "muse-spark-1.3-contributor-free", + "confirm_required" to true, + "confirm_message" to "Meta training warning", + ), + ), + ) + advanceUntilIdle() + assertEquals("Meta training warning", viewModel.uiState.value.modelSwitchConfirmMessage) + + // User dismisses/cancels + viewModel.dismissModelSwitchConfirm() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.modelSwitchConfirmMessage) + // Reverted back to previous model + assertEquals("openai/gpt-4o", viewModel.uiState.value.currentSessionModel) + } + // ── Connection / init tests ────────────────────────────────────────────── @Test