feat(chat): add @bot mention autocomplete to ChatComposer (#980)
This commit is contained in:
5 files changed
+162
No files matched your search
@@ -56,4 +56,48 @@ object ChatInputPolicy {
|
|||||||
commands: List<String>,
|
commands: List<String>,
|
||||||
usageCounts: Map<String, Int>,
|
usageCounts: Map<String, Int>,
|
||||||
): List<String> = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 }
|
): List<String> = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the active mention query (without the '@') if the cursor is currently
|
||||||
|
* positioned within an '@' mention token (e.g. "hello @res" -> "res").
|
||||||
|
* Returns null if not in a mention context.
|
||||||
|
*/
|
||||||
|
fun extractMentionQuery(
|
||||||
|
text: String,
|
||||||
|
cursorPosition: Int,
|
||||||
|
): String? {
|
||||||
|
if (text.isEmpty() || cursorPosition < 0 || cursorPosition > text.length) return null
|
||||||
|
val prefix = text.substring(0, cursorPosition)
|
||||||
|
val lastAt = prefix.lastIndexOf('@')
|
||||||
|
if (lastAt == -1) return null
|
||||||
|
|
||||||
|
// Ensure '@' is at start of string or preceded by whitespace
|
||||||
|
if (lastAt > 0 && !prefix[lastAt - 1].isWhitespace()) return null
|
||||||
|
|
||||||
|
val query = prefix.substring(lastAt + 1)
|
||||||
|
// Mention handle cannot contain whitespace
|
||||||
|
if (query.any { it.isWhitespace() }) return null
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a selected bot handle into the TextFieldValue at the active '@' mention position.
|
||||||
|
*/
|
||||||
|
fun applyMention(
|
||||||
|
current: TextFieldValue,
|
||||||
|
botName: String,
|
||||||
|
): TextFieldValue {
|
||||||
|
val text = current.text
|
||||||
|
val cursor = current.selection.end.coerceIn(0, text.length)
|
||||||
|
val prefix = text.substring(0, cursor)
|
||||||
|
val lastAt = prefix.lastIndexOf('@')
|
||||||
|
if (lastAt == -1) return current
|
||||||
|
|
||||||
|
val beforeAt = text.substring(0, lastAt)
|
||||||
|
val afterCursor = text.substring(cursor)
|
||||||
|
val inserted = "@$botName "
|
||||||
|
val newText = "$beforeAt$inserted$afterCursor"
|
||||||
|
val newCursor = beforeAt.length + inserted.length
|
||||||
|
return TextFieldValue(newText, TextRange(newCursor))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -767,6 +767,7 @@ fun ChatScreen(
|
|||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
|
availableBots = state.availableBots,
|
||||||
slashUsageCounts = state.slashUsageCounts,
|
slashUsageCounts = state.slashUsageCounts,
|
||||||
pendingAttachments = state.pendingAttachments,
|
pendingAttachments = state.pendingAttachments,
|
||||||
onCameraTap = {
|
onCameraTap = {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.Attachment
|
|||||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -304,6 +305,7 @@ data class ChatUiState(
|
|||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
// Commands catalog
|
// Commands catalog
|
||||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||||
|
val availableBots: List<ProfileInfo> = emptyList(),
|
||||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||||
// #865). Empty until the local store loads; commands without recorded
|
// #865). Empty until the local store loads; commands without recorded
|
||||||
// usage keep their catalog order.
|
// usage keep their catalog order.
|
||||||
@@ -1894,6 +1896,12 @@ class ChatViewModel(
|
|||||||
WsMethods.COMMANDS_CATALOG,
|
WsMethods.COMMANDS_CATALOG,
|
||||||
onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) },
|
onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) },
|
||||||
)
|
)
|
||||||
|
// Fetch available bot profiles for @ autocomplete
|
||||||
|
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||||
|
if (profilesResult is NetworkResult.Success) {
|
||||||
|
val profiles = profilesResult.data.profiles.orEmpty()
|
||||||
|
_uiState.update { it.copy(availableBots = profiles) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.animation.slideInVertically
|
|||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -60,9 +61,11 @@ import androidx.compose.ui.unit.sp
|
|||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||||
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
|
import com.m57.hermescontrol.ui.common.BotAvatar
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat input bar with a two-row layout: input+send on top,
|
* The chat input bar with a two-row layout: input+send on top,
|
||||||
@@ -80,6 +83,7 @@ fun ChatInputBar(
|
|||||||
commandCatalog: CommandCatalog,
|
commandCatalog: CommandCatalog,
|
||||||
slashUsageCounts: Map<String, Int> = emptyMap(),
|
slashUsageCounts: Map<String, Int> = emptyMap(),
|
||||||
pendingAttachments: List<Attachment> = emptyList(),
|
pendingAttachments: List<Attachment> = emptyList(),
|
||||||
|
availableBots: List<ProfileInfo> = emptyList(),
|
||||||
onCameraTap: () -> Unit = {},
|
onCameraTap: () -> Unit = {},
|
||||||
onImageTap: () -> Unit = {},
|
onImageTap: () -> Unit = {},
|
||||||
onFileTap: () -> Unit = {},
|
onFileTap: () -> Unit = {},
|
||||||
@@ -177,6 +181,91 @@ fun ChatInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bot mention @ suggestions
|
||||||
|
val mentionQuery =
|
||||||
|
remember(inputFieldValue.text, inputFieldValue.selection.end) {
|
||||||
|
ChatInputPolicy.extractMentionQuery(
|
||||||
|
inputFieldValue.text,
|
||||||
|
inputFieldValue.selection.end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = mentionQuery != null && availableBots.isNotEmpty(),
|
||||||
|
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||||
|
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||||
|
) {
|
||||||
|
val filteredBots =
|
||||||
|
remember(mentionQuery, availableBots) {
|
||||||
|
if (mentionQuery == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
availableBots.filter { bot ->
|
||||||
|
bot.name.startsWith(mentionQuery, ignoreCase = true) ||
|
||||||
|
bot.effectiveTitle.contains(mentionQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredBots.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
border =
|
||||||
|
BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.heightIn(max = 200.dp),
|
||||||
|
) {
|
||||||
|
items(filteredBots, key = { it.name }) { bot ->
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onInputChange(
|
||||||
|
ChatInputPolicy.applyMention(inputFieldValue, bot.name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
BotAvatar(
|
||||||
|
name = bot.name,
|
||||||
|
avatar = bot.botMeta()?.avatar,
|
||||||
|
size = 28.dp,
|
||||||
|
showPresence = false,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "@${bot.name}",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
if (bot.effectiveTitle != bot.name) {
|
||||||
|
Text(
|
||||||
|
text = bot.effectiveTitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attachment preview chips
|
// Attachment preview chips
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = pendingAttachments.isNotEmpty(),
|
visible = pendingAttachments.isNotEmpty(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -143,4 +145,22 @@ class ChatInputPolicyTest {
|
|||||||
ChatInputPolicy.sortSlashSuggestions(commands, mapOf("/MODEL" to 3)),
|
ChatInputPolicy.sortSlashSuggestions(commands, mapOf("/MODEL" to 3)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractMentionQuery_variousPositions() {
|
||||||
|
assertEquals("res", ChatInputPolicy.extractMentionQuery("@res", 4))
|
||||||
|
assertEquals("scout", ChatInputPolicy.extractMentionQuery("hey @scout", 10))
|
||||||
|
assertEquals("", ChatInputPolicy.extractMentionQuery("ask @", 5))
|
||||||
|
org.junit.Assert.assertNull(ChatInputPolicy.extractMentionQuery("test@example.com", 16))
|
||||||
|
org.junit.Assert.assertNull(ChatInputPolicy.extractMentionQuery("hello world", 5))
|
||||||
|
org.junit.Assert.assertNull(ChatInputPolicy.extractMentionQuery("@bot with spaces", 15))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun applyMention_insertsHandleAndAdvancesCursor() {
|
||||||
|
val initial = TextFieldValue("ask @sc", selection = androidx.compose.ui.text.TextRange(7))
|
||||||
|
val updated = ChatInputPolicy.applyMention(initial, "scout")
|
||||||
|
assertEquals("ask @scout ", updated.text)
|
||||||
|
assertEquals(11, updated.selection.end)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user