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
+135
-14
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.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -137,6 +138,18 @@ fun GroupChatScreen(
|
||||
},
|
||||
navigationIcon = NavIcon.Back(onBack),
|
||||
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(
|
||||
onClick = { showSettingsDialog = true },
|
||||
modifier = Modifier.testTag("group_chat_settings_button"),
|
||||
@@ -293,20 +306,41 @@ fun GroupChatScreen(
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = handleSend,
|
||||
enabled = inputFieldValue.text.isNotBlank(),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.testTag("group_chat_send_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
modifier = Modifier.size(18.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(
|
||||
onClick = handleSend,
|
||||
enabled = inputFieldValue.text.isNotBlank(),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.testTag("group_chat_send_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ private const val LEASE_STEP_TTL_MS = 45_000L
|
||||
private const val TURN_TIMEOUT_MS = 45_000L
|
||||
private const val HISTORY_LIMIT = 10
|
||||
private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue"
|
||||
private const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
|
||||
|
||||
class GroupChatViewModel(
|
||||
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) {
|
||||
val text = rawText.trim()
|
||||
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
|
||||
if (text.isBlank()) return
|
||||
|
||||
if (isStopCommand(text)) {
|
||||
stopGeneration()
|
||||
return
|
||||
}
|
||||
|
||||
val members = _uiState.value.members
|
||||
if (members.isEmpty()) {
|
||||
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")
|
||||
|
||||
Reference in new issue
Block a user