feat(chat): support first-class /compress and /compact session compression (#1306)
This commit is contained in:
13 files changed
+929
-6
No files matched your search
@@ -0,0 +1,32 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class SessionCompressResponse(
|
||||
val status: String? = null,
|
||||
val removed: Int? = null,
|
||||
val before_messages: Int? = null,
|
||||
val after_messages: Int? = null,
|
||||
val before_tokens: Long? = null,
|
||||
val after_tokens: Long? = null,
|
||||
val summary: CompressionSummary? = null,
|
||||
val compressed: Boolean? = null,
|
||||
val lock_held: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val messages: List<SessionMessage>? = null,
|
||||
val usage: JsonElement? = null,
|
||||
val info: JsonElement? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CompressionSummary(
|
||||
val noop: Boolean = false,
|
||||
val aborted: Boolean = false,
|
||||
val refused_would_grow: Boolean? = null,
|
||||
val fallback_used: Boolean? = null,
|
||||
val headline: String = "",
|
||||
val token_line: String = "",
|
||||
val note: String? = null,
|
||||
)
|
||||
@@ -73,7 +73,6 @@ object CommandBlocklist {
|
||||
"/image",
|
||||
"/quit",
|
||||
// TUI-only extras (meaningless outside the TUI)
|
||||
"/compact",
|
||||
"/logs",
|
||||
"/mouse",
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ object WsMethods {
|
||||
const val SESSION_TITLE = "session.title"
|
||||
const val SESSION_BRANCH = "session.branch"
|
||||
const val SESSION_BRANCH_WHOLE = "session.branch_whole"
|
||||
const val SESSION_COMPRESS = "session.compress"
|
||||
|
||||
/** Replay recorded events newer than client's last-seen seq for a session. */
|
||||
const val SESSION_EVENTS_SINCE = "session.events.since"
|
||||
|
||||
@@ -11,6 +11,21 @@ import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal fun buildLegacyClarifyBatchResponses(
|
||||
questions: List<ClarifyQuestionUi>,
|
||||
answers: Map<String, String>,
|
||||
lockedAnswers: Map<String, String>,
|
||||
): List<Pair<String, String>> =
|
||||
questions
|
||||
.asSequence()
|
||||
// A replayed pending_clarify can contain answers already locked by the
|
||||
// server. ClarifyBubble has no editable input for those rows, so its
|
||||
// answer map carries an empty placeholder. Never send that placeholder
|
||||
// back through legacy clarify.respond and overwrite accepted state.
|
||||
.filterNot { it.qid in lockedAnswers }
|
||||
.map { it.qid to answers[it.qid]?.trim().orEmpty() }
|
||||
.toList()
|
||||
|
||||
class ChatClarifyDelegate(
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val scope: CoroutineScope,
|
||||
@@ -114,14 +129,17 @@ class ChatClarifyDelegate(
|
||||
}
|
||||
|
||||
if (isBatch) {
|
||||
for (q in questions) {
|
||||
val ans = answers[q.qid]?.trim().orEmpty()
|
||||
for ((questionId, ans) in buildLegacyClarifyBatchResponses(
|
||||
questions = questions,
|
||||
answers = answers,
|
||||
lockedAnswers = lockedAnswers,
|
||||
)) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"response" to ans,
|
||||
"answer" to ans,
|
||||
"question_id" to q.qid,
|
||||
"question_id" to questionId,
|
||||
)
|
||||
if (clarifyId != null) {
|
||||
params["clarify_id"] = clarifyId
|
||||
|
||||
@@ -807,6 +807,8 @@ fun ChatScreen(
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
openingAttachmentPath = state.openingAttachmentPath,
|
||||
isCompressing = state.isCompressing && !timelineState.isHistorical,
|
||||
compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical },
|
||||
onImageClick = { viewingImage = it },
|
||||
speakingMessageId = speakingMessageId,
|
||||
onToggleSpeak = { message ->
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.model.SessionCompressResponse
|
||||
import com.m57.hermescontrol.data.model.SessionTimelineEntry
|
||||
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||
@@ -234,6 +235,8 @@ data class ChatUiState(
|
||||
// until the first successful session.usage fetch) — drives the
|
||||
// "compressed ×N" badge on the context chip.
|
||||
val compressionCount: Int? = null,
|
||||
val isCompressing: Boolean = false,
|
||||
val compressionStatus: String? = null,
|
||||
/** Rolling output tokens/sec over the last ~10 calls. */
|
||||
val latestTps: Double? = null,
|
||||
/** Latest cumulative backend usage snapshot for the current session. */
|
||||
@@ -3701,6 +3704,10 @@ class ChatViewModel(
|
||||
handleUndoCommand(result.count)
|
||||
}
|
||||
|
||||
is SlashResult.Compress -> {
|
||||
compressSession(result.focusTopic)
|
||||
}
|
||||
|
||||
is SlashResult.RpcDispatch -> {
|
||||
dispatchViaRpc(command)
|
||||
}
|
||||
@@ -4125,6 +4132,113 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun compressSession(focusTopic: String) {
|
||||
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
|
||||
if (sessionId == null) {
|
||||
addAssistantMessage("No active session to compress.")
|
||||
return
|
||||
}
|
||||
if (_uiState.value.isCompressing) {
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCompressing = true,
|
||||
compressionStatus = "⏳ Compressing context...",
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
val params =
|
||||
buildMap<String, Any> {
|
||||
put("session_id", sessionId)
|
||||
if (focusTopic.isNotBlank()) {
|
||||
put("focus_topic", focusTopic)
|
||||
}
|
||||
}
|
||||
// Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout
|
||||
val result =
|
||||
wsClient
|
||||
.request(
|
||||
WsMethods.SESSION_COMPRESS,
|
||||
params,
|
||||
timeoutMs = 300_000L,
|
||||
).await()
|
||||
|
||||
handleCompressionResult(result)
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
addAssistantMessage("/compress: ${e.message ?: "compression failed"}")
|
||||
} catch (e: Exception) {
|
||||
addAssistantMessage("/compress: ${e.message ?: "compression failed"}")
|
||||
} finally {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleCompressionResult(result: Any?) {
|
||||
val response =
|
||||
try {
|
||||
val jsonElement = result.toJsonElement()
|
||||
OkHttpProvider.json.decodeFromJsonElement<SessionCompressResponse>(jsonElement)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode session.compress response", e)
|
||||
null
|
||||
}
|
||||
|
||||
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
|
||||
|
||||
// 1. Authoritative transcript replacement
|
||||
if (response?.messages != null && sessionId != null) {
|
||||
val replacementMessages =
|
||||
withContext(historyDispatcher) {
|
||||
mapServerMessages(
|
||||
sessionId = sessionId,
|
||||
messages = response.messages,
|
||||
offset = 0,
|
||||
latestPaging = latestPaging,
|
||||
liveMessages = emptyList(),
|
||||
context = getApplication(),
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(messages = replacementMessages) }
|
||||
persistHistoryPage(replacementMessages, sessionId)
|
||||
}
|
||||
|
||||
// 2. Display result summary via addAssistantMessage
|
||||
val summary = response?.summary
|
||||
val lines = mutableListOf<String>()
|
||||
summary?.headline?.takeIf { it.isNotBlank() }?.let { lines.add(it) }
|
||||
summary?.token_line?.takeIf { it.isNotBlank() }?.let { lines.add(it) }
|
||||
summary?.note?.takeIf { it.isNotBlank() }?.let { lines.add(it) }
|
||||
|
||||
val feedback =
|
||||
if (lines.isNotEmpty()) {
|
||||
lines.joinToString("\n")
|
||||
} else {
|
||||
response?.message ?: "Context compressed."
|
||||
}
|
||||
addAssistantMessage(feedback)
|
||||
|
||||
// 3. Refresh usage & sessions
|
||||
fetchContextUsage()
|
||||
loadSessions()
|
||||
|
||||
// 4. Handle lineage/session info if provided
|
||||
response?.info?.let { infoElement ->
|
||||
val infoMap = (infoElement.toAny() as? Map<*, *>)?.filterKeys { it is String } as? Map<String, Any?>
|
||||
handleSessionInfo(infoMap)
|
||||
}
|
||||
sessionHasServerPresence = true
|
||||
}
|
||||
|
||||
private fun handlePrefillResult(
|
||||
message: String,
|
||||
notice: String,
|
||||
|
||||
@@ -208,7 +208,7 @@ object ChatWsEventReducer {
|
||||
|
||||
is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState)
|
||||
|
||||
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
|
||||
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState, event)
|
||||
|
||||
is WsEvent.ConnectionRequest,
|
||||
is WsEvent.ConnectionUpdate,
|
||||
@@ -1046,7 +1046,38 @@ object ChatWsEventReducer {
|
||||
private fun onStatusUpdate(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
): ReducerResult = ReducerResult(state = state, streamingState = streamingState)
|
||||
event: WsEvent.StatusUpdate,
|
||||
): ReducerResult {
|
||||
val kind = event.data?.get("kind") as? String
|
||||
val text = event.data?.get("text") as? String
|
||||
return when (kind) {
|
||||
"compressing", "compacting" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = true,
|
||||
compressionStatus = text ?: "⏳ Compressing context...",
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
"compacted" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = false,
|
||||
compressionStatus = text,
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ReducerResult(state = state, streamingState = streamingState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUnknown(
|
||||
state: ChatUiState,
|
||||
|
||||
@@ -77,6 +77,11 @@ class SlashCommandDispatcher {
|
||||
SlashResult.Undo(count = arg)
|
||||
}
|
||||
|
||||
"/compress", "/compact" -> {
|
||||
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||
SlashResult.Compress(focusTopic = arg)
|
||||
}
|
||||
|
||||
else -> {
|
||||
SlashResult.RpcDispatch
|
||||
}
|
||||
@@ -172,4 +177,11 @@ sealed class SlashResult {
|
||||
data class Undo(
|
||||
val count: String,
|
||||
) : SlashResult()
|
||||
|
||||
/**
|
||||
* Compress session context via the session.compress WebSocket RPC.
|
||||
*/
|
||||
data class Compress(
|
||||
val focusTopic: String,
|
||||
) : SlashResult()
|
||||
}
|
||||
@@ -3,11 +3,15 @@ package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -107,6 +111,8 @@ fun FullBleedChatList(
|
||||
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||
speakingMessageId: String? = null,
|
||||
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
|
||||
isCompressing: Boolean = false,
|
||||
compressionStatus: String? = null,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
Box(
|
||||
@@ -149,6 +155,23 @@ fun FullBleedChatList(
|
||||
val tailItems =
|
||||
buildMap<String, @Composable () -> Unit> {
|
||||
replyErrorContent?.let { put("reply_error", it) }
|
||||
if (isCompressing || compressionStatus != null) {
|
||||
put("compression_status") {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = compressionStatus ?: "⏳ Compressing context...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
agentStatus?.let { status ->
|
||||
put("agent_status") {
|
||||
AgentStatusIndicator(status = status)
|
||||
|
||||
Reference in new issue
Block a user