fix(chat): support confirmation dialog when switching to expensive or data-policy models (#1050)
This commit is contained in:
7 files changed
+201
-4
No files matched your search
@@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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) {
|
if (showContextSheet && state.contextBreakdown != null) {
|
||||||
ContextDetailSheet(
|
ContextDetailSheet(
|
||||||
breakdown = state.contextBreakdown!!,
|
breakdown = state.contextBreakdown!!,
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ data class ChatUiState(
|
|||||||
val modelPickerProviders: List<ModelProvider> = emptyList(),
|
val modelPickerProviders: List<ModelProvider> = emptyList(),
|
||||||
val modelPickerPinned: List<PinnedModel> = emptyList(),
|
val modelPickerPinned: List<PinnedModel> = emptyList(),
|
||||||
val modelPickerLoading: Boolean = false,
|
val modelPickerLoading: Boolean = false,
|
||||||
|
val modelSwitchConfirmMessage: String? = null,
|
||||||
// Current session's active model label (provider/model), shown in the chip
|
// Current session's active model label (provider/model), shown in the chip
|
||||||
val currentSessionModel: String? = null,
|
val currentSessionModel: String? = null,
|
||||||
// Per-model reasoning capabilities for the current session's model
|
// Per-model reasoning capabilities for the current session's model
|
||||||
@@ -463,6 +464,15 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||||
private var sessionGeneration = 0L
|
private var sessionGeneration = 0L
|
||||||
|
|
||||||
|
private data class ActiveModelSwitch(
|
||||||
|
val spec: String,
|
||||||
|
val previousModel: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
|
||||||
|
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
|
||||||
|
private var optimisticPreviousModel: String? = null
|
||||||
private var resumeRequestSequence = 0L
|
private var resumeRequestSequence = 0L
|
||||||
private var activeResumeRequestSequence = 0L
|
private var activeResumeRequestSequence = 0L
|
||||||
private var hydrationRequestSequence = 0L
|
private var hydrationRequestSequence = 0L
|
||||||
@@ -1208,6 +1218,25 @@ class ChatViewModel(
|
|||||||
addSystemMessage("Approval submitted")
|
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(
|
|||||||
* `<model> --provider <slug> --session`
|
* `<model> --provider <slug> --session`
|
||||||
* (matching the TUI client's `modelValueForConfigSet`).
|
* (matching the TUI client's `modelValueForConfigSet`).
|
||||||
*/
|
*/
|
||||||
private fun handleModelSwitch(command: String) {
|
private fun handleModelSwitch(
|
||||||
|
command: String,
|
||||||
|
confirmExpensive: Boolean = false,
|
||||||
|
) {
|
||||||
val sessionId = runtimeSessionId
|
val sessionId = runtimeSessionId
|
||||||
if (sessionId == null) {
|
if (sessionId == null) {
|
||||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||||
@@ -1906,11 +1938,25 @@ class ChatViewModel(
|
|||||||
} else {
|
} else {
|
||||||
command.trim()
|
command.trim()
|
||||||
}
|
}
|
||||||
|
val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel
|
||||||
|
optimisticPreviousModel = null
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"key" to "model",
|
||||||
|
"value" to spec,
|
||||||
|
"session_id" to sessionId,
|
||||||
|
)
|
||||||
|
if (confirmExpensive) {
|
||||||
|
params["confirm_expensive_model"] = true
|
||||||
|
}
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
WsMethods.CONFIG_SET,
|
WsMethods.CONFIG_SET,
|
||||||
mapOf("key" to "model", "value" to spec, "session_id" to sessionId),
|
params,
|
||||||
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
|
onSent = { id ->
|
||||||
|
trackRequest(id, WsMethods.CONFIG_SET)
|
||||||
|
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2265,6 +2311,7 @@ class ChatViewModel(
|
|||||||
provider: String,
|
provider: String,
|
||||||
model: String,
|
model: String,
|
||||||
) {
|
) {
|
||||||
|
optimisticPreviousModel = _uiState.value.currentSessionModel
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
showModelPicker = false,
|
showModelPicker = false,
|
||||||
@@ -2280,6 +2327,28 @@ class ChatViewModel(
|
|||||||
handleSlashCommand("/model $model --provider $provider --session")
|
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.
|
* Set the reasoning effort level for the current session.
|
||||||
*
|
*
|
||||||
@@ -2575,6 +2644,7 @@ class ChatViewModel(
|
|||||||
showSessionPicker = false,
|
showSessionPicker = false,
|
||||||
showModelPicker = false,
|
showModelPicker = false,
|
||||||
modelPickerLoading = false,
|
modelPickerLoading = false,
|
||||||
|
modelSwitchConfirmMessage = null,
|
||||||
currentSessionModel = null,
|
currentSessionModel = null,
|
||||||
currentModelCapabilities = null,
|
currentModelCapabilities = null,
|
||||||
reasoningLevel = null,
|
reasoningLevel = null,
|
||||||
|
|||||||
@@ -1078,7 +1078,8 @@
|
|||||||
<string name="moa_title">مزيج الوكلاء (Mixture of Agents)</string>
|
<string name="moa_title">مزيج الوكلاء (Mixture of Agents)</string>
|
||||||
<string name="model_aux_tasks_label">المهام المساعدة</string>
|
<string name="model_aux_tasks_label">المهام المساعدة</string>
|
||||||
<string name="model_aux_tasks_title">المهام المساعدة</string>
|
<string name="model_aux_tasks_title">المهام المساعدة</string>
|
||||||
<string name="model_expensive_title">نموذج عالي التكلفة</string>
|
<string name="model_expensive_title">نموذج مرتفع التكلفة</string>
|
||||||
|
<string name="model_confirm_continue">متابعة</string>
|
||||||
<string name="model_main_label">النموذج الرئيسي</string>
|
<string name="model_main_label">النموذج الرئيسي</string>
|
||||||
<string name="model_moa_label">مزيج الوكلاء (Mixture of Agents)</string>
|
<string name="model_moa_label">مزيج الوكلاء (Mixture of Agents)</string>
|
||||||
<string name="model_new_sessions">ينطبق على الجلسات الجديدة</string>
|
<string name="model_new_sessions">ينطبق على الجلسات الجديدة</string>
|
||||||
|
|||||||
@@ -387,6 +387,7 @@
|
|||||||
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
|
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
|
||||||
<string name="content_desc_active_model">활성 모델</string>
|
<string name="content_desc_active_model">활성 모델</string>
|
||||||
<string name="model_section_pinned">고정된 모델</string>
|
<string name="model_section_pinned">고정된 모델</string>
|
||||||
|
<string name="model_confirm_continue">계속</string>
|
||||||
<string name="content_desc_unpin_model">모델 고정 해제</string>
|
<string name="content_desc_unpin_model">모델 고정 해제</string>
|
||||||
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
|
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1069,6 +1069,7 @@
|
|||||||
<string name="moa_title">多 Agent 混合</string>
|
<string name="moa_title">多 Agent 混合</string>
|
||||||
<string name="model_aux_tasks_label">辅助任务</string>
|
<string name="model_aux_tasks_label">辅助任务</string>
|
||||||
<string name="model_aux_tasks_title">辅助任务</string>
|
<string name="model_aux_tasks_title">辅助任务</string>
|
||||||
|
<string name="model_confirm_continue">继续</string>
|
||||||
<string name="model_expensive_title">高成本模型</string>
|
<string name="model_expensive_title">高成本模型</string>
|
||||||
<string name="model_main_label">主模型</string>
|
<string name="model_main_label">主模型</string>
|
||||||
<string name="model_moa_label">多 Agent 混合</string>
|
<string name="model_moa_label">多 Agent 混合</string>
|
||||||
|
|||||||
@@ -1198,6 +1198,7 @@
|
|||||||
<string name="moa_title">Mixture of Agents</string>
|
<string name="moa_title">Mixture of Agents</string>
|
||||||
<string name="model_aux_tasks_label">Auxiliary tasks</string>
|
<string name="model_aux_tasks_label">Auxiliary tasks</string>
|
||||||
<string name="model_aux_tasks_title">Auxiliary Tasks</string>
|
<string name="model_aux_tasks_title">Auxiliary Tasks</string>
|
||||||
|
<string name="model_confirm_continue">Continue</string>
|
||||||
<string name="model_expensive_title">Expensive Model</string>
|
<string name="model_expensive_title">Expensive Model</string>
|
||||||
<string name="model_main_label">Main model</string>
|
<string name="model_main_label">Main model</string>
|
||||||
<string name="model_moa_label">Mixture of Agents</string>
|
<string name="model_moa_label">Mixture of Agents</string>
|
||||||
|
|||||||
@@ -793,6 +793,109 @@ class ChatViewModelTest {
|
|||||||
assertEquals(sessionId, call.third)
|
assertEquals(sessionId, call.third)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testModelSwitch_confirmRequired_triggersConfirmationDialogAndReSends() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
|
var lastReqId = ""
|
||||||
|
val capturedParams = mutableListOf<Map<String, Any>>()
|
||||||
|
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
|
||||||
|
val params = arg<Map<String, Any>>(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 ──────────────────────────────────────────────
|
// ── Connection / init tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user