feat(bots): emergency stop button and /stop slash command in group chat (#1012)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
bd4a73dd48
commit
97b6641d84
4 files changed
+121
No files matched your search
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.ContentCopy
|
|||||||
import androidx.compose.material.icons.filled.Description
|
import androidx.compose.material.icons.filled.Description
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material.icons.filled.Terminal
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -137,6 +138,18 @@ fun GroupChatScreen(
|
|||||||
},
|
},
|
||||||
navigationIcon = NavIcon.Back(onBack),
|
navigationIcon = NavIcon.Back(onBack),
|
||||||
actions = {
|
actions = {
|
||||||
|
if (state.activeSpeaker != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.stopGeneration() },
|
||||||
|
modifier = Modifier.testTag("group_chat_stop_button"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Stop,
|
||||||
|
contentDescription = stringResource(R.string.group_chat_action_stop),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { showSettingsDialog = true },
|
onClick = { showSettingsDialog = true },
|
||||||
modifier = Modifier.testTag("group_chat_settings_button"),
|
modifier = Modifier.testTag("group_chat_settings_button"),
|
||||||
@@ -293,6 +306,26 @@ fun GroupChatScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
if (state.activeSpeaker != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.stopGeneration() },
|
||||||
|
colors =
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.testTag("group_chat_stop_button"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Stop,
|
||||||
|
contentDescription = stringResource(R.string.group_chat_action_stop),
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = handleSend,
|
onClick = handleSend,
|
||||||
enabled = inputFieldValue.text.isNotBlank(),
|
enabled = inputFieldValue.text.isNotBlank(),
|
||||||
@@ -312,6 +345,7 @@ fun GroupChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showSettingsDialog) {
|
if (showSettingsDialog) {
|
||||||
GroupChatSettingsDialog(
|
GroupChatSettingsDialog(
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ private const val LEASE_STEP_TTL_MS = 45_000L
|
|||||||
private const val TURN_TIMEOUT_MS = 45_000L
|
private const val TURN_TIMEOUT_MS = 45_000L
|
||||||
private const val HISTORY_LIMIT = 10
|
private const val HISTORY_LIMIT = 10
|
||||||
private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue"
|
private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue"
|
||||||
|
private const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
|
||||||
|
|
||||||
class GroupChatViewModel(
|
class GroupChatViewModel(
|
||||||
private var groupName: String = "",
|
private var groupName: String = "",
|
||||||
@@ -767,11 +768,65 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun stopGeneration() {
|
||||||
|
Log.i("GroupChatViewModel", "stopGeneration requested by user")
|
||||||
|
val interruptedEpoch = UUID.randomUUID().toString()
|
||||||
|
activeEpoch = interruptedEpoch
|
||||||
|
|
||||||
|
// Send interrupt signal to any actively streaming or in-flight sessions
|
||||||
|
val activeSessions = activeStreamMsgId.keys.toList() + inFlightTurns.keys.toList()
|
||||||
|
for (sid in activeSessions.distinct()) {
|
||||||
|
try {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.SESSION_INTERRUPT,
|
||||||
|
mapOf("session_id" to sid),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("GroupChatViewModel", "Failed to send interrupt for session $sid: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up transient in-flight tracking
|
||||||
|
inFlightTurns.values.forEach { it.complete("") }
|
||||||
|
inFlightTurns.clear()
|
||||||
|
activeStreamMsgId.clear()
|
||||||
|
sessionToBot.clear()
|
||||||
|
|
||||||
|
_uiState.update { state ->
|
||||||
|
// Remove any pending streaming placeholder messages
|
||||||
|
val cleanedMessages = state.messages.filter { !it.isStreaming }
|
||||||
|
val stopMessage =
|
||||||
|
GroupChatMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
senderName = "system",
|
||||||
|
senderDisplayName = "System",
|
||||||
|
isUser = false,
|
||||||
|
isSystem = true,
|
||||||
|
text = STOPPED_SYSTEM_TEXT,
|
||||||
|
)
|
||||||
|
state.copy(
|
||||||
|
messages = cleanedMessages + stopMessage,
|
||||||
|
activeSpeaker = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
persistSyncSnapshot(_uiState.value.messages, extendLease = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isStopCommand(text: String): Boolean {
|
||||||
|
val normalized = text.trim().lowercase()
|
||||||
|
return normalized in setOf("/stop", "/interrupt", "/pause", "/cancel", "stop", "/brake")
|
||||||
|
}
|
||||||
|
|
||||||
fun sendMessage(rawText: String) {
|
fun sendMessage(rawText: String) {
|
||||||
val text = rawText.trim()
|
val text = rawText.trim()
|
||||||
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
|
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
|
|
||||||
|
if (isStopCommand(text)) {
|
||||||
|
stopGeneration()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val members = _uiState.value.members
|
val members = _uiState.value.members
|
||||||
if (members.isEmpty()) {
|
if (members.isEmpty()) {
|
||||||
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")
|
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")
|
||||||
|
|||||||
@@ -1264,4 +1264,6 @@
|
|||||||
<string name="group_chat_settings_system_prompt_hint">Custom rules, tone, or context for this group (optional)…</string>
|
<string name="group_chat_settings_system_prompt_hint">Custom rules, tone, or context for this group (optional)…</string>
|
||||||
<string name="group_chat_settings_system_prompt_desc">Custom guidelines for all bots in this room</string>
|
<string name="group_chat_settings_system_prompt_desc">Custom guidelines for all bots in this room</string>
|
||||||
<string name="group_chat_action_settings">Settings</string>
|
<string name="group_chat_action_settings">Settings</string>
|
||||||
|
<string name="group_chat_stopped_by_user">Discussion stopped</string>
|
||||||
|
<string name="group_chat_action_stop">Stop generation</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -486,4 +486,34 @@ class GroupChatViewModelTest {
|
|||||||
assertEquals(1, finalMsg.toolCalls.size)
|
assertEquals(1, finalMsg.toolCalls.size)
|
||||||
assertFalse(finalMsg.toolCalls.first().isRunning)
|
assertFalse(finalMsg.toolCalls.first().isRunning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStopCommand_stopsGenerationAndAppendsSystemBanner() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// Trigger a message
|
||||||
|
viewModel.sendMessage("@scout long task")
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// Emit a streaming fragment
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.MessageToken(
|
||||||
|
token = "Thinking about...",
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// Execute /stop command
|
||||||
|
viewModel.sendMessage("/stop")
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertNull(state.activeSpeaker)
|
||||||
|
val lastMsg = state.messages.last()
|
||||||
|
assertTrue(lastMsg.isSystem)
|
||||||
|
assertEquals("Discussion stopped", lastMsg.text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user