feat(bots): reactive multi-agent group chat orchestration with CAS leasing (#1007)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
7e24f5eba3
commit
0d09f43469
9 files changed
+982
-70
No files matched your search
@@ -157,6 +157,17 @@ data class GroupChatSyncSnapshot(
|
|||||||
room.revision?.let { roomMap["revision"] = it }
|
room.revision?.let { roomMap["revision"] = it }
|
||||||
room.syncRevision?.let { roomMap["syncRevision"] = it }
|
room.syncRevision?.let { roomMap["syncRevision"] = it }
|
||||||
room.tombstone?.let { roomMap["tombstone"] = it }
|
room.tombstone?.let { roomMap["tombstone"] = it }
|
||||||
|
room.maxBotMessages?.let { roomMap["maxBotMessages"] = it }
|
||||||
|
room.maxContinuationPasses?.let { roomMap["maxContinuationPasses"] = it }
|
||||||
|
room.systemPrompt?.let { roomMap["systemPrompt"] = it }
|
||||||
|
room.lease?.let { lease ->
|
||||||
|
roomMap["lease"] =
|
||||||
|
buildMap<String, Any?> {
|
||||||
|
lease.driverId?.let { put("driverId", it) }
|
||||||
|
lease.epoch?.let { put("epoch", it) }
|
||||||
|
lease.expiresAt?.let { put("expiresAt", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
room.members?.let { members ->
|
room.members?.let { members ->
|
||||||
roomMap["members"] =
|
roomMap["members"] =
|
||||||
members.mapNotNull {
|
members.mapNotNull {
|
||||||
@@ -200,6 +211,13 @@ data class GroupChatSyncSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GroupChatRoomLease(
|
||||||
|
val driverId: String? = null,
|
||||||
|
val epoch: String? = null,
|
||||||
|
val expiresAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GroupChatRoomMeta(
|
data class GroupChatRoomMeta(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
@@ -214,6 +232,10 @@ data class GroupChatRoomMeta(
|
|||||||
val updatedAt: Long? = null,
|
val updatedAt: Long? = null,
|
||||||
val createdAt: Long? = null,
|
val createdAt: Long? = null,
|
||||||
val tombstone: Boolean? = null,
|
val tombstone: Boolean? = null,
|
||||||
|
val lease: GroupChatRoomLease? = null,
|
||||||
|
val maxBotMessages: Int? = null,
|
||||||
|
val maxContinuationPasses: Int? = null,
|
||||||
|
val systemPrompt: String? = null,
|
||||||
) {
|
) {
|
||||||
val memberNames: List<String>
|
val memberNames: List<String>
|
||||||
get() =
|
get() =
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.model
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionMessagesResponse(
|
data class SessionMessagesResponse(
|
||||||
@@ -52,6 +54,20 @@ data class SessionMessage(
|
|||||||
val timestampText: String?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
get() = (timestamp as? JsonPrimitive)?.content
|
||||||
|
|
||||||
|
val timestampEpochMs: Long?
|
||||||
|
get() {
|
||||||
|
val prim = timestamp as? JsonPrimitive ?: return null
|
||||||
|
val asLong = prim.longOrNull
|
||||||
|
if (asLong != null) {
|
||||||
|
return if (asLong < 10_000_000_000L) asLong * 1000L else asLong
|
||||||
|
}
|
||||||
|
val asDouble = prim.doubleOrNull
|
||||||
|
if (asDouble != null) {
|
||||||
|
return (asDouble * 1000L).toLong()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
val contentText: String
|
val contentText: String
|
||||||
get() =
|
get() =
|
||||||
when (content) {
|
when (content) {
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ data class GroupChatMessage(
|
|||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
val thread: String? = null,
|
val thread: String? = null,
|
||||||
|
val isSystem: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure helper for mention extraction and responder resolution matching Desktop parity.
|
* Pure helper for mention extraction and responder resolution matching Desktop parity.
|
||||||
*/
|
*/
|
||||||
object GroupChatMentions {
|
object GroupChatMentions {
|
||||||
|
private val MENTION_REGEX = Regex("""@([a-zA-Z0-9._-]+)""")
|
||||||
|
|
||||||
data class MentionResult(
|
data class MentionResult(
|
||||||
val isEveryone: Boolean,
|
val isEveryone: Boolean,
|
||||||
val mentionedBots: Set<String>,
|
val mentionedBots: Set<String>,
|
||||||
@@ -33,16 +36,42 @@ object GroupChatMentions {
|
|||||||
fun parseMentions(
|
fun parseMentions(
|
||||||
text: String,
|
text: String,
|
||||||
members: List<ProfileInfo>,
|
members: List<ProfileInfo>,
|
||||||
|
excludedSpeaker: String? = null,
|
||||||
): MentionResult {
|
): MentionResult {
|
||||||
val lowerText = text.lowercase()
|
var isEveryone = false
|
||||||
val isEveryone = lowerText.contains("@all") || lowerText.contains("@everyone")
|
|
||||||
|
|
||||||
val mentioned = mutableSetOf<String>()
|
val mentioned = mutableSetOf<String>()
|
||||||
|
|
||||||
|
val handles = mutableMapOf<String, String>()
|
||||||
for (member in members) {
|
for (member in members) {
|
||||||
val handle = member.name.lowercase()
|
val name = member.name.lowercase()
|
||||||
val titleHandle = member.effectiveTitle.lowercase().replace(" ", "")
|
handles[name] = member.name
|
||||||
if (lowerText.contains("@$handle") || lowerText.contains("@$titleHandle")) {
|
handles[name.replace(Regex("""[\s_-]+"""), "")] = member.name
|
||||||
mentioned.add(member.name)
|
|
||||||
|
val title = member.effectiveTitle.lowercase().trim()
|
||||||
|
if (title.isNotEmpty()) {
|
||||||
|
handles[title.replace(" ", "")] = member.name
|
||||||
|
val firstWord = title.split(Regex("""\s+""")).firstOrNull().orEmpty()
|
||||||
|
if (firstWord.isNotEmpty()) {
|
||||||
|
handles[firstWord] = member.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (match in MENTION_REGEX.findAll(text)) {
|
||||||
|
val rawHandle = match.groupValues[1].lowercase()
|
||||||
|
if (rawHandle == "all" || rawHandle == "everyone") {
|
||||||
|
isEveryone = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (rawHandle == "user" || rawHandle == "you") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolved = handles[rawHandle] ?: handles[rawHandle.replace(Regex("""[._-]+"""), "")]
|
||||||
|
if (resolved != null) {
|
||||||
|
if (excludedSpeaker == null || !resolved.equals(excludedSpeaker, ignoreCase = true)) {
|
||||||
|
mentioned.add(resolved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +87,15 @@ object GroupChatMentions {
|
|||||||
fun resolveResponders(
|
fun resolveResponders(
|
||||||
text: String,
|
text: String,
|
||||||
members: List<ProfileInfo>,
|
members: List<ProfileInfo>,
|
||||||
|
excludedSpeaker: String? = null,
|
||||||
): List<ProfileInfo> {
|
): List<ProfileInfo> {
|
||||||
val parsed = parseMentions(text, members)
|
val parsed = parseMentions(text, members, excludedSpeaker)
|
||||||
return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) {
|
return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) {
|
||||||
|
if (excludedSpeaker != null) {
|
||||||
|
members.filter { !it.name.equals(excludedSpeaker, ignoreCase = true) }
|
||||||
|
} else {
|
||||||
members
|
members
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
members.filter { parsed.mentionedBots.contains(it.name) }
|
members.filter { parsed.mentionedBots.contains(it.name) }
|
||||||
}
|
}
|
||||||
@@ -75,11 +109,25 @@ object GroupChatMentions {
|
|||||||
viewer: ProfileInfo,
|
viewer: ProfileInfo,
|
||||||
peers: List<ProfileInfo>,
|
peers: List<ProfileInfo>,
|
||||||
recentLog: List<GroupChatMessage>,
|
recentLog: List<GroupChatMessage>,
|
||||||
|
historyLimit: Int = 10,
|
||||||
|
customRoomPrompt: String? = null,
|
||||||
): String {
|
): String {
|
||||||
val viewerHandle = viewer.name
|
val viewerLabel =
|
||||||
val peerNames = peers.joinToString(", ") { "@${it.name}" }
|
if (viewer.effectiveTitle.isNotBlank() && !viewer.effectiveTitle.equals(viewer.name, ignoreCase = true)) {
|
||||||
|
"@${viewer.name} (${viewer.effectiveTitle})"
|
||||||
|
} else {
|
||||||
|
"@${viewer.name}"
|
||||||
|
}
|
||||||
|
val peerNames =
|
||||||
|
peers.joinToString(", ") {
|
||||||
|
if (it.effectiveTitle.isNotBlank() && !it.effectiveTitle.equals(it.name, ignoreCase = true)) {
|
||||||
|
"@${it.name} (${it.effectiveTitle})"
|
||||||
|
} else {
|
||||||
|
"@${it.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
val lines =
|
val lines =
|
||||||
recentLog.takeLast(10).joinToString("\n") { msg ->
|
recentLog.filter { !it.isSystem }.takeLast(historyLimit).joinToString("\n") { msg ->
|
||||||
val speaker = if (msg.isUser) "User" else "@${msg.senderName}"
|
val speaker = if (msg.isUser) "User" else "@${msg.senderName}"
|
||||||
" $speaker: ${msg.text}"
|
" $speaker: ${msg.text}"
|
||||||
}
|
}
|
||||||
@@ -90,20 +138,33 @@ object GroupChatMentions {
|
|||||||
|
|
||||||
val actionGuidance =
|
val actionGuidance =
|
||||||
if (isDirectOrAll) {
|
if (isDirectOrAll) {
|
||||||
"- You were explicitly addressed (via @$viewerHandle or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status."
|
"- You were explicitly addressed (via @${viewer.name} or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status."
|
||||||
} else {
|
} else {
|
||||||
"- If you have nothing new to add, reply with exactly \"(pass)\". Passing is good and lets the conversation settle."
|
"- If you have nothing new to add, reply with exactly \"(pass)\". Passing is good and lets the conversation settle."
|
||||||
}
|
}
|
||||||
|
|
||||||
return """
|
val customInstructions =
|
||||||
[Group chat: "$groupName"] You are @$viewerHandle, one participant in a group chat with $peerNames and the user.
|
if (!customRoomPrompt.isNullOrBlank()) {
|
||||||
|
"\nRoom Instructions:\n${customRoomPrompt.trim()}\n"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultSentenceRule =
|
||||||
|
if (customRoomPrompt.isNullOrBlank()) {
|
||||||
|
"- Reply with ONE conversational message (1-3 sentences).\n"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
[Group chat: "$groupName"] You are $viewerLabel, one participant in a group chat with $peerNames and the user.
|
||||||
|
$customInstructions
|
||||||
Recent messages in the room:
|
Recent messages in the room:
|
||||||
$lines
|
$lines
|
||||||
|
|
||||||
Rules for this room:
|
Rules for this room:
|
||||||
- Reply with ONE conversational message (1-3 sentences).
|
$defaultSentenceRule$actionGuidance
|
||||||
$actionGuidance
|
|
||||||
- Mention a teammate as @name if you want to pull them in.
|
- Mention a teammate as @name if you want to pull them in.
|
||||||
- Your reply goes to the room verbatim — no preamble.
|
- Your reply goes to the room verbatim — no preamble.
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|||||||
@@ -24,13 +24,19 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -60,6 +66,7 @@ import com.m57.hermescontrol.ui.common.BotAvatar
|
|||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupChatScreen(
|
fun GroupChatScreen(
|
||||||
@@ -74,6 +81,7 @@ fun GroupChatScreen(
|
|||||||
mutableStateOf(TextFieldValue(""))
|
mutableStateOf(TextFieldValue(""))
|
||||||
}
|
}
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
var showSettingsDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val handleSend = {
|
val handleSend = {
|
||||||
val text = inputFieldValue.text
|
val text = inputFieldValue.text
|
||||||
@@ -111,6 +119,17 @@ fun GroupChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = NavIcon.Back(onBack),
|
navigationIcon = NavIcon.Back(onBack),
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showSettingsDialog = true },
|
||||||
|
modifier = Modifier.testTag("group_chat_settings_button"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Settings,
|
||||||
|
contentDescription = stringResource(R.string.group_chat_action_settings),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -276,6 +295,185 @@ fun GroupChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSettingsDialog) {
|
||||||
|
GroupChatSettingsDialog(
|
||||||
|
currentMaxMessages = state.maxBotMessages,
|
||||||
|
currentMaxPasses = state.maxContinuationPasses,
|
||||||
|
currentSystemPrompt = state.systemPrompt,
|
||||||
|
onDismiss = { showSettingsDialog = false },
|
||||||
|
onSave = { maxMsgs, maxPasses, prompt ->
|
||||||
|
viewModel.updateGroupLimits(maxMsgs, maxPasses, prompt)
|
||||||
|
showSettingsDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupChatSettingsDialog(
|
||||||
|
currentMaxMessages: Int,
|
||||||
|
currentMaxPasses: Int,
|
||||||
|
currentSystemPrompt: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (Int, Int, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
|
||||||
|
var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) }
|
||||||
|
var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.group_chat_settings_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Max Bot Messages Slider
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.group_chat_settings_max_messages),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${maxMessages.roundToInt()}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.group_chat_settings_max_messages_desc,
|
||||||
|
maxMessages.roundToInt(),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = maxMessages,
|
||||||
|
onValueChange = { maxMessages = it },
|
||||||
|
valueRange = 1f..20f,
|
||||||
|
steps = 18,
|
||||||
|
modifier = Modifier.testTag("max_messages_slider"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max Continuation Handoffs Slider
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.group_chat_settings_max_handoffs),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${maxPasses.roundToInt()}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.group_chat_settings_max_handoffs_desc,
|
||||||
|
maxPasses.roundToInt(),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = maxPasses,
|
||||||
|
onValueChange = { maxPasses = it },
|
||||||
|
valueRange = 0f..6f,
|
||||||
|
steps = 5,
|
||||||
|
modifier = Modifier.testTag("max_handoffs_slider"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room Instructions / System Prompt
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.group_chat_settings_system_prompt),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.group_chat_settings_system_prompt_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = systemPrompt,
|
||||||
|
onValueChange = { systemPrompt = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 80.dp, max = 160.dp)
|
||||||
|
.testTag("room_system_prompt_input"),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 4,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSave(
|
||||||
|
maxMessages.roundToInt(),
|
||||||
|
maxPasses.roundToInt(),
|
||||||
|
systemPrompt.trim().ifBlank { null },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag("save_settings_button"),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.group_chat_settings_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
|
||||||
|
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
|
||||||
|
systemPrompt = ""
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.group_chat_settings_reset_default))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.common_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -283,7 +481,25 @@ private fun GroupMessageCard(
|
|||||||
message: GroupChatMessage,
|
message: GroupChatMessage,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (message.isUser) {
|
if (message.isSystem) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message.text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.isUser) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots.group
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.model.GroupChatRoomLease
|
||||||
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
|
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
|
||||||
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
|
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
|
||||||
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
|
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
|
||||||
@@ -39,6 +40,9 @@ data class GroupChatUiState(
|
|||||||
val activeSpeaker: String? = null,
|
val activeSpeaker: String? = null,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
|
val maxBotMessages: Int = DEFAULT_MAX_BOT_MESSAGES,
|
||||||
|
val maxContinuationPasses: Int = DEFAULT_MAX_CONTINUATION_PASSES,
|
||||||
|
val systemPrompt: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MemberSession(
|
data class MemberSession(
|
||||||
@@ -46,6 +50,13 @@ data class MemberSession(
|
|||||||
val storedSessionId: String? = null,
|
val storedSessionId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const val DEFAULT_MAX_BOT_MESSAGES = 6
|
||||||
|
const val DEFAULT_MAX_CONTINUATION_PASSES = 2
|
||||||
|
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"
|
||||||
|
|
||||||
class GroupChatViewModel(
|
class GroupChatViewModel(
|
||||||
private var groupName: String = "",
|
private var groupName: String = "",
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
@@ -54,6 +65,12 @@ class GroupChatViewModel(
|
|||||||
private val _uiState = MutableStateFlow(GroupChatUiState(groupName = groupName))
|
private val _uiState = MutableStateFlow(GroupChatUiState(groupName = groupName))
|
||||||
val uiState: StateFlow<GroupChatUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<GroupChatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val localDriverId = "android_" + UUID.randomUUID().toString().take(8)
|
||||||
|
private var activeEpoch: String = ""
|
||||||
|
|
||||||
|
// Map of bot.name -> seen count of non-system messages
|
||||||
|
private val memberWatermarks = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
// Map of bot.name -> active session info in this group
|
// Map of bot.name -> active session info in this group
|
||||||
private val memberSessions = mutableMapOf<String, MemberSession>()
|
private val memberSessions = mutableMapOf<String, MemberSession>()
|
||||||
|
|
||||||
@@ -80,6 +97,8 @@ class GroupChatViewModel(
|
|||||||
fun setGroup(name: String) {
|
fun setGroup(name: String) {
|
||||||
if (name == groupName && _uiState.value.groupName.isNotBlank()) return
|
if (name == groupName && _uiState.value.groupName.isNotBlank()) return
|
||||||
groupName = name
|
groupName = name
|
||||||
|
activeEpoch = UUID.randomUUID().toString()
|
||||||
|
memberWatermarks.clear()
|
||||||
memberSessions.clear()
|
memberSessions.clear()
|
||||||
inFlightTurns.clear()
|
inFlightTurns.clear()
|
||||||
activeStreamMsgId.clear()
|
activeStreamMsgId.clear()
|
||||||
@@ -252,10 +271,22 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val nonSystemCount = initialMessages.filter { !it.isSystem }.size
|
||||||
|
for (member in groupMembers) {
|
||||||
|
memberWatermarks[member.name] = nonSystemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
val maxMessages = matchingRoom?.maxBotMessages ?: DEFAULT_MAX_BOT_MESSAGES
|
||||||
|
val maxPasses = matchingRoom?.maxContinuationPasses ?: DEFAULT_MAX_CONTINUATION_PASSES
|
||||||
|
val roomSystemPrompt = matchingRoom?.systemPrompt
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
members = groupMembers,
|
members = groupMembers,
|
||||||
messages = initialMessages,
|
messages = initialMessages,
|
||||||
|
maxBotMessages = maxMessages,
|
||||||
|
maxContinuationPasses = maxPasses,
|
||||||
|
systemPrompt = roomSystemPrompt,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -270,11 +301,94 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureMemberSession(bot: ProfileInfo): MemberSession? {
|
private fun findRoomEntry(rooms: Map<String, GroupChatRoomMeta>?): Pair<String, GroupChatRoomMeta?> {
|
||||||
|
if (rooms == null) return ("name:$groupName" to null)
|
||||||
|
val exact =
|
||||||
|
rooms.entries.find {
|
||||||
|
it.key == groupName || it.key == "name:$groupName" || it.key == "id:$groupName"
|
||||||
|
}
|
||||||
|
if (exact != null) return (exact.key to exact.value)
|
||||||
|
val byName = rooms.entries.find { it.value.name.equals(groupName, ignoreCase = true) }
|
||||||
|
if (byName != null) return (byName.key to byName.value)
|
||||||
|
return ("name:$groupName" to null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGroupLimits(
|
||||||
|
maxMessages: Int,
|
||||||
|
maxPasses: Int,
|
||||||
|
systemPrompt: String? = null,
|
||||||
|
) {
|
||||||
|
val clampedMessages = maxMessages.coerceIn(1, 20)
|
||||||
|
val clampedPasses = maxPasses.coerceIn(0, 10)
|
||||||
|
val cleanPrompt = systemPrompt?.trim()?.ifBlank { null }
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
maxBotMessages = clampedMessages,
|
||||||
|
maxContinuationPasses = clampedPasses,
|
||||||
|
systemPrompt = cleanPrompt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val allProfiles = fetchProfiles()
|
||||||
|
val defaultProfile =
|
||||||
|
allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch
|
||||||
|
val existingSnapshot =
|
||||||
|
defaultProfile.groupChatSyncSnapshot()
|
||||||
|
?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val (targetKey, existingRoom) = findRoomEntry(existingSnapshot.rooms)
|
||||||
|
|
||||||
|
val updatedRoom =
|
||||||
|
(existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy(
|
||||||
|
name = groupName,
|
||||||
|
members = _uiState.value.members.map { JsonPrimitive(it.name) },
|
||||||
|
maxBotMessages = clampedMessages,
|
||||||
|
maxContinuationPasses = clampedPasses,
|
||||||
|
systemPrompt = cleanPrompt,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
|
||||||
|
val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap()
|
||||||
|
updatedRooms[targetKey] = updatedRoom
|
||||||
|
|
||||||
|
val newSnapshot =
|
||||||
|
existingSnapshot.copy(
|
||||||
|
version = 3,
|
||||||
|
updatedAt = now,
|
||||||
|
rooms = updatedRooms,
|
||||||
|
)
|
||||||
|
|
||||||
|
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
||||||
|
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
|
mapOf(
|
||||||
|
"name" to defaultProfile.name,
|
||||||
|
"ui_meta" to uiMetaPayload,
|
||||||
|
),
|
||||||
|
).await()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureMemberSession(
|
||||||
|
bot: ProfileInfo,
|
||||||
|
forceRefresh: Boolean = false,
|
||||||
|
): MemberSession? {
|
||||||
|
if (!forceRefresh) {
|
||||||
val cached = memberSessions[bot.name]
|
val cached = memberSessions[bot.name]
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
memberSessions.remove(bot.name)
|
||||||
|
}
|
||||||
|
|
||||||
val title = "Group: $groupName"
|
val title = "Group: $groupName"
|
||||||
try {
|
try {
|
||||||
@@ -298,56 +412,106 @@ class GroupChatViewModel(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(rawText: String) {
|
private suspend fun acquireOrVerifyLease(
|
||||||
val text = rawText.trim()
|
defaultProfile: ProfileInfo,
|
||||||
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
|
epoch: String,
|
||||||
if (text.isBlank()) return
|
): Boolean {
|
||||||
|
try {
|
||||||
|
val syncSnapshot = defaultProfile.groupChatSyncSnapshot()
|
||||||
|
val (targetKey, existingRoom) = findRoomEntry(syncSnapshot?.rooms)
|
||||||
|
val currentLease = existingRoom?.lease
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
val members = _uiState.value.members
|
if (currentLease?.driverId != null &&
|
||||||
if (members.isEmpty()) {
|
currentLease.driverId != localDriverId &&
|
||||||
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")
|
(currentLease.expiresAt ?: 0L) > now
|
||||||
return
|
) {
|
||||||
|
val remainingSec = ((currentLease.expiresAt ?: 0L) - now) / 1000
|
||||||
|
Log.i(
|
||||||
|
"GroupChatViewModel",
|
||||||
|
"Active lease held by ${currentLease.driverId} (expires in ${remainingSec}s). Passive mode.",
|
||||||
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val userMessage =
|
val myLease =
|
||||||
GroupChatMessage(
|
GroupChatRoomLease(
|
||||||
id = UUID.randomUUID().toString(),
|
driverId = localDriverId,
|
||||||
senderName = "user",
|
epoch = epoch,
|
||||||
senderDisplayName = "You",
|
expiresAt = now + LEASE_STEP_TTL_MS,
|
||||||
isUser = true,
|
|
||||||
text = text,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_uiState.update {
|
val existingSnapshot = syncSnapshot ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
|
||||||
it.copy(messages = it.messages + userMessage)
|
val updatedRoom =
|
||||||
|
(existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy(
|
||||||
|
name = groupName,
|
||||||
|
members = _uiState.value.members.map { JsonPrimitive(it.name) },
|
||||||
|
lease = myLease,
|
||||||
|
maxBotMessages = _uiState.value.maxBotMessages,
|
||||||
|
maxContinuationPasses = _uiState.value.maxContinuationPasses,
|
||||||
|
systemPrompt = _uiState.value.systemPrompt,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
|
||||||
|
val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap()
|
||||||
|
updatedRooms[targetKey] = updatedRoom
|
||||||
|
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
|
||||||
|
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
||||||
|
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
|
mapOf(
|
||||||
|
"name" to defaultProfile.name,
|
||||||
|
"ui_meta" to uiMetaPayload,
|
||||||
|
),
|
||||||
|
).await()
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("GroupChatViewModel", "acquireOrVerifyLease failed: ${e.message}")
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
persistSyncSnapshot(_uiState.value.messages)
|
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
private suspend fun executeBotTurn(
|
||||||
val responders = GroupChatMentions.resolveResponders(text, members)
|
bot: ProfileInfo,
|
||||||
Log.d("GroupChatViewModel", "Responders: ${responders.map { it.name }}")
|
epoch: String,
|
||||||
|
members: List<ProfileInfo>,
|
||||||
|
): String? {
|
||||||
|
if (activeEpoch != epoch) return null
|
||||||
|
|
||||||
|
val currentNonSystemMessages = _uiState.value.messages.filter { !it.isSystem }
|
||||||
|
val seen = memberWatermarks[bot.name] ?: 0
|
||||||
|
val delta = currentNonSystemMessages.drop(seen)
|
||||||
|
|
||||||
|
if (delta.isEmpty() && currentNonSystemMessages.isNotEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
for (bot in responders) {
|
|
||||||
_uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) }
|
_uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) }
|
||||||
|
|
||||||
val prompt =
|
val prompt =
|
||||||
GroupChatMentions.buildTurnPrompt(
|
GroupChatMentions.buildTurnPrompt(
|
||||||
groupName = groupName,
|
groupName = groupName,
|
||||||
viewer = bot,
|
viewer = bot,
|
||||||
peers = members.filter { it.name != bot.name },
|
peers = members.filter { it.name != bot.name },
|
||||||
recentLog = _uiState.value.messages,
|
recentLog = currentNonSystemMessages,
|
||||||
|
historyLimit = HISTORY_LIMIT,
|
||||||
|
customRoomPrompt = _uiState.value.systemPrompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val turnStartTime = System.currentTimeMillis()
|
||||||
val streamMsgId = UUID.randomUUID().toString()
|
val streamMsgId = UUID.randomUUID().toString()
|
||||||
var activeRuntimeId: String? = null
|
var activeRuntimeId: String? = null
|
||||||
var activeStoredId: String? = null
|
var activeStoredId: String? = null
|
||||||
try {
|
try {
|
||||||
val sessionInfo = ensureMemberSession(bot)
|
var sessionInfo = ensureMemberSession(bot)
|
||||||
if (sessionInfo == null) {
|
if (sessionInfo == null) {
|
||||||
Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}")
|
Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}")
|
||||||
continue
|
return null
|
||||||
}
|
}
|
||||||
val runtimeId = sessionInfo.runtimeSessionId
|
var runtimeId = sessionInfo.runtimeSessionId
|
||||||
val storedId = sessionInfo.storedSessionId
|
var storedId = sessionInfo.storedSessionId
|
||||||
activeRuntimeId = runtimeId
|
activeRuntimeId = runtimeId
|
||||||
activeStoredId = storedId
|
activeStoredId = storedId
|
||||||
|
|
||||||
@@ -357,29 +521,63 @@ class GroupChatViewModel(
|
|||||||
sessionToBot[runtimeId] = bot
|
sessionToBot[runtimeId] = bot
|
||||||
storedId?.let { sessionToBot[it] = bot }
|
storedId?.let { sessionToBot[it] = bot }
|
||||||
|
|
||||||
// Set ActiveSessionHolder so the active WebSocket context matches this turn
|
|
||||||
ActiveSessionHolder.set(runtimeId, runtimeId)
|
ActiveSessionHolder.set(runtimeId, runtimeId)
|
||||||
|
|
||||||
// Register in-flight listener
|
|
||||||
val turnDeferred = CompletableDeferred<String>()
|
val turnDeferred = CompletableDeferred<String>()
|
||||||
inFlightTurns[runtimeId] = turnDeferred
|
inFlightTurns[runtimeId] = turnDeferred
|
||||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||||
|
|
||||||
// Submit prompt via sendMessage
|
try {
|
||||||
HermesWsClient.sendMessage(
|
HermesWsClient.request(
|
||||||
sessionId = runtimeId,
|
WsMethods.PROMPT_SUBMIT,
|
||||||
text = prompt,
|
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||||
|
).await()
|
||||||
|
} catch (submitErr: Exception) {
|
||||||
|
Log.w(
|
||||||
|
"GroupChatViewModel",
|
||||||
|
"prompt.submit failed for ${bot.name} on $runtimeId: ${submitErr.message}, recreating session",
|
||||||
)
|
)
|
||||||
|
activeStreamMsgId.remove(runtimeId)
|
||||||
|
storedId?.let { activeStreamMsgId.remove(it) }
|
||||||
|
sessionToBot.remove(runtimeId)
|
||||||
|
storedId?.let { sessionToBot.remove(it) }
|
||||||
|
inFlightTurns.remove(runtimeId)
|
||||||
|
storedId?.let { inFlightTurns.remove(it) }
|
||||||
|
|
||||||
|
sessionInfo = ensureMemberSession(bot, forceRefresh = true)
|
||||||
|
if (sessionInfo == null) return null
|
||||||
|
|
||||||
|
runtimeId = sessionInfo.runtimeSessionId
|
||||||
|
storedId = sessionInfo.storedSessionId
|
||||||
|
activeRuntimeId = runtimeId
|
||||||
|
activeStoredId = storedId
|
||||||
|
|
||||||
|
activeStreamMsgId[runtimeId] = streamMsgId
|
||||||
|
storedId?.let { activeStreamMsgId[it] = streamMsgId }
|
||||||
|
sessionToBot[runtimeId] = bot
|
||||||
|
storedId?.let { sessionToBot[it] = bot }
|
||||||
|
ActiveSessionHolder.set(runtimeId, runtimeId)
|
||||||
|
inFlightTurns[runtimeId] = turnDeferred
|
||||||
|
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||||
|
|
||||||
|
try {
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||||
|
).await()
|
||||||
|
} catch (retryErr: Exception) {
|
||||||
|
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Await event or polling fallback
|
|
||||||
var replyText =
|
var replyText =
|
||||||
withTimeoutOrNull(45_000L) {
|
withTimeoutOrNull(TURN_TIMEOUT_MS) {
|
||||||
turnDeferred.await()
|
turnDeferred.await()
|
||||||
}
|
}
|
||||||
inFlightTurns.remove(runtimeId)
|
inFlightTurns.remove(runtimeId)
|
||||||
storedId?.let { inFlightTurns.remove(it) }
|
storedId?.let { inFlightTurns.remove(it) }
|
||||||
|
|
||||||
// Polling fallback if event dropped or session was reaped
|
|
||||||
if (replyText == null) {
|
if (replyText == null) {
|
||||||
val targetIds = listOfNotNull(storedId, runtimeId).distinct()
|
val targetIds = listOfNotNull(storedId, runtimeId).distinct()
|
||||||
for (tid in targetIds) {
|
for (tid in targetIds) {
|
||||||
@@ -394,7 +592,11 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
if (res is NetworkResult.Success) {
|
if (res is NetworkResult.Success) {
|
||||||
val lastAssistant =
|
val lastAssistant =
|
||||||
res.data.messages.reversed().firstOrNull { it.role == "assistant" }
|
res.data.messages.reversed().firstOrNull { msg ->
|
||||||
|
val ts = msg.timestampEpochMs
|
||||||
|
msg.role == "assistant" &&
|
||||||
|
(ts == null || ts >= (turnStartTime - 5000L))
|
||||||
|
}
|
||||||
val candidate = lastAssistant?.contentText?.trim()
|
val candidate = lastAssistant?.contentText?.trim()
|
||||||
if (!candidate.isNullOrBlank()) {
|
if (!candidate.isNullOrBlank()) {
|
||||||
replyText = candidate
|
replyText = candidate
|
||||||
@@ -407,9 +609,14 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) {
|
if (activeEpoch != epoch) {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val existing = state.messages.find { it.id == streamMsgId }
|
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) {
|
||||||
val finalMsg =
|
val finalMsg =
|
||||||
GroupChatMessage(
|
GroupChatMessage(
|
||||||
id = streamMsgId,
|
id = streamMsgId,
|
||||||
@@ -420,6 +627,8 @@ class GroupChatViewModel(
|
|||||||
text = replyText.trim(),
|
text = replyText.trim(),
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
)
|
)
|
||||||
|
_uiState.update { state ->
|
||||||
|
val existing = state.messages.find { it.id == streamMsgId }
|
||||||
val updatedList =
|
val updatedList =
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
state.messages.map { if (it.id == streamMsgId) finalMsg else it }
|
state.messages.map { if (it.id == streamMsgId) finalMsg else it }
|
||||||
@@ -428,17 +637,19 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
state.copy(messages = updatedList)
|
state.copy(messages = updatedList)
|
||||||
}
|
}
|
||||||
persistSyncSnapshot(_uiState.value.messages)
|
return replyText.trim()
|
||||||
} else {
|
} else {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("GroupChatViewModel", "Turn failed for ${bot.name}: ${e.message}")
|
Log.w("GroupChatViewModel", "Turn failed for ${bot.name}: ${e.message}")
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
activeRuntimeId?.let {
|
activeRuntimeId?.let {
|
||||||
activeStreamMsgId.remove(it)
|
activeStreamMsgId.remove(it)
|
||||||
@@ -452,11 +663,179 @@ class GroupChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendMessage(rawText: String) {
|
||||||
|
val text = rawText.trim()
|
||||||
|
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
|
||||||
|
if (text.isBlank()) return
|
||||||
|
|
||||||
|
val members = _uiState.value.members
|
||||||
|
if (members.isEmpty()) {
|
||||||
|
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val epoch = UUID.randomUUID().toString()
|
||||||
|
activeEpoch = epoch
|
||||||
|
|
||||||
|
val userMessage =
|
||||||
|
GroupChatMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
senderName = "user",
|
||||||
|
senderDisplayName = "You",
|
||||||
|
isUser = true,
|
||||||
|
text = text,
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(messages = it.messages + userMessage)
|
||||||
|
}
|
||||||
|
persistSyncSnapshot(_uiState.value.messages, extendLease = true)
|
||||||
|
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
val allProfiles = fetchProfiles()
|
||||||
|
val defaultProfile =
|
||||||
|
allProfiles.find { it.is_default == true || it.name == "default" }
|
||||||
|
?: allProfiles.firstOrNull()
|
||||||
|
|
||||||
|
if (defaultProfile != null) {
|
||||||
|
val hasLease = acquireOrVerifyLease(defaultProfile, epoch)
|
||||||
|
if (!hasLease) {
|
||||||
|
Log.i("GroupChatViewModel", "Passive mode active — skipping local turn drive")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var postedBotMessages = 0
|
||||||
|
var continuationsCount = 0
|
||||||
|
|
||||||
|
val maxBotLimit = _uiState.value.maxBotMessages
|
||||||
|
val maxPassLimit = _uiState.value.maxContinuationPasses
|
||||||
|
|
||||||
|
val initialResponders = GroupChatMentions.resolveResponders(text, members).toMutableList()
|
||||||
|
val remainingInitial = initialResponders.toMutableList()
|
||||||
|
|
||||||
|
// ── Round 1: Initial Turn ──
|
||||||
|
while (remainingInitial.isNotEmpty()) {
|
||||||
|
if (activeEpoch != epoch) return@launch
|
||||||
|
if (postedBotMessages >= maxBotLimit) break
|
||||||
|
|
||||||
|
val bot = remainingInitial.removeAt(0)
|
||||||
|
val reply = executeBotTurn(bot, epoch, members)
|
||||||
|
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size
|
||||||
|
memberWatermarks[bot.name] = nonSystemSize
|
||||||
|
|
||||||
|
if (reply != null) {
|
||||||
|
postedBotMessages++
|
||||||
|
persistSyncSnapshot(_uiState.value.messages, extendLease = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reactive Continuation Passes ──
|
||||||
|
while (continuationsCount < maxPassLimit && postedBotMessages < maxBotLimit) {
|
||||||
|
if (activeEpoch != epoch) return@launch
|
||||||
|
|
||||||
|
val currentLog = _uiState.value.messages.filter { !it.isSystem }
|
||||||
|
val userIndex = currentLog.indexOfLast { it.isUser }
|
||||||
|
val recentAssistantMsgs =
|
||||||
|
if (userIndex >= 0) currentLog.drop(userIndex + 1) else currentLog
|
||||||
|
|
||||||
|
val pendingCitedBots = mutableListOf<ProfileInfo>()
|
||||||
|
for (msg in recentAssistantMsgs) {
|
||||||
|
val parsed =
|
||||||
|
GroupChatMentions.parseMentions(
|
||||||
|
text = msg.text,
|
||||||
|
members = members,
|
||||||
|
excludedSpeaker = msg.senderName,
|
||||||
|
)
|
||||||
|
for (citedName in parsed.mentionedBots) {
|
||||||
|
val bot = members.find { it.name.equals(citedName, ignoreCase = true) }
|
||||||
|
if (bot != null && !pendingCitedBots.any { it.name == bot.name }) {
|
||||||
|
val seenCount = memberWatermarks[bot.name] ?: 0
|
||||||
|
if (seenCount < currentLog.size) {
|
||||||
|
pendingCitedBots.add(bot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingCitedBots.isEmpty()) {
|
||||||
|
// Quiescence / Natural Settle reached
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationsCount++
|
||||||
|
val continuationResponders = pendingCitedBots.toMutableList()
|
||||||
|
|
||||||
|
while (continuationResponders.isNotEmpty()) {
|
||||||
|
if (activeEpoch != epoch) return@launch
|
||||||
|
if (postedBotMessages >= maxBotLimit) break
|
||||||
|
|
||||||
|
val bot = continuationResponders.removeAt(0)
|
||||||
|
val reply = executeBotTurn(bot, epoch, members)
|
||||||
|
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size
|
||||||
|
memberWatermarks[bot.name] = nonSystemSize
|
||||||
|
|
||||||
|
if (reply != null) {
|
||||||
|
postedBotMessages++
|
||||||
|
persistSyncSnapshot(_uiState.value.messages, extendLease = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capped Exit Banner Check ──
|
||||||
|
val isBudgetExceeded =
|
||||||
|
postedBotMessages >= maxBotLimit || continuationsCount >= maxPassLimit
|
||||||
|
val hasUnfinishedWork = remainingInitial.isNotEmpty() || hasPendingMentions(members)
|
||||||
|
|
||||||
|
if (isBudgetExceeded && hasUnfinishedWork && activeEpoch == epoch) {
|
||||||
|
val cappedMessage =
|
||||||
|
GroupChatMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
senderName = "system",
|
||||||
|
senderDisplayName = "System",
|
||||||
|
isUser = false,
|
||||||
|
isSystem = true,
|
||||||
|
text = CAPPED_SYSTEM_TEXT,
|
||||||
|
)
|
||||||
|
_uiState.update { it.copy(messages = it.messages + cappedMessage) }
|
||||||
|
persistSyncSnapshot(_uiState.value.messages, extendLease = false)
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(activeSpeaker = null) }
|
_uiState.update { it.copy(activeSpeaker = null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistSyncSnapshot(currentMessages: List<GroupChatMessage>) {
|
private fun hasPendingMentions(members: List<ProfileInfo>): Boolean {
|
||||||
|
val currentLog = _uiState.value.messages.filter { !it.isSystem }
|
||||||
|
val userIndex = currentLog.indexOfLast { it.isUser }
|
||||||
|
val recentAssistantMsgs =
|
||||||
|
if (userIndex >= 0) currentLog.drop(userIndex + 1) else currentLog
|
||||||
|
|
||||||
|
for (msg in recentAssistantMsgs) {
|
||||||
|
val parsed =
|
||||||
|
GroupChatMentions.parseMentions(
|
||||||
|
text = msg.text,
|
||||||
|
members = members,
|
||||||
|
excludedSpeaker = msg.senderName,
|
||||||
|
)
|
||||||
|
for (citedName in parsed.mentionedBots) {
|
||||||
|
val bot = members.find { it.name.equals(citedName, ignoreCase = true) }
|
||||||
|
if (bot != null) {
|
||||||
|
val seenCount = memberWatermarks[bot.name] ?: 0
|
||||||
|
if (seenCount < currentLog.size) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistSyncSnapshot(
|
||||||
|
currentMessages: List<GroupChatMessage>,
|
||||||
|
extendLease: Boolean = true,
|
||||||
|
) {
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
val allProfiles = fetchProfiles()
|
val allProfiles = fetchProfiles()
|
||||||
@@ -467,7 +846,7 @@ class GroupChatViewModel(
|
|||||||
?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
|
?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
|
||||||
|
|
||||||
val logEntries =
|
val logEntries =
|
||||||
currentMessages.takeLast(32).map { msg ->
|
currentMessages.filter { !it.isSystem }.takeLast(32).map { msg ->
|
||||||
GroupChatSyncLogEntry(
|
GroupChatSyncLogEntry(
|
||||||
id = msg.id,
|
id = msg.id,
|
||||||
from =
|
from =
|
||||||
@@ -481,25 +860,38 @@ class GroupChatViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val roomKey = "name:$groupName"
|
val now = System.currentTimeMillis()
|
||||||
val existingRoom =
|
val (targetKey, existingRoom) = findRoomEntry(existingSnapshot.rooms)
|
||||||
existingSnapshot.rooms?.get(roomKey)
|
|
||||||
?: existingSnapshot.rooms?.get(groupName)
|
val updatedLease =
|
||||||
?: existingSnapshot.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) }
|
if (extendLease) {
|
||||||
|
GroupChatRoomLease(
|
||||||
|
driverId = localDriverId,
|
||||||
|
epoch = activeEpoch,
|
||||||
|
expiresAt = now + LEASE_STEP_TTL_MS,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
existingRoom?.lease
|
||||||
|
}
|
||||||
|
|
||||||
val updatedRoom =
|
val updatedRoom =
|
||||||
(existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = System.currentTimeMillis())).copy(
|
(existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy(
|
||||||
name = groupName,
|
name = groupName,
|
||||||
members = _uiState.value.members.map { JsonPrimitive(it.name) },
|
members = _uiState.value.members.map { JsonPrimitive(it.name) },
|
||||||
log = logEntries,
|
log = logEntries,
|
||||||
updatedAt = System.currentTimeMillis(),
|
lease = updatedLease,
|
||||||
|
maxBotMessages = _uiState.value.maxBotMessages,
|
||||||
|
maxContinuationPasses = _uiState.value.maxContinuationPasses,
|
||||||
|
systemPrompt = _uiState.value.systemPrompt,
|
||||||
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
|
|
||||||
val updatedRooms = (existingSnapshot.rooms.orEmpty() + (roomKey to updatedRoom))
|
val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap()
|
||||||
|
updatedRooms[targetKey] = updatedRoom
|
||||||
val newSnapshot =
|
val newSnapshot =
|
||||||
existingSnapshot.copy(
|
existingSnapshot.copy(
|
||||||
version = 3,
|
version = 3,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = now,
|
||||||
rooms = updatedRooms,
|
rooms = updatedRooms,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -520,7 +912,7 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
private fun isPass(text: String): Boolean {
|
private fun isPass(text: String): Boolean {
|
||||||
val clean = text.trim().lowercase()
|
val clean = text.trim().lowercase()
|
||||||
return clean == "(pass)" || clean == "pass"
|
return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass."
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractSessionInfo(response: Any?): MemberSession? {
|
private fun extractSessionInfo(response: Any?): MemberSession? {
|
||||||
|
|||||||
@@ -822,4 +822,16 @@
|
|||||||
<string name="group_chat_typing">입력 중…</string>
|
<string name="group_chat_typing">입력 중…</string>
|
||||||
<string name="group_chat_members_count">멤버 %1$d명</string>
|
<string name="group_chat_members_count">멤버 %1$d명</string>
|
||||||
<string name="group_chat_mention_all">@all</string>
|
<string name="group_chat_mention_all">@all</string>
|
||||||
|
<string name="group_chat_capped_limit">대화가 일시중지되었습니다(턴 한도 도달) — 계속하려면 메시지를 보내세요</string>
|
||||||
|
<string name="group_chat_settings_title">그룹 채팅 설정</string>
|
||||||
|
<string name="group_chat_settings_max_messages">최대 봇 메시지 수</string>
|
||||||
|
<string name="group_chat_settings_max_messages_desc">메시지당 최대 봇 응답 한도 (%1$d)</string>
|
||||||
|
<string name="group_chat_settings_max_handoffs">최대 핸드오프 패스</string>
|
||||||
|
<string name="group_chat_settings_max_handoffs_desc">봇이 서로를 트리거할 수 있는 최대 횟수 (%1$d)</string>
|
||||||
|
<string name="group_chat_settings_save">저장</string>
|
||||||
|
<string name="group_chat_settings_reset_default">기본값으로 초기화</string>
|
||||||
|
<string name="group_chat_settings_system_prompt">방 지침 / 프롬프트</string>
|
||||||
|
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
|
||||||
|
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
|
||||||
|
<string name="group_chat_action_settings">설정</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1121,4 +1121,16 @@
|
|||||||
<string name="group_chat_typing">正在输入…</string>
|
<string name="group_chat_typing">正在输入…</string>
|
||||||
<string name="group_chat_members_count">%1$d 个成员</string>
|
<string name="group_chat_members_count">%1$d 个成员</string>
|
||||||
<string name="group_chat_mention_all">@all</string>
|
<string name="group_chat_mention_all">@all</string>
|
||||||
|
<string name="group_chat_capped_limit">讨论已暂停(达到轮次上限)— 发送消息继续</string>
|
||||||
|
<string name="group_chat_settings_title">群聊设置</string>
|
||||||
|
<string name="group_chat_settings_max_messages">最大 Bot 回复数</string>
|
||||||
|
<string name="group_chat_settings_max_messages_desc">单次发送的最大 Bot 回复总数 (%1$d)</string>
|
||||||
|
<string name="group_chat_settings_max_handoffs">最大交接轮次</string>
|
||||||
|
<string name="group_chat_settings_max_handoffs_desc">Bot 间互相触发的最大次数 (%1$d)</string>
|
||||||
|
<string name="group_chat_settings_save">保存</string>
|
||||||
|
<string name="group_chat_settings_reset_default">重置为默认</string>
|
||||||
|
<string name="group_chat_settings_system_prompt">房间指令 / 提示词</string>
|
||||||
|
<string name="group_chat_settings_system_prompt_hint">此群聊的自定义规则、语气或上下文(可选)…</string>
|
||||||
|
<string name="group_chat_settings_system_prompt_desc">适用于此房间内所有 Bot 的自定义准则</string>
|
||||||
|
<string name="group_chat_action_settings">设置</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1252,4 +1252,16 @@
|
|||||||
<string name="group_chat_typing">Typing…</string>
|
<string name="group_chat_typing">Typing…</string>
|
||||||
<string name="group_chat_members_count">%1$d members</string>
|
<string name="group_chat_members_count">%1$d members</string>
|
||||||
<string name="group_chat_mention_all">@all</string>
|
<string name="group_chat_mention_all">@all</string>
|
||||||
|
<string name="group_chat_capped_limit">Discussion paused (turn limit reached) — reply to continue</string>
|
||||||
|
<string name="group_chat_settings_title">Group Chat Settings</string>
|
||||||
|
<string name="group_chat_settings_max_messages">Max Bot Messages</string>
|
||||||
|
<string name="group_chat_settings_max_messages_desc">Ceiling on total bot replies per message (%1$d)</string>
|
||||||
|
<string name="group_chat_settings_max_handoffs">Max Handoff Passes</string>
|
||||||
|
<string name="group_chat_settings_max_handoffs_desc">How many times bots can trigger each other (%1$d)</string>
|
||||||
|
<string name="group_chat_settings_save">Save</string>
|
||||||
|
<string name="group_chat_settings_reset_default">Reset to Default</string>
|
||||||
|
<string name="group_chat_settings_system_prompt">Room Instructions / Prompt</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_action_settings">Settings</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots.group
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
import com.m57.hermescontrol.data.model.BotRosterMeta
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
|
import com.m57.hermescontrol.data.model.GroupChatRoomLease
|
||||||
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
|
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
|
||||||
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
|
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
|
||||||
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
|
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
|
||||||
@@ -263,4 +264,172 @@ class GroupChatViewModelTest {
|
|||||||
assertEquals(1, finalState.messages.size)
|
assertEquals(1, finalState.messages.size)
|
||||||
assertTrue(finalState.messages.first().isUser)
|
assertTrue(finalState.messages.first().isUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSendMessage_reactiveContinuationHandoff() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// User prompts scout
|
||||||
|
viewModel.sendMessage("@scout please review the specs")
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// Scout finishes turn and tags @coder
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.MessageComplete(
|
||||||
|
text = "Specs look good, @coder please implement this!",
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
assertEquals(2, viewModel.uiState.value.messages.size)
|
||||||
|
assertEquals("scout", viewModel.uiState.value.messages[1].senderName)
|
||||||
|
|
||||||
|
// Continuation pass triggers coder
|
||||||
|
eventsFlow.emit(
|
||||||
|
WsEvent.MessageComplete(
|
||||||
|
text = "On it! Writing unit tests now.",
|
||||||
|
sessionId = "session-scout-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
val messages = viewModel.uiState.value.messages
|
||||||
|
assertEquals(3, messages.size)
|
||||||
|
assertEquals("coder", messages[2].senderName)
|
||||||
|
assertEquals("On it! Writing unit tests now.", messages[2].text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSendMessage_passiveListenerWhenLeaseHeldByOtherDevice() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val activeLeaseSnapshot =
|
||||||
|
GroupChatSyncSnapshot(
|
||||||
|
version = 3,
|
||||||
|
rooms =
|
||||||
|
mapOf(
|
||||||
|
"name:Dev Team" to
|
||||||
|
GroupChatRoomMeta(
|
||||||
|
name = "Dev Team",
|
||||||
|
members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")),
|
||||||
|
lease =
|
||||||
|
GroupChatRoomLease(
|
||||||
|
driverId = "desktop_client_99",
|
||||||
|
expiresAt = System.currentTimeMillis() + 60_000L,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultProfileWithLease =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "default",
|
||||||
|
is_default = true,
|
||||||
|
ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(activeLeaseSnapshot)),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery { mockApi.getProfiles() } returns
|
||||||
|
Response.success(
|
||||||
|
ProfilesResponse(
|
||||||
|
profiles = listOf(defaultProfileWithLease, botA, botB),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.sendMessage("@scout check this")
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
// Because lease is held by desktop_client_99, mobile only posts the user message and yields
|
||||||
|
assertEquals(1, viewModel.uiState.value.messages.size)
|
||||||
|
assertTrue(viewModel.uiState.value.messages.first().isUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadGroup_loadsCustomPerGroupLimits() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val customLimitsSnapshot =
|
||||||
|
GroupChatSyncSnapshot(
|
||||||
|
version = 3,
|
||||||
|
rooms =
|
||||||
|
mapOf(
|
||||||
|
"name:Dev Team" to
|
||||||
|
GroupChatRoomMeta(
|
||||||
|
name = "Dev Team",
|
||||||
|
members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")),
|
||||||
|
maxBotMessages = 12,
|
||||||
|
maxContinuationPasses = 5,
|
||||||
|
systemPrompt = "You are elite software architects.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultProfileWithCustomLimits =
|
||||||
|
ProfileInfo(
|
||||||
|
name = "default",
|
||||||
|
is_default = true,
|
||||||
|
ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(customLimitsSnapshot)),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery { mockApi.getProfiles() } returns
|
||||||
|
Response.success(
|
||||||
|
ProfilesResponse(
|
||||||
|
profiles = listOf(defaultProfileWithCustomLimits, botA, botB),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals(12, state.maxBotMessages)
|
||||||
|
assertEquals(5, state.maxContinuationPasses)
|
||||||
|
assertEquals("You are elite software architects.", state.systemPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUpdateGroupLimits_updatesStateAndClamps() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.updateGroupLimits(
|
||||||
|
maxMessages = 15,
|
||||||
|
maxPasses = 4,
|
||||||
|
systemPrompt = "Custom instructions for this group",
|
||||||
|
)
|
||||||
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
|
assertEquals(15, viewModel.uiState.value.maxBotMessages)
|
||||||
|
assertEquals(4, viewModel.uiState.value.maxContinuationPasses)
|
||||||
|
assertEquals("Custom instructions for this group", viewModel.uiState.value.systemPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBuildTurnPrompt_includesCustomRoomInstructions() {
|
||||||
|
val prompt =
|
||||||
|
GroupChatMentions.buildTurnPrompt(
|
||||||
|
groupName = "Dev Team",
|
||||||
|
viewer = botA,
|
||||||
|
peers = listOf(botB),
|
||||||
|
recentLog =
|
||||||
|
listOf(
|
||||||
|
GroupChatMessage(
|
||||||
|
id = "1",
|
||||||
|
senderName = "user",
|
||||||
|
senderDisplayName = "User",
|
||||||
|
isUser = true,
|
||||||
|
text = "hello team",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
customRoomPrompt = "You are sarcastic AI buddies.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(prompt.contains("Room Instructions:\nYou are sarcastic AI buddies."))
|
||||||
|
assertTrue(prompt.contains("@scout (Scout Bot)"))
|
||||||
|
assertTrue(prompt.contains("@coder (Dev Bot)"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user