feat(chat): subtle tool-call divider every 5 calls per turn (#769)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
1e8c3629f0
commit
ec72ed4733
5 files changed
+276
-21
No files matched your search
@@ -0,0 +1,110 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
|
||||
* subtle divider between the tool bubble and the next bubble, labeled with the
|
||||
* per-turn call count against the real per-turn budget (e.g. `5/90`) so the
|
||||
* user can see how much of the turn's tool budget has been burned (issue #767).
|
||||
*
|
||||
* The divider is pure derived state: it is computed from the in-memory message
|
||||
* list at render time and is never persisted, so scrolling back through old
|
||||
* history shows the same milestones the live stream did.
|
||||
*/
|
||||
const val TOOL_CALL_DIVIDER_INTERVAL = 5
|
||||
|
||||
/**
|
||||
* Returns the message indices that complete a tool-call milestone — a [MessageRole.TOOL]
|
||||
* message whose per-turn tool-call count is a multiple of [TOOL_CALL_DIVIDER_INTERVAL] —
|
||||
* mapped to the per-turn count at that point.
|
||||
*
|
||||
* The counter resets at each [MessageRole.USER] message (a new turn), so
|
||||
* milestones read `5, 10, 15...` within a turn and restart at `5` on the next.
|
||||
*/
|
||||
fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
|
||||
val milestones = mutableMapOf<Int, Int>()
|
||||
var toolCount = 0
|
||||
messages.forEachIndexed { index, message ->
|
||||
when (message.role) {
|
||||
MessageRole.USER -> toolCount = 0
|
||||
MessageRole.TOOL -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[index] = toolCount
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return milestones
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider label: `count/maxPerTurn` when the real budget is known (fetched from
|
||||
* the backend config), bare `count` when it isn't — never a hardcoded default.
|
||||
*/
|
||||
fun toolCallDividerLabel(
|
||||
count: Int,
|
||||
maxPerTurn: Int?,
|
||||
): String =
|
||||
if (maxPerTurn != null && maxPerTurn > 0) {
|
||||
"$count/$maxPerTurn"
|
||||
} else {
|
||||
count.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtle "beat counter" divider: a hairline, the per-turn count vs budget
|
||||
* (e.g. `5/90`), and a hairline. Rendered between bubbles after every 5th
|
||||
* tool call of the current turn.
|
||||
*
|
||||
* @param maxPerTurn the real per-turn tool-call budget (agent.max_turns from
|
||||
* the backend config), or null when it could not be fetched — the label then
|
||||
* degrades to the bare count.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolCallDivider(
|
||||
count: Int,
|
||||
maxPerTurn: Int? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f)
|
||||
val labelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 40.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
thickness = 0.5.dp,
|
||||
color = lineColor,
|
||||
)
|
||||
Text(
|
||||
text = toolCallDividerLabel(count, maxPerTurn),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = labelColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
thickness = 0.5.dp,
|
||||
color = lineColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -504,6 +504,7 @@ fun ChatScreen(
|
||||
searchMatchIndices = state.searchMatchIndices,
|
||||
typingEffectEnabled = state.typingEffectEnabled,
|
||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||
isLoading = state.isLoading,
|
||||
isLoadingOlder = state.isLoadingOlder,
|
||||
isDark = isDark,
|
||||
|
||||
@@ -44,7 +44,10 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -67,6 +70,12 @@ data class ChatUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingOlder: Boolean = false,
|
||||
val hasOlderMessages: Boolean = false,
|
||||
/**
|
||||
* Real per-turn tool-call budget (agent.max_turns) from GET /api/config.
|
||||
* Null when it could not be fetched — the tool-call dividers then degrade
|
||||
* to a bare count instead of a hardcoded default.
|
||||
*/
|
||||
val maxToolCallsPerTurn: Int? = null,
|
||||
/** Standalone streaming message — rendered after the main list. */
|
||||
val streamingMessage: ChatMessage? = null,
|
||||
val errorMessage: String? = null,
|
||||
@@ -281,6 +290,7 @@ class ChatViewModel(
|
||||
|
||||
init {
|
||||
refreshSettings()
|
||||
refreshMaxToolCallsPerTurn()
|
||||
|
||||
connectWebSocket(setLoading = false)
|
||||
viewModelScope.launch {
|
||||
@@ -1326,6 +1336,32 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the real per-turn tool-call budget (`agent.max_turns` — falling back
|
||||
* to the legacy top-level `max_turns`) from GET /api/config so the chat's
|
||||
* tool-call dividers can render `count/max` against the actual backend
|
||||
* limit. Never hardcoded. Null on failure — the divider degrades to a
|
||||
* bare count.
|
||||
*/
|
||||
private fun refreshMaxToolCallsPerTurn() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val response = ApiClient.hermesApi.getConfig()
|
||||
if (!response.isSuccessful) return@launch
|
||||
val config = response.body() ?: return@launch
|
||||
val agent = config["agent"] as? JsonObject
|
||||
val max =
|
||||
(agent?.get("max_turns")?.jsonPrimitive?.intOrNull)
|
||||
?: config["max_turns"]?.jsonPrimitive?.intOrNull
|
||||
if (max != null && max > 0) {
|
||||
_uiState.update { it.copy(maxToolCallsPerTurn = max) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch max tool calls per turn", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -22,6 +23,8 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.toolCallMilestones
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
|
||||
/**
|
||||
@@ -39,6 +42,7 @@ fun ChatMessageList(
|
||||
searchMatchIndices: List<Int>,
|
||||
typingEffectEnabled: Boolean,
|
||||
typingEffectDelayMs: Int,
|
||||
maxToolCallsPerTurn: Int? = null,
|
||||
isLoading: Boolean,
|
||||
isLoadingOlder: Boolean,
|
||||
isDark: Boolean,
|
||||
@@ -62,6 +66,7 @@ fun ChatMessageList(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val toolMilestones = toolCallMilestones(messages)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -90,27 +95,33 @@ fun ChatMessageList(
|
||||
val isLastMessage = index == messages.lastIndex
|
||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||
|
||||
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||
lastAnimatedMessageId != message.id
|
||||
) {
|
||||
StreamingBubbleWithTypingEffect(
|
||||
streaming = message,
|
||||
typingDelayMs = typingEffectDelayMs,
|
||||
isDark = isDark,
|
||||
onAnimationComplete = {
|
||||
onLastAnimatedMessageIdChange(message.id)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ChatBubble(
|
||||
message = message,
|
||||
isDarkTheme = isDark,
|
||||
searchQuery = if (isSearchActive) searchQuery else "",
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onRespondApproval = viewModel::respondToApproval,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
Column {
|
||||
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||
lastAnimatedMessageId != message.id
|
||||
) {
|
||||
StreamingBubbleWithTypingEffect(
|
||||
streaming = message,
|
||||
typingDelayMs = typingEffectDelayMs,
|
||||
isDark = isDark,
|
||||
onAnimationComplete = {
|
||||
onLastAnimatedMessageIdChange(message.id)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ChatBubble(
|
||||
message = message,
|
||||
isDarkTheme = isDark,
|
||||
searchQuery = if (isSearchActive) searchQuery else "",
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onRespondApproval = viewModel::respondToApproval,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
// Subtle beat counter after every 5th tool call (issue #767).
|
||||
toolMilestones[index]?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user