chore: promote dev to main (#1455)
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
1 parent
62fcf07e1b
commit
944390bec1
56 files changed
+3914
-304
No files matched your search
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||
import com.m57.hermescontrol.ui.chat.cleanStalePastedImages
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -28,6 +29,10 @@ class HermesControlApp :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val startupTimeMs = System.currentTimeMillis()
|
||||
appScope.launch(Dispatchers.IO) {
|
||||
cleanStalePastedImages(cacheDir, startupTimeMs)
|
||||
}
|
||||
AuthManager.init(this)
|
||||
NetworkMonitor.init(this)
|
||||
SessionListCacheStore.init(this)
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||
import com.m57.hermescontrol.ui.chat.normalizedGatewayNotice
|
||||
|
||||
/**
|
||||
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
||||
@@ -44,7 +45,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
messageProvenance =
|
||||
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
||||
?: MessageProvenance.UNKNOWN,
|
||||
)
|
||||
).normalizedGatewayNotice()
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
ChatMessageEntity(
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.model
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
@@ -100,20 +101,10 @@ data class SessionMessage(
|
||||
}
|
||||
|
||||
val contentText: String
|
||||
get() =
|
||||
when (content) {
|
||||
is JsonPrimitive -> content.content
|
||||
null -> ""
|
||||
else -> content.toString()
|
||||
}
|
||||
get() = content?.transcriptText().orEmpty()
|
||||
|
||||
val displayContentText: String?
|
||||
get() =
|
||||
when (display_content) {
|
||||
is JsonPrimitive -> display_content.content
|
||||
null -> null
|
||||
else -> display_content.toString()
|
||||
}
|
||||
get() = display_content?.transcriptText()
|
||||
|
||||
val reasoningText: String
|
||||
get() =
|
||||
@@ -160,4 +151,25 @@ data class SessionMessage(
|
||||
get() = tool_call_id.orEmpty()
|
||||
}
|
||||
|
||||
// #1432: model-facing multipart payloads contain inline image bytes, not displayable JSON.
|
||||
private fun JsonElement.transcriptText(): String =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
content
|
||||
}
|
||||
|
||||
is JsonArray -> {
|
||||
mapNotNull { part ->
|
||||
val block = part as? JsonObject ?: return@mapNotNull null
|
||||
val type = block["type"]
|
||||
if (type != null && (type as? JsonPrimitive)?.content != "text") return@mapNotNull null
|
||||
(block["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
else -> {
|
||||
toString()
|
||||
}
|
||||
}
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
@@ -279,7 +279,7 @@ object EventParser {
|
||||
|
||||
"status.update" -> {
|
||||
val status = payload?.get("status") as? String
|
||||
WsEvent.StatusUpdate(status, payload)
|
||||
WsEvent.StatusUpdate(status, payload, sessionId)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
|
||||
@@ -205,6 +205,7 @@ sealed class WsEvent {
|
||||
data class StatusUpdate(
|
||||
val status: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class SessionUpdated(
|
||||
|
||||
@@ -11,9 +11,21 @@ data class SessionCreateParams(
|
||||
@SerialName("hidden") val hidden: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `source` this client declares for every session it mints or resumes.
|
||||
*
|
||||
* Mobile presents the desktop surface, so it claims `"desktop"` on BOTH `session.create` and
|
||||
* `session.resume`. The gateway resolves an omitted `source` from the host environment
|
||||
* (`_resolve_session_source`), which on a headless backend is `"tui"`; a resumed runtime then
|
||||
* reads as a surface switch and loses the `desktop_ui` toolset (#1450). One constant so the
|
||||
* create and resume call sites cannot drift apart again.
|
||||
*/
|
||||
const val DESKTOP_SESSION_SOURCE = "desktop"
|
||||
|
||||
@Serializable
|
||||
data class SessionResumeParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("source") val source: String? = null,
|
||||
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||
@@ -157,6 +158,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = storedSessionId,
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
omitMessages = true,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
@@ -564,7 +565,7 @@ class GroupChatViewModel(
|
||||
method = RpcMethods.SESSION_CREATE,
|
||||
params =
|
||||
SessionCreateParams(
|
||||
source = "desktop",
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
profile = bot.name,
|
||||
title = title,
|
||||
hidden = true,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/** Captured before asynchronous clipboard access; never retarget a late image to a new chat. */
|
||||
internal data class ChatAttachmentTarget(
|
||||
val sessionId: String,
|
||||
val generation: Long,
|
||||
val baseUrl: String,
|
||||
val connectionProfileId: String?,
|
||||
val agentProfileId: String?,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||
|
||||
/**
|
||||
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
|
||||
@@ -56,7 +57,7 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
message.role == MessageRole.TOOL -> {
|
||||
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[message.id] = toolCount
|
||||
@@ -79,7 +80,7 @@ fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
message.role == MessageRole.TOOL -> {
|
||||
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[index] = toolCount
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
|
||||
private const val PASTED_IMAGE_DIRECTORY = "pasted_images"
|
||||
private const val PASTED_IMAGE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Startup-only sweep (#1444). Keep recent files for transcript previews/retries;
|
||||
* never run during a live session where an old attachment may still be uploading.
|
||||
*/
|
||||
internal fun cleanStalePastedImages(
|
||||
cacheDir: File,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val directory = File(cacheDir, PASTED_IMAGE_DIRECTORY)
|
||||
val files = runCatching { directory.listFiles() }.getOrNull() ?: return
|
||||
val cutoff = nowMs - PASTED_IMAGE_MAX_AGE_MS
|
||||
for (file in files) {
|
||||
runCatching {
|
||||
// Do not traverse subdirectories or touch any other cache namespace.
|
||||
if (file.isFile && file.lastModified() < cutoff) file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PastedImageTooLargeException : IOException("Pasted image exceeds attachment limit")
|
||||
|
||||
/** A private cache file owned by the caller after [ChatPastedImageStore.importImage] returns. */
|
||||
class PastedImage(
|
||||
val attachment: Attachment,
|
||||
val file: File,
|
||||
) {
|
||||
/** Call if the import cannot be queued (for example, after a session switch). */
|
||||
fun discard() {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit clipboard/IME image importer; it never observes the clipboard itself. */
|
||||
class ChatPastedImageStore(
|
||||
private val context: Context,
|
||||
) {
|
||||
/** Returns null for unsupported, missing, or empty input; oversized streams fail explicitly. */
|
||||
suspend fun importImage(
|
||||
uri: Uri,
|
||||
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||
): PastedImage? {
|
||||
var pending: PastedImage? = null
|
||||
var delivered = false
|
||||
try {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (uri.scheme != "content") return@withContext null
|
||||
val resolver = context.contentResolver
|
||||
val mimeType =
|
||||
resolver
|
||||
.getType(uri)
|
||||
?.takeIf { it.startsWith("image/", ignoreCase = true) && it.length > "image/".length }
|
||||
?: return@withContext null
|
||||
val extension =
|
||||
MimeTypeMap
|
||||
.getSingleton()
|
||||
.getExtensionFromMimeType(mimeType)
|
||||
?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,10}")) }
|
||||
?: "img"
|
||||
val input = resolver.openInputStream(uri) ?: return@withContext null
|
||||
val activeContext = currentCoroutineContext()
|
||||
val file =
|
||||
stagePastedImage(
|
||||
input = input,
|
||||
directory = File(context.cacheDir, PASTED_IMAGE_DIRECTORY),
|
||||
extension = extension,
|
||||
maxBytes = maxBytes,
|
||||
checkActive = { activeContext.ensureActive() },
|
||||
) ?: return@withContext null
|
||||
try {
|
||||
activeContext.ensureActive()
|
||||
val privateUri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file,
|
||||
)
|
||||
PastedImage(
|
||||
attachment =
|
||||
Attachment(
|
||||
uri = privateUri.toString(),
|
||||
name = file.name,
|
||||
mimeType = mimeType,
|
||||
size = file.length(),
|
||||
),
|
||||
file = file,
|
||||
).also { pending = it }
|
||||
} catch (failure: Throwable) {
|
||||
file.delete()
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
delivered = true
|
||||
return result
|
||||
} finally {
|
||||
// withContext may discard its completed IO result if the parent was cancelled
|
||||
// during dispatch back; in that case ownership never reached the caller.
|
||||
if (!delivered) pending?.discard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM-testable, exclusive-create bounded copy. Null means empty input. */
|
||||
internal fun stagePastedImage(
|
||||
input: InputStream,
|
||||
directory: File,
|
||||
extension: String,
|
||||
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||
checkActive: () -> Unit = {},
|
||||
newId: () -> String = { UUID.randomUUID().toString() },
|
||||
): File? {
|
||||
require(maxBytes > 0) { "maxBytes must be positive" }
|
||||
val effectiveMaxBytes = minOf(maxBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||
require(extension.matches(Regex("[A-Za-z0-9]{1,10}"))) { "Invalid extension" }
|
||||
var owned: File? = null
|
||||
var keep = false
|
||||
try {
|
||||
return input
|
||||
.use source@{ source ->
|
||||
if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) {
|
||||
throw IOException("Cannot create pasted image cache directory")
|
||||
}
|
||||
val file = File(directory, "${UUID.fromString(newId())}.$extension")
|
||||
if (!file.createNewFile()) throw IOException("Pasted image cache name already exists")
|
||||
owned = file
|
||||
var total = 0L
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
FileOutputStream(file).use { output ->
|
||||
while (true) {
|
||||
checkActive()
|
||||
// Probe one byte beyond the limit without trusting provider-reported size.
|
||||
val remaining = effectiveMaxBytes - total
|
||||
val requested = if (remaining < buffer.size) (remaining + 1).toInt() else buffer.size
|
||||
val count = source.read(buffer, 0, requested)
|
||||
if (count < 0) break
|
||||
if (count > remaining) throw PastedImageTooLargeException()
|
||||
if (count == 0) {
|
||||
val byte = source.read()
|
||||
if (byte < 0) break
|
||||
if (remaining == 0L) throw PastedImageTooLargeException()
|
||||
output.write(byte)
|
||||
total++
|
||||
continue
|
||||
}
|
||||
output.write(buffer, 0, count)
|
||||
total += count
|
||||
}
|
||||
checkActive()
|
||||
}
|
||||
if (total == 0L) null else file
|
||||
}.also { keep = it != null }
|
||||
} finally {
|
||||
if (!keep) owned?.delete()
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -70,7 +72,6 @@ import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -118,9 +119,12 @@ import com.m57.hermescontrol.ui.chat.components.SpeechRequest
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatImagePaste
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
||||
import com.m57.hermescontrol.ui.chat.components.replaceComposerDraft
|
||||
import com.m57.hermescontrol.ui.chat.components.restoreRejectedComposerDraft
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
@@ -338,20 +342,18 @@ fun ChatScreen(
|
||||
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
||||
}
|
||||
}
|
||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(""))
|
||||
}
|
||||
val inputState = rememberTextFieldState()
|
||||
|
||||
LaunchedEffect(state.pendingPrefillText) {
|
||||
val prefill = state.pendingPrefillText
|
||||
if (prefill != null) {
|
||||
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill)
|
||||
inputState.replaceComposerDraft(prefill)
|
||||
viewModel.consumePendingPrefill()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.composerTextToRestore) {
|
||||
state.composerTextToRestore?.let { text ->
|
||||
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue)
|
||||
inputState.restoreRejectedComposerDraft(text)
|
||||
viewModel.consumeComposerTextRestore()
|
||||
}
|
||||
}
|
||||
@@ -364,6 +366,10 @@ fun ChatScreen(
|
||||
}
|
||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val imagePaste =
|
||||
rememberChatImagePaste(viewModel) { message ->
|
||||
scrollScope.launch { snackbarHostState.showSnackbar(message) }
|
||||
}
|
||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||
@@ -411,8 +417,7 @@ fun ChatScreen(
|
||||
|
||||
val mediaLaunchers =
|
||||
rememberChatMediaLaunchers(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputFieldValueChange = { inputFieldValue = it },
|
||||
inputState = inputState,
|
||||
onAddAttachment = { uri, name, mimeType, size ->
|
||||
viewModel.addAttachment(uri, name, mimeType, size)
|
||||
},
|
||||
@@ -865,11 +870,12 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
ChatInputBar(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
inputState = inputState,
|
||||
receiveContentListener = imagePaste,
|
||||
isReceivingContent = imagePaste.isReceiving,
|
||||
onSend = {
|
||||
if (viewModel.sendMessage(inputFieldValue.text)) {
|
||||
inputFieldValue = TextFieldValue("")
|
||||
if (!imagePaste.isReceiving && viewModel.sendMessage(inputState.text.toString())) {
|
||||
inputState.clearText()
|
||||
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
|
||||
@@ -100,7 +101,7 @@ class ChatSearchController {
|
||||
}
|
||||
|
||||
MessageRole.TOOL -> {
|
||||
scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||
if (!message.isHiddenTool()) scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||
}
|
||||
|
||||
MessageRole.SYSTEM -> {
|
||||
|
||||
@@ -61,6 +61,25 @@ internal fun pendingSendIdsConfirmedByDurableAliases(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* #1427: the `prompt.submit` `user_row_id` is the exact gateway row for that send, so a history
|
||||
* page containing it proves delivery even when the merge could not alias the local bubble.
|
||||
*/
|
||||
internal fun pendingSendIdsConfirmedByRowIds(
|
||||
pageRowIds: Set<Long>,
|
||||
pending: List<PendingSend>,
|
||||
): Set<String> =
|
||||
pending
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
||||
}.filter { it.userRowId != null && it.userRowId in pageRowIds }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
|
||||
/** #1427: a receipt holding a gateway `user_row_id` is stored server-side and must never become UNKNOWN. */
|
||||
internal fun canDemoteAcceptedReceipt(receipt: PendingSend): Boolean = receipt.userRowId == null
|
||||
|
||||
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
|
||||
class ChatSendStore(
|
||||
private val prefs: SharedPreferences? = null,
|
||||
|
||||
@@ -232,7 +232,9 @@ internal fun mapServerMessages(
|
||||
rawContent
|
||||
}
|
||||
var attachments: List<Attachment>? = null
|
||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
if (role == MessageRole.USER) {
|
||||
attachments = userImageAttachments(finalContent, mediaUrl).takeIf { it.isNotEmpty() }
|
||||
} else if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
val items = HostMediaExtractor.extract(rawContent)
|
||||
if (items.isNotEmpty()) {
|
||||
finalContent = HostMediaExtractor.strip(rawContent)
|
||||
@@ -275,7 +277,7 @@ internal fun mapServerMessages(
|
||||
completionId = completionId,
|
||||
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
||||
reactions = msg.reactions,
|
||||
),
|
||||
).normalizedGatewayNotice(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -417,6 +417,12 @@ internal fun mergeCachedTranscriptPage(
|
||||
match.id to
|
||||
rich.copy(
|
||||
id = match.id,
|
||||
attachments =
|
||||
if (rich.role == MessageRole.USER) {
|
||||
rich.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments
|
||||
} else {
|
||||
rich.attachments
|
||||
},
|
||||
content = preservedContent ?: rich.content,
|
||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||
serverRowId = match.serverRowId ?: message.serverRowId,
|
||||
@@ -457,6 +463,15 @@ internal fun mergeTranscriptWithLive(
|
||||
when {
|
||||
match?.role == MessageRole.USER -> {
|
||||
match.copy(
|
||||
// #1432: cached user rows lack attachment metadata; hydrate from REST,
|
||||
// while preserving richer optimistic/local attachments when present.
|
||||
attachments = match.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments,
|
||||
content =
|
||||
if (match.isHistoricalCache && !message.attachments.isNullOrEmpty()) {
|
||||
message.content
|
||||
} else {
|
||||
match.content
|
||||
},
|
||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||
reactions = message.reactions.ifEmpty { match.reactions },
|
||||
@@ -531,14 +546,21 @@ internal fun mergeTranscriptWithLive(
|
||||
.mapNotNull { index ->
|
||||
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
||||
}.toMap()
|
||||
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true)
|
||||
.reconcileReasoningRows()
|
||||
return dedupeCachedMessages(
|
||||
transcript.inTranscriptOrder(
|
||||
previous = current,
|
||||
resolvedOrders = resolvedOrders,
|
||||
observedSuccessorAnchors = current,
|
||||
),
|
||||
confirmedOnly = true,
|
||||
).reconcileReasoningRows()
|
||||
}
|
||||
|
||||
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
|
||||
private fun List<ChatMessage>.inTranscriptOrder(
|
||||
previous: List<ChatMessage>,
|
||||
resolvedOrders: Map<String, Long>,
|
||||
observedSuccessorAnchors: List<ChatMessage>? = null,
|
||||
): List<ChatMessage> {
|
||||
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
||||
// Session-start markers use -1 and must remain before unresolved cached history.
|
||||
@@ -549,6 +571,21 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
var pendingLocalOrder: Long? = null
|
||||
val localAnchors = mutableMapOf<String, Long>()
|
||||
val pendingOrderByLocal = mutableMapOf<String, Long>()
|
||||
// #1451: an observed live block must stay before its later confirmed USER successor,
|
||||
// even when its own REST echoes are absent. Successor anchors derive only from genuinely
|
||||
// observed current lists (never cache concatenations) and only from later USER prompts.
|
||||
val nextCanonicalOrder = mutableMapOf<String, Long>()
|
||||
val anchorSource = observedSuccessorAnchors ?: emptyList()
|
||||
var followingUserCanonical: Long? = null
|
||||
for (i in anchorSource.indices.reversed()) {
|
||||
val message = anchorSource[i]
|
||||
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||
if (order != null && message.role == MessageRole.USER) {
|
||||
followingUserCanonical = order
|
||||
} else if (followingUserCanonical != null) {
|
||||
nextCanonicalOrder[message.id] = followingUserCanonical
|
||||
}
|
||||
}
|
||||
previous.forEach { message ->
|
||||
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||
if (order != null) {
|
||||
@@ -563,7 +600,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
} else if (message.isPermanentlyLocal()) {
|
||||
localAnchors[message.id] =
|
||||
if (hasPendingPredecessor) {
|
||||
Long.MAX_VALUE
|
||||
nextCanonicalOrder[message.id]?.let { it - 1L } ?: Long.MAX_VALUE
|
||||
} else {
|
||||
precedingCanonical?.takeIf { it >= 0L }
|
||||
?: if (message.role == MessageRole.USER) {
|
||||
@@ -581,6 +618,9 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
} else {
|
||||
hasPendingPredecessor = true
|
||||
pendingLocalOrder = message.localOrder
|
||||
nextCanonicalOrder[message.id]?.let { successor ->
|
||||
localAnchors[message.id] = successor - 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.m57.hermescontrol.data.ws.contract.CommandDispatchParams
|
||||
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||
import com.m57.hermescontrol.data.ws.contract.FileAttachParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
||||
@@ -1511,6 +1512,10 @@ class ChatViewModel(
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
|
||||
is ReducerEffect.DeleteLocalMessage -> {
|
||||
viewModelScope.launch(ioDispatcher) { repo.deleteMessage(effect.messageId) }
|
||||
}
|
||||
|
||||
is ReducerEffect.AttachHostMedia -> {
|
||||
// Issue #724: turn host-path MEDIA: directives into real
|
||||
// attachments (images inline, every other file tappable)
|
||||
@@ -1804,6 +1809,8 @@ class ChatViewModel(
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
sessionUsage = null,
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
)
|
||||
}
|
||||
publishPendingSends()
|
||||
@@ -3676,6 +3683,34 @@ class ChatViewModel(
|
||||
|
||||
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
|
||||
|
||||
/** Clipboard reads are asynchronous: bind them to this exact session generation and server. */
|
||||
internal fun captureAttachmentTarget(): ChatAttachmentTarget? {
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return null
|
||||
// Connection status is combined into public uiState, not written to _uiState.
|
||||
if (wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ||
|
||||
!state.isSessionReady || _timelineState.value.isHistorical
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return ChatAttachmentTarget(
|
||||
sessionId = sessionId,
|
||||
generation = sessionGeneration,
|
||||
baseUrl = AuthManager.getBaseUrl(),
|
||||
connectionProfileId = AuthManager.getSelectedProfileId(),
|
||||
agentProfileId = AuthManager.activeProfileId.value,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun addPastedAttachments(
|
||||
target: ChatAttachmentTarget,
|
||||
attachments: List<Attachment>,
|
||||
): Boolean {
|
||||
if (captureAttachmentTarget() != target) return false
|
||||
attachmentsDelegate.addAttachments(attachments)
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
||||
|
||||
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
||||
@@ -4127,44 +4162,46 @@ class ChatViewModel(
|
||||
val arg = parts.getOrElse(1) { "" }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
// Primary path: command.dispatch handles quick/plugin/bundle/
|
||||
// skill commands + a few hardcoded ones. It returns a hard 4018
|
||||
// "not a ... command" for everything that lives only in the TUI
|
||||
// slash worker (the 29 commands that 4018'd on mobile — issue
|
||||
// #576). For those we fall back to slash.exec, which runs the
|
||||
// full COMMAND_REGISTRY through the worker.
|
||||
// Desktop parity: slash.exec owns generic commands. Handle errors here,
|
||||
// not again through the shared RpcError banner (/usage regression).
|
||||
val result =
|
||||
wsClient.call(
|
||||
RpcMethods.COMMAND_DISPATCH,
|
||||
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
||||
RpcMethods.SLASH_EXEC,
|
||||
SlashExecParams(sessionId = sessionId, command = command.removePrefix("/")),
|
||||
suppressErrorEvent = true,
|
||||
)
|
||||
handleDispatchResult(result.toAny())
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
val msg = e.message.orEmpty()
|
||||
// Registry miss on command.dispatch: the backend emits exactly
|
||||
// "not a quick/plugin/bundle/skill command: <name>" (tui_gateway
|
||||
// server.py L12408). Match that precise phrase so unrelated
|
||||
// errors can't accidentally trigger the slash.exec fallback.
|
||||
if (msg.contains("not a quick/plugin/bundle/skill command")) {
|
||||
// Registry miss on command.dispatch -> retry via slash.exec,
|
||||
// which routes the full CLI command set through the worker.
|
||||
try {
|
||||
val result =
|
||||
wsClient.call(
|
||||
RpcMethods.SLASH_EXEC,
|
||||
SlashExecParams(
|
||||
sessionId = sessionId,
|
||||
command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}",
|
||||
),
|
||||
)
|
||||
val output = (result.toAny() as? Map<*, *>)?.get("output") as? String
|
||||
if (!output.isNullOrBlank()) addAssistantMessage(output)
|
||||
} catch (e2: HermesWsClient.HermesRpcException) {
|
||||
addAssistantMessage("/$name: ${e2.message}")
|
||||
}
|
||||
val map = result.toAny() as? Map<*, *>
|
||||
if (map?.get("type") is String) {
|
||||
handleDispatchResult(map)
|
||||
} else {
|
||||
// Legit error from command.dispatch (busy, no history, etc.)
|
||||
addAssistantMessage("/$name: ${e.message}")
|
||||
val output = (map?.get("output") as? String).orEmpty().ifBlank { "/$name: no output" }
|
||||
val warning = (map?.get("warning") as? String).orEmpty()
|
||||
addAssistantMessage(if (warning.isBlank()) output else "warning: $warning\n$output")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
val result =
|
||||
wsClient.call(
|
||||
RpcMethods.COMMAND_DISPATCH,
|
||||
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
||||
suppressErrorEvent = true,
|
||||
)
|
||||
val map = result.toAny() as? Map<*, *>
|
||||
if (map?.get("type") is String) {
|
||||
handleDispatchResult(map)
|
||||
} else {
|
||||
addAssistantMessage("/$name: invalid response: command.dispatch")
|
||||
}
|
||||
} catch (fallback: CancellationException) {
|
||||
throw fallback
|
||||
} catch (fallback: Exception) {
|
||||
val registryMiss =
|
||||
Regex("not a quick/plugin/(?:bundle/)?skill command", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(fallback.message.orEmpty())
|
||||
val failure = if (registryMiss) e else fallback
|
||||
addAssistantMessage("/$name: ${failure.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4534,7 +4571,7 @@ class ChatViewModel(
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
RpcMethods.SESSION_CREATE,
|
||||
SessionCreateParams(source = "desktop"),
|
||||
SessionCreateParams(source = DESKTOP_SESSION_SOURCE),
|
||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
||||
)
|
||||
}
|
||||
@@ -5070,7 +5107,22 @@ class ChatViewModel(
|
||||
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
||||
val valid = { isCurrent() && cacheCursor == before }
|
||||
if (!valid()) return false
|
||||
mergeHistoryPage(valid, cached = true) { page.messages } ?: return false
|
||||
mergeHistoryPage(valid, cached = true) {
|
||||
// #1432: Room retains host references, not connection-bound download URLs.
|
||||
page.messages.map { message ->
|
||||
if (message.role == MessageRole.USER && message.attachments.isNullOrEmpty()) {
|
||||
message.copy(
|
||||
attachments =
|
||||
userImageAttachments(
|
||||
message.content,
|
||||
::gatewayMediaUrl,
|
||||
).takeIf { it.isNotEmpty() },
|
||||
)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
} ?: return false
|
||||
if (!valid()) return false
|
||||
cacheCursor = page.cursor
|
||||
cacheHasOlder = page.hasOlder
|
||||
@@ -5216,7 +5268,11 @@ class ChatViewModel(
|
||||
val scope = sendScope()
|
||||
val scopedPending =
|
||||
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
||||
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend)
|
||||
val pageRowIds = page.mapNotNull { it.serverRowId }.toSet()
|
||||
(
|
||||
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending) +
|
||||
pendingSendIdsConfirmedByRowIds(pageRowIds, scopedPending)
|
||||
).forEach(::removePendingSend)
|
||||
publishPendingSends()
|
||||
drainPendingQueue()
|
||||
}
|
||||
@@ -5324,6 +5380,9 @@ class ChatViewModel(
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
sessionUsage = null,
|
||||
// #1433: compaction state belongs to the session being left.
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
pendingAttachments = emptyList(),
|
||||
composerTextToRestore = null,
|
||||
reactionKind = null,
|
||||
@@ -5352,6 +5411,7 @@ class ChatViewModel(
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = sessionId,
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
omitMessages = true,
|
||||
profile = profile?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
@@ -5709,7 +5769,8 @@ class ChatViewModel(
|
||||
persistHistoryPage(page, sessionId)
|
||||
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
|
||||
unverifiedAccepted.forEach { receipt ->
|
||||
if (sendStore.all().any { it == receipt }) {
|
||||
// A gateway-issued user_row_id means the row is stored: never walk back to UNKNOWN.
|
||||
if (canDemoteAcceptedReceipt(receipt) && sendStore.all().any { it == receipt }) {
|
||||
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ object ChatWsEventReducer {
|
||||
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
||||
is WsEvent.VaultCodeRequest -> event.sessionId
|
||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||
is WsEvent.StatusUpdate -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
||||
@@ -457,6 +458,7 @@ object ChatWsEventReducer {
|
||||
): ReducerResult {
|
||||
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
||||
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
||||
if (GatewayNotice.isInterruptSentinel(event.text)) return onInterruptNotice(state, streamingState, event)
|
||||
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
||||
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
||||
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
||||
@@ -1052,36 +1054,107 @@ object ChatWsEventReducer {
|
||||
event: WsEvent.StatusUpdate,
|
||||
): ReducerResult {
|
||||
val kind = event.data?.get("kind") as? String
|
||||
val text = event.data?.get("text") as? String
|
||||
val text = (event.data?.get("text") as? String)?.trim()
|
||||
return when (kind) {
|
||||
// In-progress compaction is live state: the tail indicator shows only while it runs.
|
||||
"compressing", "compacting" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = true,
|
||||
compressionStatus = text ?: "⏳ Compressing context...",
|
||||
compressionStatus = text?.ifBlank { null } ?: "⏳ Compressing context...",
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// #1433: the done edge is a transcript event, seated where it happened, never a floating tail row.
|
||||
"compacted" -> {
|
||||
appendGatewayNotice(state.copy(isCompressing = false, compressionStatus = null), streamingState, text)
|
||||
}
|
||||
|
||||
"ready" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = false,
|
||||
compressionStatus = text,
|
||||
),
|
||||
state = state.copy(isCompressing = false, compressionStatus = null),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// Durable notices the desktop/TUI also paint into the transcript.
|
||||
"goal", "loop", "warn", "fallback" -> {
|
||||
appendGatewayNotice(state, streamingState, text)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ReducerResult(state = state, streamingState = streamingState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a gateway notice as a persisted SYSTEM row (#1433). SYSTEM rows are permanently local, so
|
||||
* `inTranscriptOrder` anchors them after their preceding confirmed row instead of the transcript tail.
|
||||
* A replayed event (reconnect `session.events.since`) re-sends the same text within the current turn;
|
||||
* skip it rather than stacking a duplicate.
|
||||
*/
|
||||
private fun appendGatewayNotice(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
text: String?,
|
||||
extraEffects: List<ReducerEffect> = emptyList(),
|
||||
): ReducerResult {
|
||||
if (text.isNullOrBlank()) {
|
||||
return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||
}
|
||||
val turnStart = state.messages.indexOfLast { it.isUserTurnBoundary() } + 1
|
||||
val duplicate =
|
||||
state.messages.drop(turnStart).any { it.role == MessageRole.SYSTEM && it.content == text }
|
||||
if (duplicate) return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||
val notice = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||
val sid = state.currentSessionId
|
||||
return ReducerResult(
|
||||
state = state.copy(messages = state.messages + notice),
|
||||
streamingState = streamingState,
|
||||
effects = extraEffects + listOfNotNull(sid?.let { ReducerEffect.PersistMessage(notice, it) }),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn whose final text is a gateway interrupt sentinel ends with a notice, not assistant prose (#1433).
|
||||
* For the repetition-loop sentinel the gateway hides the looped partial; drop it here too, including a
|
||||
* copy already sealed by Stop, or it lingers as a live-only row that jumps to the tail on every merge.
|
||||
*/
|
||||
private fun onInterruptNotice(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.MessageComplete,
|
||||
): ReducerResult {
|
||||
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
|
||||
val text = event.text.trim()
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
var messages = usageState.messages
|
||||
if (text == GatewayNotice.REPETITION_LOOP_INTERRUPTED) {
|
||||
val looped = setOfNotNull(streamingState.interruptedMessage?.id, streamingState.streamingMessage?.id)
|
||||
if (looped.isNotEmpty()) {
|
||||
messages = messages.filterNot { it.id in looped }
|
||||
looped.forEach { effects += ReducerEffect.DeleteLocalMessage(it) }
|
||||
}
|
||||
} else {
|
||||
// Other exits keep any real partial the user already saw.
|
||||
streamingState.streamingMessage
|
||||
?.takeIf { it.content.isNotBlank() || it.reasoningText.isNotBlank() }
|
||||
?.copy(isStreaming = false, finishTimestamp = System.currentTimeMillis())
|
||||
?.let { partial ->
|
||||
messages = messages.upsertById(partial)
|
||||
usageState.currentSessionId?.let { effects += ReducerEffect.PersistMessage(partial, it) }
|
||||
}
|
||||
}
|
||||
effects += ReducerEffect.RefreshSessions
|
||||
effects += ReducerEffect.RefreshContextUsage
|
||||
val settled = usageState.copy(messages = messages, isAgentTyping = false, clarifyRequest = null)
|
||||
return appendGatewayNotice(settled, StreamingState(), text, effects)
|
||||
}
|
||||
|
||||
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
|
||||
private fun onMessageReaction(
|
||||
state: ChatUiState,
|
||||
@@ -1293,6 +1366,11 @@ sealed class ReducerEffect {
|
||||
|
||||
data object RefreshContextUsage : ReducerEffect()
|
||||
|
||||
/** Drop a never-confirmed local row from Room (the DAO refuses rows that carry a REST identity). */
|
||||
data class DeleteLocalMessage(
|
||||
val messageId: String,
|
||||
) : ReducerEffect()
|
||||
|
||||
/**
|
||||
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
|
||||
* just-finalized assistant message into real [Attachment]s (images inline,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/**
|
||||
* Gateway turn-outcome sentinels that arrive as assistant text but are notices, not prose (#1433).
|
||||
*
|
||||
* Sources in hermes-agent: `agent/repetition_guard.py` (`REPETITION_LOOP_INTERRUPTED`) and the
|
||||
* `interrupt_text` / `close_interrupted_tool_sequence` exits ("Operation interrupted…"). They have no
|
||||
* matching visible REST row (or a model-facing closing row), so as assistant prose they never get a
|
||||
* canonical position and jump to the transcript tail on every merge. They render as system rows instead.
|
||||
*/
|
||||
internal object GatewayNotice {
|
||||
const val REPETITION_LOOP_INTERRUPTED = "[the reply degenerated into a repetition loop and was interrupted]"
|
||||
|
||||
// "Operation interrupted." / "Operation interrupted: <reason>." / "Operation interrupted during retry (…)."
|
||||
private val OPERATION_INTERRUPTED = Regex("""^Operation interrupted(?:\.|(?::| during) [^\n]*\.)$""")
|
||||
|
||||
fun isInterruptSentinel(text: String): Boolean {
|
||||
val trimmed = text.trim()
|
||||
return trimmed == REPETITION_LOOP_INTERRUPTED || OPERATION_INTERRUPTED.matches(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway persists an interrupted turn's closing row as `role=assistant` with the sentinel text. Map it
|
||||
* (REST and Room alike) to the same SYSTEM notice the live path appends, so every copy matches one row.
|
||||
*/
|
||||
internal fun ChatMessage.normalizedGatewayNotice(): ChatMessage =
|
||||
if (role == MessageRole.ASSISTANT && attachments.isNullOrEmpty() && GatewayNotice.isInterruptSentinel(content)) {
|
||||
copy(role = MessageRole.SYSTEM, content = content.trim(), reasoningText = "", completionId = null)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
@@ -106,7 +106,7 @@ sealed class SlashResult {
|
||||
/** Create a new session (client-side immediate). */
|
||||
data object NewSession : SlashResult()
|
||||
|
||||
/** Forward to command.dispatch via WebSocket. */
|
||||
/** Execute through slash.exec, with command.dispatch as the desktop-compatible fallback. */
|
||||
data object RpcDispatch : SlashResult()
|
||||
|
||||
/** Fork the active conversation via the session.branch WebSocket RPC. */
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -58,6 +60,7 @@ import com.m57.hermescontrol.theme.DarkOnSurface
|
||||
import com.m57.hermescontrol.theme.LightOnSurface
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -100,18 +103,36 @@ fun UserBubble(
|
||||
var copied by rememberCopyFeedback()
|
||||
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val deliveryState =
|
||||
when {
|
||||
pendingSendState == PendingSendState.SENDING -> DeliveryState.SENT
|
||||
|
||||
pendingSendState == PendingSendState.ACCEPTED ||
|
||||
message.canonicalRestId != null || message.serverRowId != null -> DeliveryState.DELIVERED
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
// #1432: also protect cached/legacy rows and unexpectedly large plain-string payloads.
|
||||
// Keep the original content for Copy; only the layout input is bounded.
|
||||
val displayContent =
|
||||
remember(message.content, message.attachments) {
|
||||
// #1432: the path is only hidden when its image renders as an attachment below.
|
||||
val visible =
|
||||
if (message.attachments.isNullOrEmpty()) message.content else hideImageRefLines(message.content)
|
||||
ToolJson.clampForDisplay(visible)
|
||||
}
|
||||
val highlightedText =
|
||||
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
||||
remember(displayContent, searchQuery, isCurrentMatch, statusColors) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
buildHighlightedString(
|
||||
message.content,
|
||||
displayContent,
|
||||
searchQuery,
|
||||
isCurrentMatch,
|
||||
statusColors,
|
||||
)
|
||||
} else {
|
||||
AnnotatedString(message.content)
|
||||
AnnotatedString(displayContent)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -153,7 +174,7 @@ fun UserBubble(
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
||||
val isRtl = remember(displayContent) { BidiUtils.isRtlText(displayContent) }
|
||||
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
@@ -183,23 +204,6 @@ fun UserBubble(
|
||||
canSave = canSaveAttachment,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
// #1427: keep the live text and media visible with an honest delivery label.
|
||||
if (pendingSendState == PendingSendState.SENDING ||
|
||||
pendingSendState == PendingSendState.ACCEPTED
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (pendingSendState == PendingSendState.SENDING) {
|
||||
R.string.chat_pending_sending
|
||||
} else {
|
||||
R.string.chat_send_accepted
|
||||
},
|
||||
),
|
||||
color = userBubbleTextColor,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.testTag("user_send_status_${message.id}"),
|
||||
)
|
||||
}
|
||||
if (!message.isStreaming) {
|
||||
FlowRow(
|
||||
modifier =
|
||||
@@ -236,6 +240,11 @@ fun UserBubble(
|
||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
DeliveryChecks(
|
||||
state = deliveryState,
|
||||
tint = userBubbleTextColor,
|
||||
messageId = message.id,
|
||||
)
|
||||
if (messageStatsEnabled && showUserMessageTokens &&
|
||||
message.tokenCount != null && message.tokenCount > 0
|
||||
) {
|
||||
@@ -268,3 +277,24 @@ fun UserBubble(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** WhatsApp-style delivery ticks: one dim check while sending, two once the server has the prompt. */
|
||||
private enum class DeliveryState { SENT, DELIVERED }
|
||||
|
||||
@Composable
|
||||
private fun DeliveryChecks(
|
||||
state: DeliveryState?,
|
||||
tint: Color,
|
||||
messageId: String,
|
||||
) {
|
||||
if (state == null) return
|
||||
val delivered = state == DeliveryState.DELIVERED
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = if (delivered) Icons.Filled.DoneAll else Icons.Filled.Done,
|
||||
contentDescription =
|
||||
stringResource(if (delivered) R.string.chat_send_accepted else R.string.chat_pending_sending),
|
||||
modifier = Modifier.size(14.dp).testTag("user_send_status_$messageId"),
|
||||
tint = tint.copy(alpha = if (delivered) 1f else 0.6f),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
|
||||
/**
|
||||
* Parses user-authored or server-persisted `@image:` directives from message content into
|
||||
* gateway-backed [Attachment]s.
|
||||
*
|
||||
* Requirements:
|
||||
* - Recognizes whole trimmed lines `@image:/absolute/path` (spaces in path allowed).
|
||||
* - Ignores inline prose refs (e.g. "look at @image:/path in the middle of a sentence").
|
||||
* - Ignores empty or relative paths (must start with '/').
|
||||
* - Preserves unique paths across lines (deduplicates repeated references to the same path).
|
||||
* - Resolves each path via [mediaUrl]; drops attachments where [mediaUrl] returns null.
|
||||
* - Constructs [Attachment]s with source = [AttachmentSource.GATEWAY], resolved uri/gatewayUrl,
|
||||
* size = 0, name = [mediaNameFromPath], mimeType = [mediaMimeForPath].
|
||||
*/
|
||||
internal fun userImageAttachments(
|
||||
content: String,
|
||||
mediaUrl: (String) -> String?,
|
||||
): List<Attachment> {
|
||||
if (!content.contains("@image:")) return emptyList()
|
||||
|
||||
val seenPaths = mutableSetOf<String>()
|
||||
val attachments = mutableListOf<Attachment>()
|
||||
|
||||
for (rawLine in content.lines()) {
|
||||
val line = rawLine.trim()
|
||||
if (!line.startsWith("@image:")) continue
|
||||
val path = line.removePrefix("@image:").trim()
|
||||
if (!path.startsWith("/")) continue
|
||||
if (!seenPaths.add(path)) continue
|
||||
|
||||
val url = mediaUrl(path) ?: continue
|
||||
attachments.add(
|
||||
Attachment(
|
||||
uri = url,
|
||||
name = mediaNameFromPath(path),
|
||||
mimeType = mediaMimeForPath(path),
|
||||
size = 0,
|
||||
gatewayUrl = url,
|
||||
source = AttachmentSource.GATEWAY,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
/** Hides whole-line `@image:/abs/path` refs from bubble text once they render as attachments. */
|
||||
internal fun hideImageRefLines(content: String): String {
|
||||
if (!content.contains("@image:")) return content
|
||||
return content
|
||||
.lines()
|
||||
.filterNot { line ->
|
||||
line.trim().let {
|
||||
it.startsWith("@image:") &&
|
||||
it.removePrefix("@image:").trim().startsWith("/")
|
||||
}
|
||||
}.joinToString("\n")
|
||||
.trim()
|
||||
}
|
||||
@@ -10,8 +10,11 @@ import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.content.ReceiveContentListener
|
||||
import androidx.compose.foundation.content.contentReceiver
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -32,6 +35,9 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -64,8 +71,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -86,9 +94,9 @@ import com.m57.hermescontrol.util.BidiUtils
|
||||
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ChatInputBar(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputChange: (TextFieldValue) -> Unit,
|
||||
inputState: TextFieldState,
|
||||
onSend: () -> Unit,
|
||||
onMicTap: () -> Unit,
|
||||
isListening: Boolean,
|
||||
@@ -127,15 +135,18 @@ fun ChatInputBar(
|
||||
isRecordingVoice: Boolean = false,
|
||||
amplitudeProvider: () -> Float = { 0f },
|
||||
onStopGeneration: () -> Unit = {},
|
||||
receiveContentListener: ReceiveContentListener? = null,
|
||||
isReceivingContent: Boolean = false,
|
||||
) {
|
||||
val inputText = inputState.text.toString()
|
||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||
val canSend =
|
||||
pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
!isReceivingContent && pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
@@ -232,6 +243,16 @@ fun ChatInputBar(
|
||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||
if (isReceivingContent) {
|
||||
val loadingLabel = stringResource(R.string.chat_image_paste_loading)
|
||||
LinearProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("chat_image_paste_progress")
|
||||
.semantics { contentDescription = loadingLabel },
|
||||
)
|
||||
}
|
||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||
// TUI-only commands that don't function on mobile (issue #574).
|
||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||
@@ -245,13 +266,13 @@ fun ChatInputBar(
|
||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
||||
visible = inputText.startsWith("/") && !inputText.contains(" "),
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
commandNames.filter { it.startsWith(inputText, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
@@ -280,7 +301,7 @@ fun ChatInputBar(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
||||
onClick = { inputState.replaceComposerDraft(cmd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +362,7 @@ fun ChatInputBar(
|
||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||
}
|
||||
|
||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||
ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping) -> {
|
||||
stringResource(R.string.chat_input_placeholder_queue)
|
||||
}
|
||||
|
||||
@@ -356,9 +377,9 @@ fun ChatInputBar(
|
||||
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
remember(inputText, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(
|
||||
inputFieldValue.text,
|
||||
inputText,
|
||||
fallback = ambientLayoutDirection,
|
||||
)
|
||||
}
|
||||
@@ -381,8 +402,7 @@ fun ChatInputBar(
|
||||
.heightIn(min = 42.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
|
||||
state = inputState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -392,18 +412,28 @@ fun ChatInputBar(
|
||||
.focusRequester(inputFocusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
inputFocused = focusState.isFocused
|
||||
}.testTag("chat_input"),
|
||||
}.testTag("chat_input")
|
||||
.then(
|
||||
if (receiveContentListener != null) {
|
||||
Modifier.contentReceiver(receiveContentListener)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
enabled = isConnected,
|
||||
inputTransformation =
|
||||
InputTransformation {
|
||||
if (isRecordingVoice) revertAllChanges()
|
||||
},
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 8),
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
decorator = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
@@ -416,7 +446,7 @@ fun ChatInputBar(
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
|
||||
if (inputText.isEmpty() && !isRecordingVoice) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||
|
||||
/** Apply an intentional external draft replacement, never a value mirror during ordinary typing. */
|
||||
internal fun TextFieldState.replaceComposerDraft(text: String) {
|
||||
val replacement = ChatInputPolicy.commandFieldValue(text)
|
||||
edit {
|
||||
replace(0, length, replacement.text)
|
||||
selection = replacement.selection
|
||||
}
|
||||
}
|
||||
|
||||
/** Recheck the live draft when a rejected send is returned; it may have changed since dispatch. */
|
||||
internal fun TextFieldState.restoreRejectedComposerDraft(rejectedText: String) {
|
||||
val current = TextFieldValue(text.toString(), selection)
|
||||
val restored = ChatInputPolicy.restoreRejectedText(rejectedText, current)
|
||||
edit {
|
||||
replace(0, length, restored.text)
|
||||
selection = restored.selection
|
||||
}
|
||||
}
|
||||
|
||||
/** Speech results arrive asynchronously: append to the draft as it exists at result time. */
|
||||
internal fun TextFieldState.appendSpeechComposerDraft(spokenText: String) {
|
||||
if (spokenText.isBlank()) return
|
||||
val current = text.toString()
|
||||
replaceComposerDraft(if (current.isBlank()) spokenText else "$current $spokenText")
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.content.MediaType
|
||||
import androidx.compose.foundation.content.ReceiveContentListener
|
||||
import androidx.compose.foundation.content.TransferableContent
|
||||
import androidx.compose.foundation.content.consume
|
||||
import androidx.compose.foundation.content.hasMediaType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.MAX_CHAT_ATTACHMENT_BYTES
|
||||
import com.m57.hermescontrol.ui.chat.PastedImage
|
||||
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
|
||||
internal const val MAX_PASTED_IMAGE_COUNT = 8
|
||||
internal const val MAX_PASTED_IMAGE_BATCH_BYTES = 20L * 1024L * 1024L
|
||||
|
||||
private class PastedImageBatchLimitException : IOException("Pasted image batch exceeds its limit")
|
||||
|
||||
/** Receives explicit paste/IME commits only; never reads or observes the system clipboard. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal class ChatImagePasteController(
|
||||
private val scope: CoroutineScope,
|
||||
private val importImage: suspend (Uri, Long) -> PastedImage?,
|
||||
private val captureTarget: () -> ChatAttachmentTarget?,
|
||||
private val commit: (ChatAttachmentTarget, List<Attachment>) -> Boolean,
|
||||
private val onError: (Int) -> Unit,
|
||||
private val isImageUri: (Uri) -> Boolean = { false },
|
||||
) : ReceiveContentListener {
|
||||
var isReceiving by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var closed = false
|
||||
private var job: Job? = null
|
||||
private val retainedPayloads = mutableSetOf<Any>()
|
||||
|
||||
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
|
||||
if (!transferableContent.hasMediaType(MediaType.Image)) return transferableContent
|
||||
// Drag grants have a different lifecycle; this feature handles paste and IME commits only.
|
||||
if (transferableContent.source != TransferableContent.Source.Clipboard &&
|
||||
transferableContent.source != TransferableContent.Source.Keyboard
|
||||
) {
|
||||
return transferableContent
|
||||
}
|
||||
val clip = transferableContent.clipEntry.clipData
|
||||
val uris = ArrayList<Uri>(MAX_PASTED_IMAGE_COUNT + 1)
|
||||
for (index in 0 until clip.itemCount) {
|
||||
val uri = clip.getItemAt(index).uri
|
||||
if (uri?.scheme == "content" && isImageUri(uri)) {
|
||||
uris.add(uri)
|
||||
if (uris.size > MAX_PASTED_IMAGE_COUNT) break
|
||||
}
|
||||
}
|
||||
if (!receiveImages(uris, transferableContent)) return transferableContent
|
||||
// Only accepted image candidates are consumed. Text and non-image URIs
|
||||
// remain available to the ordinary text field/other receivers.
|
||||
val accepted = uris.toSet()
|
||||
// An item can carry both image URI and text. Keep that item for the text
|
||||
// field after importing its image; consuming it would drop its text.
|
||||
return transferableContent.consume { it.uri in accepted && it.text == null && it.htmlText == null }
|
||||
}
|
||||
|
||||
/** [payload] strongly retains Compose's temporary InputContentInfo permission until IO ends. */
|
||||
internal fun receiveImages(
|
||||
uris: List<Uri>,
|
||||
payload: Any,
|
||||
): Boolean {
|
||||
if (closed || !scope.isActive || uris.isEmpty()) return false
|
||||
if (isReceiving) {
|
||||
onError(R.string.chat_image_paste_busy)
|
||||
return true
|
||||
}
|
||||
if (uris.size > MAX_PASTED_IMAGE_COUNT) {
|
||||
onError(R.string.chat_image_paste_batch_limit)
|
||||
return true
|
||||
}
|
||||
val target = captureTarget()
|
||||
if (target == null) {
|
||||
onError(R.string.chat_session_not_ready)
|
||||
return true
|
||||
}
|
||||
isReceiving = true
|
||||
retainedPayloads.add(payload)
|
||||
job =
|
||||
scope.launch {
|
||||
val images = mutableListOf<PastedImage>()
|
||||
var remainingBytes = MAX_PASTED_IMAGE_BATCH_BYTES
|
||||
var committed = false
|
||||
try {
|
||||
for (uri in uris) {
|
||||
if (remainingBytes == 0L) throw PastedImageBatchLimitException()
|
||||
val cap = minOf(remainingBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||
val image = importImage(uri, cap) ?: throw IOException("Unreadable pasted image")
|
||||
images += image
|
||||
remainingBytes -= image.file.length()
|
||||
if (remainingBytes < 0L) throw PastedImageBatchLimitException()
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (!closed && captureTarget() == target) {
|
||||
committed = commit(target, images.map { it.attachment })
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (failure: Exception) {
|
||||
if (!closed && captureTarget() == target) {
|
||||
onError(
|
||||
if (failure is PastedImageBatchLimitException ||
|
||||
(failure is PastedImageTooLargeException && remainingBytes < MAX_CHAT_ATTACHMENT_BYTES)
|
||||
) {
|
||||
R.string.chat_image_paste_batch_limit
|
||||
} else if (failure is PastedImageTooLargeException) {
|
||||
R.string.chat_image_paste_too_large
|
||||
} else {
|
||||
R.string.chat_image_paste_failed
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!committed) {
|
||||
withContext(NonCancellable + Dispatchers.IO) { images.forEach { it.discard() } }
|
||||
}
|
||||
retainedPayloads.remove(payload)
|
||||
isReceiving = false
|
||||
}
|
||||
}
|
||||
// Covers a scope cancelled before the launched coroutine ever starts.
|
||||
job?.invokeOnCompletion { retainedPayloads.remove(payload) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun rememberChatImagePaste(
|
||||
viewModel: ChatViewModel,
|
||||
onShowMessage: (String) -> Unit,
|
||||
): ChatImagePasteController {
|
||||
val context = LocalContext.current
|
||||
val resources by rememberUpdatedState(LocalResources.current)
|
||||
val scope = rememberCoroutineScope()
|
||||
val showMessage by rememberUpdatedState(onShowMessage)
|
||||
val controller =
|
||||
remember(viewModel, context, scope) {
|
||||
val store = ChatPastedImageStore(context.applicationContext)
|
||||
ChatImagePasteController(
|
||||
scope = scope,
|
||||
importImage = store::importImage,
|
||||
isImageUri = { uri ->
|
||||
try {
|
||||
context.contentResolver.getType(uri)?.let { type ->
|
||||
type.startsWith("image/", ignoreCase = true) && type.length > "image/".length
|
||||
} == true
|
||||
} catch (_: RuntimeException) {
|
||||
// Provider failures must never consume an unclassified URI.
|
||||
false
|
||||
}
|
||||
},
|
||||
captureTarget = viewModel::captureAttachmentTarget,
|
||||
commit = viewModel::addPastedAttachments,
|
||||
onError = { showMessage(resources.getString(it)) },
|
||||
)
|
||||
}
|
||||
DisposableEffect(controller) { onDispose { controller.close() } }
|
||||
return controller
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import android.speech.RecognizerIntent
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -21,13 +22,11 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
||||
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -55,8 +54,7 @@ class ChatMediaLaunchers(
|
||||
|
||||
@Composable
|
||||
fun rememberChatMediaLaunchers(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
||||
inputState: TextFieldState,
|
||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||
onAddAttachments: (List<Attachment>) -> Unit,
|
||||
onVoiceNoteRecorded: (file: File) -> Unit,
|
||||
@@ -68,8 +66,7 @@ fun rememberChatMediaLaunchers(
|
||||
var isListening by remember { mutableStateOf(false) }
|
||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
|
||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
||||
val currentInputState by rememberUpdatedState(inputState)
|
||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
||||
@@ -134,14 +131,7 @@ fun rememberChatMediaLaunchers(
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
if (spokenText.isNotBlank()) {
|
||||
val currentText = currentInputFieldValue.text
|
||||
val merged =
|
||||
if (currentText.isBlank()) {
|
||||
spokenText
|
||||
} else {
|
||||
"$currentText $spokenText"
|
||||
}
|
||||
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
|
||||
currentInputState.appendSpeechComposerDraft(spokenText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,23 @@ private object FullBleedContentType {
|
||||
const val SYSTEM_EVENT: String = "system_event"
|
||||
}
|
||||
|
||||
/** Only show the placeholder when there are no messages or renderable live tail items. */
|
||||
internal fun shouldShowChatEmptyState(
|
||||
transcript: TranscriptUiState,
|
||||
hasReplyError: Boolean,
|
||||
): Boolean =
|
||||
transcript.messages.isEmpty() &&
|
||||
transcript.streamingState.streamingMessage == null &&
|
||||
!transcript.isLoading &&
|
||||
!transcript.isAgentTyping &&
|
||||
!hasReplyError &&
|
||||
transcript.clarifyRequest == null &&
|
||||
transcript.vaultUnlockPrompt == null &&
|
||||
transcript.vaultSaveLoginPrompt == null &&
|
||||
transcript.vaultCodePrompt == null &&
|
||||
!transcript.isCompressing &&
|
||||
transcript.compressionStatus == null
|
||||
|
||||
/**
|
||||
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
||||
* surface since the bubble renderer was removed. User messages keep their
|
||||
@@ -105,7 +122,7 @@ fun FullBleedChatList(
|
||||
val isCompressing = transcript.isCompressing
|
||||
val compressionStatus = transcript.compressionStatus
|
||||
val speakingMessageId = transcript.speakingMessageId
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
if (shouldShowChatEmptyState(transcript, hasReplyError = replyErrorContent != null)) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
@@ -60,6 +60,11 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
|
||||
displayKind == null &&
|
||||
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||
|
||||
private const val HIDDEN_TOOL_NAME = "react_to_message"
|
||||
|
||||
/** Tool rows that never get a transcript bubble. */
|
||||
internal fun ChatMessage.isHiddenTool(): Boolean = role == MessageRole.TOOL && toolName == HIDDEN_TOOL_NAME
|
||||
|
||||
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
|
||||
|
||||
/**
|
||||
@@ -108,6 +113,10 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
||||
agentEntries += AgentEntry.Prose(message)
|
||||
}
|
||||
|
||||
message.isHiddenTool() -> {
|
||||
// Reaction tool calls have no useful bubble; the reaction itself is the feedback.
|
||||
}
|
||||
|
||||
MessageRole.TOOL == message.role -> {
|
||||
agentEntries += AgentEntry.ToolRow(message)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user