Merge dev into main for v1.27.0
Promote verified dev to stable main for release v1.27.0.
This commit is contained in:
206 files changed
+29780
-10330
No files matched your search
@@ -34,6 +34,12 @@
|
||||
|
||||
---
|
||||
|
||||
## Support the project
|
||||
|
||||
If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7).
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"identityHash": "3deb0f3f202de9316222a02a0b5ae80e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "chat_messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionId",
|
||||
"columnName": "session_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reasoningText",
|
||||
"columnName": "reasoning_text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolName",
|
||||
"columnName": "tool_name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolCallId",
|
||||
"columnName": "tool_call_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolStatus",
|
||||
"columnName": "tool_status",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStreaming",
|
||||
"columnName": "is_streaming",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayKind",
|
||||
"columnName": "display_kind",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tokenCount",
|
||||
"columnName": "token_count",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tps",
|
||||
"columnName": "tps",
|
||||
"affinity": "REAL"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_chat_messages_session_id_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"session_id",
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3deb0f3f202de9316222a02a0b5ae80e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorStatus
|
||||
import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState
|
||||
import com.m57.hermescontrol.ui.chat.ConnectorActionState
|
||||
import com.m57.hermescontrol.ui.chat.ConnectorUiItem
|
||||
import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class SessionIntegrationsSheetTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testLoadingState_displaysLoadingIndicator() {
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loading,
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_loading").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnectedItem_displaysConnectedStatusAndReconnectAction() {
|
||||
var clickedSlug: String? = null
|
||||
var clickedReconnect: Boolean? = null
|
||||
|
||||
val item =
|
||||
ConnectorUiItem(
|
||||
slug = "github",
|
||||
name = "GitHub",
|
||||
isConnected = true,
|
||||
isEnabled = true,
|
||||
status = ConnectorStatus.CONNECTED,
|
||||
connectionStatus = "connected",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
|
||||
items = listOf(item),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { slug, reconnect ->
|
||||
clickedSlug = slug
|
||||
clickedReconnect = reconnect
|
||||
},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("GitHub").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_action_github").performClick()
|
||||
assertEquals("github", clickedSlug)
|
||||
assertEquals(true, clickedReconnect)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDisconnectedItem_displaysConnectActionAndTriggersCallback() {
|
||||
var clickedSlug: String? = null
|
||||
var clickedReconnect: Boolean? = null
|
||||
|
||||
val item =
|
||||
ConnectorUiItem(
|
||||
slug = "slack",
|
||||
name = "Slack",
|
||||
isConnected = false,
|
||||
isEnabled = true,
|
||||
status = ConnectorStatus.DISCONNECTED,
|
||||
connectionStatus = "disconnected",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
|
||||
items = listOf(item),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { slug, reconnect ->
|
||||
clickedSlug = slug
|
||||
clickedReconnect = reconnect
|
||||
},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_item_slack").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_action_slack").assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_action_slack").performClick()
|
||||
assertEquals("slack", clickedSlug)
|
||||
assertEquals(false, clickedReconnect)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnectingState_displaysSpinnerAndDisablesActions() {
|
||||
val item =
|
||||
ConnectorUiItem(
|
||||
slug = "google",
|
||||
name = "Google Drive",
|
||||
isConnected = false,
|
||||
isEnabled = true,
|
||||
status = ConnectorStatus.DISCONNECTED,
|
||||
connectionStatus = "disconnected",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
|
||||
items = listOf(item),
|
||||
actionState = ConnectorActionState.Connecting(slug = "google", isReconnect = false),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_spinner_google").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAwaitingAuthorizationState_displaysBannerAndRefreshStatus() {
|
||||
var refreshed = false
|
||||
val item =
|
||||
ConnectorUiItem(
|
||||
slug = "jira",
|
||||
name = "Jira",
|
||||
isConnected = false,
|
||||
isEnabled = true,
|
||||
status = ConnectorStatus.INITIATED,
|
||||
connectionStatus = "initiated",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
|
||||
items = listOf(item),
|
||||
actionState = ConnectorActionState.AwaitingAuthorization(slug = "jira"),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = { refreshed = true },
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_awaiting_banner").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").performClick()
|
||||
assertTrue(refreshed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnavailablePhase_displaysErrorMessage() {
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Unavailable("Connectors unavailable"),
|
||||
items = emptyList(),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorPhase_displaysRetryButtonAndTriggersRefresh() {
|
||||
var retryTriggered = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Error(ConnectorError.NetworkError(), canRetry = true),
|
||||
items = emptyList(),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = { retryTriggered = true },
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_retry_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_retry_button").performClick()
|
||||
assertTrue(retryTriggered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyState_displaysNoIntegrationsMessage() {
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
|
||||
items = emptyList(),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_empty").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNoSessionState_displaysNoActiveSession() {
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Initial,
|
||||
items = emptyList(),
|
||||
),
|
||||
sessionId = null,
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_no_session_state").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNoStorageButRuntimeSession_usesAuthoritativeRuntimeSessionId() {
|
||||
val item =
|
||||
ConnectorUiItem(
|
||||
slug = "linear",
|
||||
name = "Linear",
|
||||
isConnected = false,
|
||||
isEnabled = true,
|
||||
status = ConnectorStatus.DISCONNECTED,
|
||||
connectionStatus = "disconnected",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
|
||||
sessionId = "sess-runtime-999",
|
||||
items = listOf(item),
|
||||
),
|
||||
sessionId = null,
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_item_linear").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnsupportedBackend_displaysErrorMessage() {
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.UnsupportedBackend("Older server does not support connectors"),
|
||||
items = emptyList(),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnsupportedRuntime_displaysErrorMessage() {
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime("Runtime incompatible"),
|
||||
items = emptyList(),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNotOwner_displaysErrorMessage() {
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.NotOwner("Session owned by another user"),
|
||||
items = emptyList(),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExpiredAndRevokedItems_displayStatusAndAction() {
|
||||
val expiredItem =
|
||||
ConnectorUiItem(
|
||||
slug = "github",
|
||||
name = "GitHub",
|
||||
isConnected = false,
|
||||
isEnabled = true,
|
||||
status = ConnectorStatus.EXPIRED,
|
||||
connectionStatus = "expired",
|
||||
)
|
||||
val revokedItem =
|
||||
ConnectorUiItem(
|
||||
slug = "jira",
|
||||
name = "Jira",
|
||||
isConnected = false,
|
||||
isEnabled = true,
|
||||
status = ConnectorStatus.REVOKED,
|
||||
connectionStatus = "revoked",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SessionIntegrationsContent(
|
||||
uiState =
|
||||
ChatConnectorsUiState(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(available = true),
|
||||
items = listOf(expiredItem, revokedItem),
|
||||
),
|
||||
sessionId = "session-12345",
|
||||
onDismiss = {},
|
||||
onRefresh = {},
|
||||
onConnect = { _, _ -> },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_item_jira").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_integrations_action_jira").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class SubagentInspectionSheetTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testSubagentInspectionSheet_displaysSubagentAndTogglesTranscript() {
|
||||
var toggledSubagentId = ""
|
||||
val indicator =
|
||||
SubagentIndicator(
|
||||
type = "subagent.progress",
|
||||
subagentId = "sub-1089",
|
||||
goal = "Analyze server logs",
|
||||
status = "running",
|
||||
model = "claude-sonnet",
|
||||
durationSeconds = 12.4,
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SubagentInspectionSheet(
|
||||
indicators = listOf(indicator),
|
||||
inspectingSubagentId = null,
|
||||
subagentTranscript = null,
|
||||
onToggleTranscript = { toggledSubagentId = it },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("subagent_inspection_sheet").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Analyze server logs").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").performClick()
|
||||
assertEquals("sub-1089", toggledSubagentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSubagentInspectionSheet_displaysTranscriptContentAndTruncation() {
|
||||
val indicator =
|
||||
SubagentIndicator(
|
||||
type = "subagent.progress",
|
||||
subagentId = "sub-1089",
|
||||
goal = "Analyze server logs",
|
||||
status = "running",
|
||||
)
|
||||
val transcript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = "sub-1089",
|
||||
text = "Fetched 100 log lines successfully.",
|
||||
isTruncated = true,
|
||||
isLoading = false,
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SubagentInspectionSheet(
|
||||
indicators = listOf(indicator),
|
||||
inspectingSubagentId = "sub-1089",
|
||||
subagentTranscript = transcript,
|
||||
onToggleTranscript = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("subagent_transcript_container").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("subagent_transcript_text").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Fetched 100 log lines successfully.").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Rolling 16KB tail (older output truncated)").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSubagentInspectionSheet_displaysErrorAndTriggersRetry() {
|
||||
var retried = false
|
||||
val indicator =
|
||||
SubagentIndicator(
|
||||
type = "subagent.progress",
|
||||
subagentId = "sub-1089",
|
||||
goal = "Analyze server logs",
|
||||
status = "running",
|
||||
)
|
||||
val transcript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = "sub-1089",
|
||||
text = "",
|
||||
error = "Connection timeout",
|
||||
isLoading = false,
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
SubagentInspectionSheet(
|
||||
indicators = listOf(indicator),
|
||||
inspectingSubagentId = "sub-1089",
|
||||
subagentTranscript = transcript,
|
||||
onToggleTranscript = {},
|
||||
onRetryTranscript = { retried = true },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Connection timeout").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("subagent_transcript_retry").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("subagent_transcript_retry").performClick()
|
||||
assertEquals(true, retried)
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class VaultPromptCardsTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testVaultUnlockCard_interaction() {
|
||||
var confirmedPassword = ""
|
||||
var dismissed = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
VaultUnlockCard(
|
||||
prompt =
|
||||
VaultUnlockPromptUi(
|
||||
requestId = "req-1",
|
||||
sessionId = "sess-1",
|
||||
backend = "onepassword",
|
||||
displayName = "1Password",
|
||||
),
|
||||
onConfirm = { confirmedPassword = it },
|
||||
onDismiss = { dismissed = true },
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_unlock_card").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Unlock 1Password").assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_unlock_password_input").performTextInput("mypass123")
|
||||
composeTestRule.onNodeWithTag("vault_unlock_confirm").performClick()
|
||||
|
||||
assertEquals("mypass123", confirmedPassword)
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_unlock_dismiss").performClick()
|
||||
assertEquals(true, dismissed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVaultSaveLoginCard_interaction() {
|
||||
var savedIdentifier = ""
|
||||
var savedPassword = ""
|
||||
var dismissed = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
VaultSaveLoginCard(
|
||||
prompt =
|
||||
VaultSaveLoginPromptUi(
|
||||
requestId = "req-save",
|
||||
sessionId = "sess-1",
|
||||
origin = "https://github.com/login",
|
||||
site = "GitHub",
|
||||
),
|
||||
onConfirm = { id, pass ->
|
||||
savedIdentifier = id
|
||||
savedPassword = pass
|
||||
},
|
||||
onDismiss = { dismissed = true },
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_save_login_card").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("vault_save_login_identifier_input").performTextInput("testuser@example.com")
|
||||
composeTestRule.onNodeWithTag("vault_save_login_password_input").performTextInput("supersecret")
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_save_login_confirm").performClick()
|
||||
assertEquals("testuser@example.com", savedIdentifier)
|
||||
assertEquals("supersecret", savedPassword)
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_save_login_dismiss").performClick()
|
||||
assertEquals(true, dismissed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVaultCodeCard_interaction() {
|
||||
var submittedCode = ""
|
||||
var dismissed = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
VaultCodeCard(
|
||||
prompt =
|
||||
VaultCodePromptUi(
|
||||
requestId = "req-code",
|
||||
sessionId = "sess-1",
|
||||
site = "GitHub",
|
||||
hint = "SMS verification to ***1234",
|
||||
),
|
||||
onConfirm = { submittedCode = it },
|
||||
onDismiss = { dismissed = true },
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_code_card").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("SMS verification to ***1234").assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_code_input").performTextInput("654321")
|
||||
composeTestRule.onNodeWithTag("vault_code_confirm").performClick()
|
||||
assertEquals("654321", submittedCode)
|
||||
|
||||
composeTestRule.onNodeWithTag("vault_code_dismiss").performClick()
|
||||
assertEquals(true, dismissed)
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionCard
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class SessionLiveStatusIndicatorTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun workingStatus_displaysRunningIndicator() {
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = SessionLiveStatus.WORKING,
|
||||
sessionId = "sess-working",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_live_status_sess-working").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Running").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waitingStatus_displaysNeedsInputIndicator() {
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = SessionLiveStatus.WAITING,
|
||||
sessionId = "sess-waiting",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_live_status_sess-waiting").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Needs input").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCard_withLiveStatus_displaysIndicator() {
|
||||
val session =
|
||||
SessionInfo(
|
||||
id = "sess-card-working",
|
||||
title = "Working session",
|
||||
message_count = 5,
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = "Working session",
|
||||
branchStem = null,
|
||||
isFork = false,
|
||||
forkDepth = 0,
|
||||
query = "",
|
||||
isSelecting = false,
|
||||
isSelected = false,
|
||||
isDeleting = false,
|
||||
isPinned = false,
|
||||
isHidden = false,
|
||||
liveStatus = SessionLiveStatus.WORKING,
|
||||
highlightBackground = Color.Transparent,
|
||||
highlightForeground = Color.Transparent,
|
||||
onCardClick = {},
|
||||
onToggleSelection = {},
|
||||
onSelect = {},
|
||||
onRename = {},
|
||||
onTogglePin = {},
|
||||
onToggleHide = {},
|
||||
onDelete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_card_sess-card-working").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithTag(
|
||||
"session_live_status_sess-card-working",
|
||||
useUnmergedTree = true,
|
||||
).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Running", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCard_withoutLiveStatus_hidesIndicator() {
|
||||
val session =
|
||||
SessionInfo(
|
||||
id = "sess-card-idle",
|
||||
title = "Idle session",
|
||||
message_count = 2,
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SessionCard(
|
||||
session = session,
|
||||
displayTitle = "Idle session",
|
||||
branchStem = null,
|
||||
isFork = false,
|
||||
forkDepth = 0,
|
||||
query = "",
|
||||
isSelecting = false,
|
||||
isSelected = false,
|
||||
isDeleting = false,
|
||||
isPinned = false,
|
||||
isHidden = false,
|
||||
liveStatus = null,
|
||||
highlightBackground = Color.Transparent,
|
||||
highlightForeground = Color.Transparent,
|
||||
onCardClick = {},
|
||||
onToggleSelection = {},
|
||||
onSelect = {},
|
||||
onRename = {},
|
||||
onTogglePin = {},
|
||||
onToggleHide = {},
|
||||
onDelete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_card_sess-card-idle").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("session_live_status_sess-card-idle").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchResultCard_withLiveStatus_displaysIndicator() {
|
||||
val session =
|
||||
SessionInfo(
|
||||
id = "sess-search-waiting",
|
||||
preview = "Searching something",
|
||||
source = "web",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
SearchResultCard(
|
||||
session = session,
|
||||
query = "Searching",
|
||||
isSelecting = false,
|
||||
isSelected = false,
|
||||
isDeleting = false,
|
||||
liveStatus = SessionLiveStatus.WAITING,
|
||||
highlightBackground = Color.Transparent,
|
||||
highlightForeground = Color.Transparent,
|
||||
onCardClick = {},
|
||||
onToggleSelection = {},
|
||||
onSelect = {},
|
||||
onRename = {},
|
||||
onDelete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("session_card_sess-search-waiting").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithTag(
|
||||
"session_live_status_sess-search-waiting",
|
||||
useUnmergedTree = true,
|
||||
).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Needs input", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsChatPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsLanguagePage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsVaultPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -137,6 +138,11 @@ private fun appEntryProvider(
|
||||
viewModel = viewModel { SettingsViewModel() },
|
||||
)
|
||||
}
|
||||
entry<SettingsVault> {
|
||||
SettingsVaultPage(
|
||||
onBack = { NavigationController.goBack() },
|
||||
)
|
||||
}
|
||||
entry<SettingsAbout> {
|
||||
SettingsAboutPage(
|
||||
onBack = { NavigationController.goBack() },
|
||||
|
||||
@@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object SettingsBehavior : NavKey
|
||||
|
||||
@Serializable data object SettingsVault : NavKey
|
||||
|
||||
@Serializable data object SettingsAbout : NavKey
|
||||
@@ -12,6 +12,8 @@ data class ServerStoreState(
|
||||
val port: Int = 9119,
|
||||
val baseUrl: String? = null,
|
||||
val autoReconnect: Boolean = true,
|
||||
val restoreLastSession: Boolean = false,
|
||||
val lastOpenedSessionIds: Map<String, String> = emptyMap(),
|
||||
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
||||
val useDynamicColors: Boolean = false,
|
||||
val themePreset: ThemePreset = ThemePreset.DEFAULT,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
@@ -500,6 +501,11 @@ object AuthManager {
|
||||
_baseUrlFlow.value = ""
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun setBaseUrlForTest(baseUrl: String) {
|
||||
_baseUrlFlow.value = baseUrl
|
||||
}
|
||||
|
||||
fun getToken(): String? {
|
||||
if (tokenInitialized) return cachedToken
|
||||
synchronized(this) {
|
||||
@@ -610,6 +616,47 @@ object AuthManager {
|
||||
serverStore.update { it.copy(autoReconnect = enabled) }
|
||||
}
|
||||
|
||||
// ── Restore last session on startup (issue #1102) ─────────────────────
|
||||
|
||||
private fun currentSessionScopeKey(): String {
|
||||
val connId = getSelectedProfileId()?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||
val serverProfile = activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||
return "$connId:$serverProfile"
|
||||
}
|
||||
|
||||
fun isRestoreLastSession(): Boolean = serverStore.getLatestState().restoreLastSession
|
||||
|
||||
fun setRestoreLastSession(enabled: Boolean) {
|
||||
serverStore.update { it.copy(restoreLastSession = enabled) }
|
||||
}
|
||||
|
||||
fun getLastOpenedSessionId(): String? = serverStore.getLatestState().lastOpenedSessionIds[currentSessionScopeKey()]
|
||||
|
||||
fun setLastOpenedSessionId(sessionId: String?) {
|
||||
val key = currentSessionScopeKey()
|
||||
serverStore.update { state ->
|
||||
val updated =
|
||||
if (sessionId != null) {
|
||||
state.lastOpenedSessionIds + (key to sessionId)
|
||||
} else {
|
||||
state.lastOpenedSessionIds - key
|
||||
}
|
||||
state.copy(lastOpenedSessionIds = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLastOpenedSessionId() {
|
||||
setLastOpenedSessionId(null)
|
||||
}
|
||||
|
||||
fun clearLastOpenedSessionIdsForConnection(connectionProfileId: String) {
|
||||
serverStore.update { state ->
|
||||
val prefix = "$connectionProfileId:"
|
||||
val updated = state.lastOpenedSessionIds.filterKeys { !it.startsWith(prefix) }
|
||||
state.copy(lastOpenedSessionIds = updated)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme preference ──────────────────────────────────────────────────
|
||||
|
||||
fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference
|
||||
|
||||
@@ -42,4 +42,8 @@ data class ChatMessageEntity(
|
||||
val isStreaming: Boolean = false,
|
||||
@ColumnInfo(name = "display_kind")
|
||||
val displayKind: String? = null,
|
||||
@ColumnInfo(name = "token_count")
|
||||
val tokenCount: Int? = null,
|
||||
@ColumnInfo(name = "tps")
|
||||
val tps: Double? = null,
|
||||
)
|
||||
@@ -35,6 +35,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
else -> null
|
||||
},
|
||||
displayKind = displayKind,
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
)
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
@@ -50,4 +52,6 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
toolStatus = toolStatus?.name,
|
||||
isStreaming = isStreaming,
|
||||
displayKind = displayKind,
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
)
|
||||
@@ -12,7 +12,7 @@ import java.io.File
|
||||
|
||||
@Database(
|
||||
entities = [ChatMessageEntity::class],
|
||||
version = 6,
|
||||
version = 7,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class HermesDatabase : RoomDatabase() {
|
||||
@@ -67,6 +67,18 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_6_7: Migration =
|
||||
object : Migration(6, 7) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `chat_messages` ADD COLUMN `token_count` INTEGER",
|
||||
)
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `chat_messages` ADD COLUMN `tps` REAL",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context): HermesDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
// SQLCipher can't open plaintext SQLite databases — if an old
|
||||
@@ -97,6 +109,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||
|
||||
/**
|
||||
* High-level connection status for a connector item.
|
||||
* Deliberate precedence: if [connected] is true, status resolves to [CONNECTED]
|
||||
* regardless of expired/revoked/stale statuses reported by backend.
|
||||
*/
|
||||
enum class ConnectorStatus {
|
||||
CONNECTED,
|
||||
INITIATED,
|
||||
EXPIRED,
|
||||
REVOKED,
|
||||
FAILED,
|
||||
DISCONNECTED,
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
val isConnected: Boolean get() = this == CONNECTED
|
||||
|
||||
companion object {
|
||||
fun from(
|
||||
connected: Boolean,
|
||||
rawStatus: String?,
|
||||
): ConnectorStatus = from(connected = connected as Boolean?, rawStatus = rawStatus)
|
||||
|
||||
fun from(
|
||||
connected: Boolean?,
|
||||
rawStatus: String?,
|
||||
): ConnectorStatus {
|
||||
if (connected == true) return CONNECTED
|
||||
if (connected == false) {
|
||||
return when (rawStatus?.lowercase()?.trim()) {
|
||||
"expired" -> EXPIRED
|
||||
"revoked" -> REVOKED
|
||||
"initiated" -> INITIATED
|
||||
"failed", "error" -> FAILED
|
||||
"disconnected" -> DISCONNECTED
|
||||
null, "", "active", "connected", "authorized" -> DISCONNECTED
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
// Missing boolean: "authorized", "active", or "connected" yields effective CONNECTED
|
||||
return when (rawStatus?.lowercase()?.trim()) {
|
||||
"authorized", "active", "connected" -> CONNECTED
|
||||
"expired" -> EXPIRED
|
||||
"revoked" -> REVOKED
|
||||
"initiated" -> INITIATED
|
||||
"failed", "error" -> FAILED
|
||||
"disconnected" -> DISCONNECTED
|
||||
null, "" -> DISCONNECTED
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status resulting from a connect/reconnect initiation.
|
||||
*/
|
||||
enum class ConnectorConnectStatus {
|
||||
ACTIVE,
|
||||
INITIATED,
|
||||
FAILED,
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
val isActive: Boolean get() = this == ACTIVE
|
||||
|
||||
companion object {
|
||||
fun from(rawStatus: String?): ConnectorConnectStatus =
|
||||
when (rawStatus?.lowercase()?.trim()) {
|
||||
"active", "connected" -> ACTIVE
|
||||
"initiated" -> INITIATED
|
||||
"failed", "error" -> FAILED
|
||||
null, "" -> UNKNOWN
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual connector item returned by `connectors.list`.
|
||||
*/
|
||||
data class ConnectorItem(
|
||||
val connector: String,
|
||||
val name: String = connector,
|
||||
val connected: Boolean = false,
|
||||
val enabled: Boolean = true,
|
||||
val connectionStatus: String? = null,
|
||||
val status: ConnectorStatus = ConnectorStatus.from(connected, connectionStatus),
|
||||
val connectUrl: String? = null,
|
||||
) {
|
||||
val isConnected: Boolean get() = connected || status.isConnected
|
||||
|
||||
/** Safe validated HTTPS URL, or null if invalid or absent. */
|
||||
val safeConnectUrl: String?
|
||||
get() = if (ConnectorUrlValidator.isValidHttpsUrl(connectUrl)) connectUrl else null
|
||||
|
||||
override fun toString(): String {
|
||||
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
|
||||
return "ConnectorItem(" +
|
||||
"connector=$connector, " +
|
||||
"name=$name, " +
|
||||
"connected=$connected, " +
|
||||
"enabled=$enabled, " +
|
||||
"status=$status, " +
|
||||
"connectUrl=$redactedUrl" +
|
||||
")"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a connect/reconnect action for an individual connector.
|
||||
* Instructions from arbitrary backends are intentionally not exposed for UI display.
|
||||
*/
|
||||
data class ConnectorConnectItem(
|
||||
val connector: String,
|
||||
val status: ConnectorConnectStatus,
|
||||
val connectUrl: String? = null,
|
||||
) {
|
||||
val isConnectUrlValid: Boolean
|
||||
get() = ConnectorUrlValidator.isValidHttpsUrl(connectUrl)
|
||||
|
||||
val safeConnectUrl: String?
|
||||
get() = if (isConnectUrlValid) connectUrl else null
|
||||
|
||||
override fun toString(): String {
|
||||
val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
|
||||
return "ConnectorConnectItem(" +
|
||||
"connector=$connector, " +
|
||||
"status=$status, " +
|
||||
"connectUrl=$redactedUrl, " +
|
||||
"isConnectUrlValid=$isConnectUrlValid" +
|
||||
")"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary counts returned by `connectors.connect`.
|
||||
*/
|
||||
data class ConnectorConnectSummary(
|
||||
val total: Int = 0,
|
||||
val active: Int = 0,
|
||||
val initiated: Int = 0,
|
||||
val failed: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Typed safe connector errors mapped from RPC codes and reasons.
|
||||
* Raw exceptions or auth tokens are never exposed.
|
||||
*/
|
||||
sealed interface ConnectorError {
|
||||
val message: String
|
||||
|
||||
data class InvalidParams(
|
||||
override val message: String = "Invalid connector parameters.",
|
||||
) : ConnectorError
|
||||
|
||||
data class NotOwner(
|
||||
override val message: String = "Session is not owned by this transport.",
|
||||
) : ConnectorError
|
||||
|
||||
data class Unavailable(
|
||||
override val message: String = "Connectors are not available in this session.",
|
||||
) : ConnectorError
|
||||
|
||||
data class UnsupportedRuntime(
|
||||
override val message: String = "Connectors must be managed on the session's compute host.",
|
||||
) : ConnectorError
|
||||
|
||||
data class RequestFailed(
|
||||
override val message: String = "Connector request failed. Please try again.",
|
||||
) : ConnectorError
|
||||
|
||||
data class InvalidResponse(
|
||||
override val message: String = "Connector service returned an invalid response.",
|
||||
) : ConnectorError
|
||||
|
||||
data class UnsupportedBackend(
|
||||
override val message: String = "Connector service is not supported by this backend.",
|
||||
) : ConnectorError
|
||||
|
||||
data class MalformedEnvelope(
|
||||
override val message: String = "Malformed response envelope received from gateway.",
|
||||
) : ConnectorError
|
||||
|
||||
data class NetworkError(
|
||||
override val message: String = "Failed to communicate with gateway.",
|
||||
) : ConnectorError
|
||||
|
||||
data class Other(
|
||||
val code: Int = 0,
|
||||
override val message: String = "Connector operation failed.",
|
||||
) : ConnectorError
|
||||
}
|
||||
|
||||
/**
|
||||
* Result envelope for `connectors.list`.
|
||||
*/
|
||||
sealed interface ConnectorListResult {
|
||||
data class Success(
|
||||
val available: Boolean,
|
||||
val connectors: List<ConnectorItem>,
|
||||
) : ConnectorListResult
|
||||
|
||||
data class Error(
|
||||
val error: ConnectorError,
|
||||
) : ConnectorListResult
|
||||
|
||||
fun getOrNull(): List<ConnectorItem>? = (this as? Success)?.connectors
|
||||
|
||||
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
|
||||
}
|
||||
|
||||
/**
|
||||
* Result envelope for `connectors.connect`.
|
||||
*/
|
||||
sealed interface ConnectorConnectResult {
|
||||
data class Success(
|
||||
val results: List<ConnectorConnectItem>,
|
||||
val summary: ConnectorConnectSummary,
|
||||
) : ConnectorConnectResult
|
||||
|
||||
data class Error(
|
||||
val error: ConnectorError,
|
||||
) : ConnectorConnectResult
|
||||
|
||||
fun getOrNull(): List<ConnectorConnectItem>? = (this as? Success)?.results
|
||||
|
||||
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import com.m57.hermescontrol.data.ws.toAny
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
/**
|
||||
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
|
||||
* the same RPC the Hermes desktop app's status-bar meter uses.
|
||||
@@ -30,20 +36,47 @@ data class ContextBreakdownResponse(
|
||||
* Parse the decoded JSON-RPC result of `session.context_breakdown`.
|
||||
*
|
||||
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
|
||||
* so every count is read through [Number]. Returns null when the payload is
|
||||
* so every count is read through [Number]. Also supports raw [JsonObject] from
|
||||
* [HermesWsClient.request]. Returns null when the payload is
|
||||
* not a map (error/malformed response) — callers treat that as unknown and
|
||||
* keep the last known meter values.
|
||||
*/
|
||||
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? {
|
||||
val map = result as? Map<*, *> ?: return null
|
||||
val rawMap =
|
||||
when (result) {
|
||||
is JsonElement -> result.toAny() as? Map<*, *>
|
||||
is Map<*, *> -> result
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
fun num(key: String): Long? = (map[key] as? Number)?.toLong()
|
||||
fun num(key: String): Long? =
|
||||
when (val value = rawMap[key]) {
|
||||
is Number -> value.toLong()
|
||||
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
|
||||
is String -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun intNum(key: String): Int? =
|
||||
when (val value = rawMap[key]) {
|
||||
is Number -> value.toInt()
|
||||
is JsonPrimitive -> value.intOrNull
|
||||
is String -> value.toIntOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun str(key: String): String? =
|
||||
when (val value = rawMap[key]) {
|
||||
is String -> value
|
||||
is JsonPrimitive -> value.content
|
||||
else -> value?.toString()
|
||||
}
|
||||
|
||||
return ContextBreakdownResponse(
|
||||
contextUsed = num("context_used"),
|
||||
contextMax = num("context_max"),
|
||||
contextPercent = (map["context_percent"] as? Number)?.toInt(),
|
||||
contextPercent = intNum("context_percent"),
|
||||
estimatedTotal = num("estimated_total"),
|
||||
model = map["model"] as? String,
|
||||
model = str("model"),
|
||||
)
|
||||
}
|
||||
@@ -80,9 +80,63 @@ data class PluginsHubResponse(
|
||||
|
||||
@Serializable
|
||||
data class AgentPluginInstallBody(
|
||||
val identifier: String,
|
||||
val identifier: String = "",
|
||||
val force: Boolean = false,
|
||||
val enable: Boolean = true,
|
||||
@SerialName("catalog_name") val catalogName: String? = null,
|
||||
val ref: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogCapabilities(
|
||||
@SerialName("provides_tools") val providesTools: List<String> = emptyList(),
|
||||
@SerialName("provides_hooks") val providesHooks: List<String> = emptyList(),
|
||||
@SerialName("provides_middleware") val providesMiddleware: List<String> = emptyList(),
|
||||
@SerialName("requires_env") val requiresEnv: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogRemovedEntry(
|
||||
val name: String? = null,
|
||||
val repo: String? = null,
|
||||
val reason: String? = null,
|
||||
val date: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogEntry(
|
||||
val name: String,
|
||||
@SerialName("catalog_name") val catalogName: String? = null,
|
||||
val repo: String? = null,
|
||||
val sha: String? = null,
|
||||
@SerialName("sha_short") val shaShort: String? = null,
|
||||
val description: String? = null,
|
||||
val maintainer: String? = null,
|
||||
val tier: String? = null,
|
||||
val category: String? = null,
|
||||
@SerialName("requires_hermes") val requiresHermes: String? = null,
|
||||
val subdir: String? = null,
|
||||
@SerialName("docs_url") val docsUrl: String? = null,
|
||||
val platforms: List<String> = emptyList(),
|
||||
val capabilities: PluginCatalogCapabilities? = null,
|
||||
@SerialName("capability_summary") val capabilitySummary: String? = null,
|
||||
val installed: Boolean = false,
|
||||
@SerialName("installed_sha") val installedSha: String? = null,
|
||||
@SerialName("update_available") val updateAvailable: Boolean = false,
|
||||
@SerialName("runtime_status") val runtimeStatus: String? = null,
|
||||
) {
|
||||
val displayName: String
|
||||
get() = catalogName?.takeIf { it.isNotBlank() } ?: name
|
||||
|
||||
val isOfficial: Boolean
|
||||
get() = tier?.equals("official", ignoreCase = true) == true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogResponse(
|
||||
val entries: List<PluginCatalogEntry> = emptyList(),
|
||||
val removed: List<PluginCatalogRemovedEntry> = emptyList(),
|
||||
@SerialName("generated_at") val generatedAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
private fun recency(s: SessionInfo): Double = s.last_active ?: s.started_at ?: 0.0
|
||||
|
||||
/**
|
||||
* Port of desktop session-branch-tree.ts#flattenSessionsWithBranches.
|
||||
*
|
||||
* - Aliases _lineage_root_id so forks find compression-projected parents.
|
||||
* - Sorts sibling branches by own recency; sorts roots by groupRecency
|
||||
* (max over subtree) so activity on any branch lifts the whole family.
|
||||
* - preserveOrder keeps caller-chosen root order (pinned section).
|
||||
* - Cycle-guarded; trailing sweep never drops an input row.
|
||||
*/
|
||||
fun flattenSessionsWithBranches(
|
||||
sessions: List<SessionInfo>,
|
||||
preserveOrder: Boolean = false,
|
||||
): List<SessionTreeItem> {
|
||||
if (sessions.size < 2) {
|
||||
return sessions.map { it.toTreeItem(depth = 0, siblingIndex = 0, siblingCount = 1) }
|
||||
}
|
||||
|
||||
val byVisibleId = HashMap<String, SessionInfo>(sessions.size * 2)
|
||||
for (s in sessions) {
|
||||
byVisibleId[s.id] = s
|
||||
s.lineageRootId
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { byVisibleId[it] = s }
|
||||
}
|
||||
|
||||
val childrenByParent = HashMap<String, MutableList<SessionInfo>>()
|
||||
val nestedIds = HashSet<String>()
|
||||
for (s in sessions) {
|
||||
val parentId = s.parent_session_id?.trim()?.takeIf(String::isNotEmpty) ?: continue
|
||||
val parent = byVisibleId[parentId] ?: continue
|
||||
if (parent.id == s.id) continue
|
||||
nestedIds += s.id
|
||||
childrenByParent.getOrPut(parent.id) { mutableListOf() } += s
|
||||
}
|
||||
childrenByParent.values.forEach { it.sortByDescending(::recency) }
|
||||
|
||||
// Group sorts by its freshest member; memo pre-seed doubles as cycle guard.
|
||||
val groupRecencyMemo = HashMap<String, Double>()
|
||||
|
||||
fun groupRecency(s: SessionInfo): Double {
|
||||
groupRecencyMemo[s.id]?.let { return it }
|
||||
groupRecencyMemo[s.id] = recency(s)
|
||||
val max =
|
||||
childrenByParent[s.id]
|
||||
.orEmpty()
|
||||
.fold(recency(s)) { acc, child -> maxOf(acc, groupRecency(child)) }
|
||||
groupRecencyMemo[s.id] = max
|
||||
return max
|
||||
}
|
||||
|
||||
val out = mutableListOf<SessionTreeItem>()
|
||||
val seen = HashSet<String>()
|
||||
|
||||
fun emit(
|
||||
s: SessionInfo,
|
||||
depth: Int,
|
||||
siblingIndex: Int,
|
||||
siblingCount: Int,
|
||||
) {
|
||||
if (!seen.add(s.id)) return
|
||||
out += s.toTreeItem(depth, siblingIndex, siblingCount)
|
||||
val kids = childrenByParent[s.id].orEmpty()
|
||||
kids.forEachIndexed { i, child -> emit(child, depth + 1, i, kids.size) }
|
||||
}
|
||||
|
||||
val roots = sessions.withIndex().filter { it.value.id !in nestedIds }
|
||||
val ordered =
|
||||
if (preserveOrder) {
|
||||
roots
|
||||
} else {
|
||||
roots.sortedWith(
|
||||
compareByDescending<IndexedValue<SessionInfo>> { groupRecency(it.value) }
|
||||
.thenBy { it.index },
|
||||
)
|
||||
}
|
||||
ordered.forEachIndexed { i, r -> emit(r.value, 0, i, ordered.size) }
|
||||
sessions.filter { it.id !in seen }.forEach { emit(it, 0, 0, 1) } // trailing sweep
|
||||
return out
|
||||
}
|
||||
|
||||
private fun SessionInfo.toTreeItem(
|
||||
depth: Int,
|
||||
siblingIndex: Int,
|
||||
siblingCount: Int,
|
||||
): SessionTreeItem {
|
||||
// G4 fix: fork identity from the field itself, never from load-set membership.
|
||||
val isFork = !parent_session_id.isNullOrBlank()
|
||||
val title =
|
||||
title?.takeIf(String::isNotBlank)
|
||||
?: display_name?.takeIf(String::isNotBlank)
|
||||
?: if (isFork && depth > 0) {
|
||||
"branch ${siblingIndex + 1}"
|
||||
} else {
|
||||
preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
|
||||
}
|
||||
return SessionTreeItem(
|
||||
session = this,
|
||||
depth = depth,
|
||||
branchStem =
|
||||
if (depth == 0) {
|
||||
null
|
||||
} else if (siblingIndex == siblingCount - 1) {
|
||||
"└─"
|
||||
} else {
|
||||
"├─"
|
||||
},
|
||||
isFork = isFork,
|
||||
forkDepth = if (depth > 0) depth else 0,
|
||||
displayTitle = title,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
@@ -78,6 +79,14 @@ data class SessionInfo(
|
||||
val display_name: String? = null,
|
||||
val model: String? = null,
|
||||
val terminal_backend: String? = null,
|
||||
// Epoch seconds of the latest message (backend-computed, present on all
|
||||
// list rows). Recency source of truth; started_at is the fallback.
|
||||
val last_active: Double? = null,
|
||||
// Original root id when this row is a compression-projected tip. Branch
|
||||
// children reference the ROOT id in parent_session_id, so tree building
|
||||
// must alias this id to this row (desktop byVisibleId parity).
|
||||
@SerialName("_lineage_root_id")
|
||||
val lineageRootId: String? = null,
|
||||
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
|
||||
// sweep and are surfaced first in the history list (sorted client-side).
|
||||
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* High-level live status for a session in History.
|
||||
*/
|
||||
enum class SessionLiveStatus {
|
||||
WORKING,
|
||||
WAITING,
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC response from `session.active_list`.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActiveSessionsResponse(
|
||||
val sessions: List<ActiveSessionItem> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Individual item in `session.active_list`.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActiveSessionItem(
|
||||
val id: String? = null,
|
||||
@SerialName("session_key")
|
||||
val sessionKey: String? = null,
|
||||
val status: String? = null,
|
||||
@SerialName("last_active")
|
||||
val lastActive: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed authoritative snapshot of live sessions.
|
||||
*/
|
||||
data class LiveSessionSnapshot(
|
||||
val statusByStoredId: Map<String, SessionLiveStatus> = emptyMap(),
|
||||
val storedIdByRuntimeId: Map<String, String> = emptyMap(),
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
@Serializable
|
||||
@@ -50,10 +51,18 @@ data class SessionMessage(
|
||||
val display_kind: String? = null,
|
||||
/** Display-only metadata for the marker (e.g. delegation result counts). */
|
||||
val display_metadata: JsonElement? = null,
|
||||
/** Token count recorded by the backend. */
|
||||
val token_count: JsonElement? = null,
|
||||
) {
|
||||
val timestampText: String?
|
||||
get() = (timestamp as? JsonPrimitive)?.content
|
||||
|
||||
val tokenCount: Int?
|
||||
get() {
|
||||
val prim = token_count as? JsonPrimitive ?: return null
|
||||
return prim.intOrNull ?: prim.content.toIntOrNull()
|
||||
}
|
||||
|
||||
val timestampEpochMs: Long?
|
||||
get() {
|
||||
val prim = timestamp as? JsonPrimitive ?: return null
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* Item returned in the [SubagentListResponse] from `subagent.list` (issue #1089).
|
||||
*/
|
||||
@Serializable
|
||||
data class SubagentListItem(
|
||||
@SerialName("subagent_id") val subagentId: String = "",
|
||||
val goal: String? = null,
|
||||
val status: String? = null,
|
||||
val model: String? = null,
|
||||
@SerialName("elapsed_seconds") val elapsedSeconds: Double? = null,
|
||||
@SerialName("started_at") val startedAt: Double? = null,
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
val depth: Int? = null,
|
||||
@SerialName("delegation_id") val delegationId: String? = null,
|
||||
@SerialName("tool_count") val toolCount: Int? = null,
|
||||
@SerialName("last_tool") val lastTool: String? = null,
|
||||
@SerialName("accepting_steer") val acceptingSteer: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response returned from `subagent.list` (issue #1089).
|
||||
*/
|
||||
@Serializable
|
||||
data class SubagentListResponse(
|
||||
val subagents: List<SubagentListItem> = emptyList(),
|
||||
val delegations: List<JsonElement> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Response returned from `subagent.tail` (issue #1089).
|
||||
*
|
||||
* Backend returns either `text` (desktop/TUI gateway) or `tail` (contract).
|
||||
* [content] returns the authoritative transcript text from whichever field is present.
|
||||
*/
|
||||
@Serializable
|
||||
data class SubagentTailResponse(
|
||||
@SerialName("subagent_id") val subagentId: String = "",
|
||||
val tail: String? = null,
|
||||
val text: String? = null,
|
||||
val available: Boolean? = null,
|
||||
val truncated: Boolean? = null,
|
||||
@SerialName("bytes_read") val bytesRead: Long? = null,
|
||||
) {
|
||||
/** Returns authoritative transcript text across backend schema variants. */
|
||||
fun content(): String = text ?: tail ?: ""
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import com.m57.hermescontrol.data.ws.toAny
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
/**
|
||||
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
|
||||
* only the fields the context widget consumes today. The backend sends many more keys
|
||||
@@ -20,6 +27,8 @@ data class UsageSnapshotResponse(
|
||||
val contextMax: Long? = null,
|
||||
/** Cumulative session total tokens. */
|
||||
val totalTokens: Long? = null,
|
||||
/** Rolling tokens-per-second generation speed. */
|
||||
val avgTps: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -27,16 +36,47 @@ data class UsageSnapshotResponse(
|
||||
*
|
||||
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
|
||||
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
|
||||
* (`JsonRpcModels.toAny`), so every count is read through [Number].
|
||||
* (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
|
||||
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
|
||||
*/
|
||||
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
||||
val map = result as? Map<*, *> ?: return null
|
||||
val usageMap = (map["usage"] as? Map<*, *>) ?: map
|
||||
val rawMap =
|
||||
when (result) {
|
||||
is JsonElement -> result.toAny() as? Map<*, *>
|
||||
is Map<*, *> -> result
|
||||
else -> null
|
||||
} ?: return null
|
||||
val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap
|
||||
|
||||
fun num(key: String): Long? =
|
||||
when (val value = usageMap[key]) {
|
||||
is Number -> value.toLong()
|
||||
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
|
||||
is String -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun intNum(key: String): Int? =
|
||||
when (val value = usageMap[key]) {
|
||||
is Number -> value.toInt()
|
||||
is JsonPrimitive -> value.intOrNull
|
||||
is String -> value.toIntOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun doubleNum(key: String): Double? =
|
||||
when (val value = usageMap[key]) {
|
||||
is Number -> value.toDouble()
|
||||
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
|
||||
is String -> value.toDoubleOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
return UsageSnapshotResponse(
|
||||
compressions = (usageMap["compressions"] as? Number)?.toInt(),
|
||||
contextUsed = (usageMap["context_used"] as? Number)?.toLong(),
|
||||
contextMax = (usageMap["context_max"] as? Number)?.toLong(),
|
||||
totalTokens = (usageMap["total"] as? Number)?.toLong(),
|
||||
compressions = intNum("compressions"),
|
||||
contextUsed = num("context_used"),
|
||||
contextMax = num("context_max"),
|
||||
totalTokens = num("total"),
|
||||
avgTps = doubleNum("avg_tps"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Models for the Credential Vault RPC surface (issue #1090).
|
||||
*
|
||||
* Backend: `tui_gateway/methods_vault.py`.
|
||||
* Sources represent local encrypted vault or external managers (1Password, Bitwarden).
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data class VaultSource(
|
||||
val name: String,
|
||||
@SerialName("display_name") val displayName: String = name,
|
||||
val enabled: Boolean = false,
|
||||
@SerialName("needs_unlock") val needsUnlock: Boolean = false,
|
||||
val unlocked: Boolean = false,
|
||||
val installed: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultSourcesResponse(
|
||||
val sources: List<VaultSource> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultItem(
|
||||
val id: String,
|
||||
val backend: String = "local",
|
||||
val kind: String = "login",
|
||||
val label: String = "",
|
||||
val origin: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("identifier_type") val identifierType: String? = null,
|
||||
val identifier: String? = null,
|
||||
@SerialName("has_otp") val hasOtp: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultListResponse(
|
||||
val items: List<VaultItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultSourceSetResponse(
|
||||
val name: String? = null,
|
||||
val enabled: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultUnlockResponse(
|
||||
val name: String? = null,
|
||||
val unlocked: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultLockResponse(
|
||||
val locked: Boolean? = null,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Handles basic credential login and WS ticket minting for in-chat reconnect/re-authentication.
|
||||
*
|
||||
* Extracted from [com.m57.hermescontrol.ui.chat.ChatViewModel] to isolate raw OkHttp network calls.
|
||||
*/
|
||||
class ChatReloginAuthenticator(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||
) {
|
||||
suspend fun relogin(
|
||||
username: String,
|
||||
password: String,
|
||||
onSuccess: () -> Unit,
|
||||
onFailure: (String) -> Unit,
|
||||
) {
|
||||
withContext(ioDispatcher) {
|
||||
val endpoint = AuthManager.endpointForBuild()
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val jsonBody =
|
||||
JSONObject()
|
||||
.put("provider", "basic")
|
||||
.put("username", username)
|
||||
.put("password", password)
|
||||
.put("next", "")
|
||||
.toString()
|
||||
|
||||
try {
|
||||
val loginClient =
|
||||
OkHttpProvider.probe
|
||||
.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val loginReq =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint.resolve("auth/password-login").toString())
|
||||
.header("Content-Type", "application/json")
|
||||
.post(jsonBody.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
loginClient.newCall(loginReq).execute().use { loginResp ->
|
||||
if (!loginResp.isSuccessful) {
|
||||
val msg =
|
||||
when (loginResp.code) {
|
||||
401 -> "Invalid username or password (401)"
|
||||
403 -> "Forbidden (403)"
|
||||
else -> "HTTP error code: ${loginResp.code}"
|
||||
}
|
||||
withContext(mainDispatcher) {
|
||||
onFailure(msg)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
val ticketClient =
|
||||
OkHttpProvider.base
|
||||
.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val ticketReq =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint.resolve("api/auth/ws-ticket").toString())
|
||||
.post("{}".toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
ticketClient.newCall(ticketReq).execute().use { ticketResp ->
|
||||
if (!ticketResp.isSuccessful) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Failed to mint WS ticket: HTTP ${ticketResp.code}")
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val body = ticketResp.body.string()
|
||||
val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() }
|
||||
|
||||
if (ticket.isNullOrBlank()) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Invalid ticket returned from server")
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
AuthManager.setWsAuthParam("ticket")
|
||||
AuthManager.setToken(ticket)
|
||||
|
||||
withContext(mainDispatcher) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Connection failed: ${e.message}")
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
withContext(mainDispatcher) {
|
||||
onFailure("Connection failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse
|
||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||
import com.m57.hermescontrol.data.model.PairingResponse
|
||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogResponse
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.PortalResponse
|
||||
@@ -95,6 +96,7 @@ import com.m57.hermescontrol.data.model.RenameProfileRequest
|
||||
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
||||
import com.m57.hermescontrol.data.model.ScanStatus
|
||||
import com.m57.hermescontrol.data.model.SessionDetailResponse
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||
@@ -267,6 +269,12 @@ interface HermesApiService {
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<SessionDetailResponse>
|
||||
|
||||
@GET("api/sessions/{id}")
|
||||
suspend fun getSessionInfo(
|
||||
@Path("id", encoded = true) sessionId: String,
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<SessionInfo>
|
||||
|
||||
@GET("api/model/info")
|
||||
suspend fun getModelInfo(): Response<ModelInfoResponse>
|
||||
|
||||
@@ -618,6 +626,9 @@ interface HermesApiService {
|
||||
@GET("api/dashboard/plugins/hub")
|
||||
suspend fun getPlugins(): Response<PluginsHubResponse>
|
||||
|
||||
@GET("api/dashboard/plugins/catalog")
|
||||
suspend fun getPluginCatalog(): Response<PluginCatalogResponse>
|
||||
|
||||
@POST("api/dashboard/agent-plugins/install")
|
||||
suspend fun installPlugin(
|
||||
@Body body: AgentPluginInstallBody,
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectItem
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectSummary
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorItem
|
||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorStatus
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
/**
|
||||
* Dedicated parser for connector payloads with strict envelope validation,
|
||||
* resilient type coercion, legacy alias normalization, and safe error mapping.
|
||||
*/
|
||||
object ConnectorParser {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw result from `connectors.list` RPC.
|
||||
* Fails with [ConnectorError.MalformedEnvelope] if the required envelope structure is missing.
|
||||
*/
|
||||
fun parseListResult(raw: Any?): ConnectorListResult {
|
||||
val root =
|
||||
toJsonElement(raw) as? JsonObject
|
||||
?: return ConnectorListResult.Error(
|
||||
ConnectorError.MalformedEnvelope("Response envelope must be a JSON object."),
|
||||
)
|
||||
|
||||
// Required envelope check: "connectors" must exist and be an array.
|
||||
val connectorsArray =
|
||||
root["connectors"] as? JsonArray
|
||||
?: return ConnectorListResult.Error(
|
||||
ConnectorError.MalformedEnvelope("Envelope missing required 'connectors' array."),
|
||||
)
|
||||
|
||||
// Optional 'available' flag (defaults to true if omitted, unless explicitly false)
|
||||
val available = parseBoolean(root["available"], default = true)
|
||||
|
||||
val items = mutableListOf<ConnectorItem>()
|
||||
val seenSlugs = mutableSetOf<String>()
|
||||
for (element in connectorsArray) {
|
||||
val itemObj = element as? JsonObject ?: continue
|
||||
val item = parseConnectorItem(itemObj) ?: continue
|
||||
if (seenSlugs.add(item.connector)) {
|
||||
items.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Entirely invalid nonempty connectors array must fail rather than looking silently valid empty.
|
||||
if (connectorsArray.isNotEmpty() && items.isEmpty()) {
|
||||
return ConnectorListResult.Error(
|
||||
ConnectorError.MalformedEnvelope("Connectors list contains only invalid items."),
|
||||
)
|
||||
}
|
||||
|
||||
return ConnectorListResult.Success(
|
||||
available = available,
|
||||
connectors = items,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw result from `connectors.connect` RPC.
|
||||
* Fails with [ConnectorError.MalformedEnvelope] or [ConnectorError.InvalidResponse] if invalid.
|
||||
*/
|
||||
fun parseConnectResult(raw: Any?): ConnectorConnectResult {
|
||||
val rawElement = toJsonElement(raw)
|
||||
|
||||
// Tolerate purposeful legacy bare array form: [ { "connector": "linear", ... } ]
|
||||
if (rawElement is JsonArray) {
|
||||
val results = mutableListOf<ConnectorConnectItem>()
|
||||
val seenSlugs = mutableSetOf<String>()
|
||||
for (element in rawElement) {
|
||||
val itemObj = element as? JsonObject ?: continue
|
||||
val item = parseConnectItem(itemObj) ?: continue
|
||||
if (seenSlugs.add(item.connector)) {
|
||||
results.add(item)
|
||||
}
|
||||
}
|
||||
if (results.isNotEmpty()) {
|
||||
val summary =
|
||||
ConnectorConnectSummary(
|
||||
total = results.size,
|
||||
active = results.count { it.status == ConnectorConnectStatus.ACTIVE },
|
||||
initiated = results.count { it.status == ConnectorConnectStatus.INITIATED },
|
||||
failed = results.count { it.status == ConnectorConnectStatus.FAILED },
|
||||
)
|
||||
return ConnectorConnectResult.Success(
|
||||
results = results,
|
||||
summary = summary,
|
||||
)
|
||||
}
|
||||
return ConnectorConnectResult.Error(
|
||||
ConnectorError.InvalidResponse("Connect response contains no valid results."),
|
||||
)
|
||||
}
|
||||
|
||||
val root =
|
||||
rawElement as? JsonObject
|
||||
?: return ConnectorConnectResult.Error(
|
||||
ConnectorError.MalformedEnvelope("Connect response must be a JSON object."),
|
||||
)
|
||||
|
||||
// Required envelope check: "results" must exist and be an array.
|
||||
val resultsArray =
|
||||
root["results"] as? JsonArray
|
||||
?: return ConnectorConnectResult.Error(
|
||||
ConnectorError.MalformedEnvelope("Connect envelope missing required 'results' array."),
|
||||
)
|
||||
|
||||
val results = mutableListOf<ConnectorConnectItem>()
|
||||
val seenSlugs = mutableSetOf<String>()
|
||||
for (element in resultsArray) {
|
||||
val itemObj = element as? JsonObject ?: continue
|
||||
val item = parseConnectItem(itemObj) ?: continue
|
||||
if (seenSlugs.add(item.connector)) {
|
||||
results.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect must return at least nonempty valid results
|
||||
if (results.isEmpty()) {
|
||||
return ConnectorConnectResult.Error(
|
||||
ConnectorError.InvalidResponse("Connect response contains no valid results."),
|
||||
)
|
||||
}
|
||||
|
||||
// Current summary object is required in standard envelope
|
||||
val summaryObj =
|
||||
root["summary"] as? JsonObject
|
||||
?: return ConnectorConnectResult.Error(
|
||||
ConnectorError.MalformedEnvelope("Connect envelope missing required 'summary' object."),
|
||||
)
|
||||
val summary = parseConnectSummary(summaryObj)
|
||||
|
||||
return ConnectorConnectResult.Success(
|
||||
results = results,
|
||||
summary = summary,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseConnectorItem(obj: JsonObject): ConnectorItem? {
|
||||
// Strict string identifier with backend slug validation; legacy aliases supported
|
||||
val slug =
|
||||
parseIdentifier(obj["connector"])
|
||||
?: parseIdentifier(obj["slug"])
|
||||
?: parseIdentifier(obj["id"])
|
||||
?: return null // Skip items without valid connector identifier
|
||||
|
||||
// Display name: primary "name", legacy aliases "displayName", "title", fallback to slug
|
||||
val name =
|
||||
parseString(obj["name"])
|
||||
?: parseString(obj["displayName"])
|
||||
?: parseString(obj["title"])
|
||||
?: slug
|
||||
|
||||
val rawConnected = parseBooleanOrNull(obj["connected"])
|
||||
val enabled = parseBoolean(obj["enabled"], default = true)
|
||||
|
||||
// Connection status string: primary "connectionStatus", legacy aliases "status", "connection_status"
|
||||
val rawStatus =
|
||||
parseString(obj["connectionStatus"])
|
||||
?: parseString(obj["status"])
|
||||
?: parseString(obj["connection_status"])
|
||||
|
||||
// Compute typed status respecting connected precedence:
|
||||
// When connected boolean is missing (legacy), status "authorized"/"connected"/"active" yields CONNECTED.
|
||||
// Explicit connected=false is authoritative and not overridden by status active.
|
||||
val status = ConnectorStatus.from(connected = rawConnected, rawStatus = rawStatus)
|
||||
val effectiveConnected = rawConnected ?: status.isConnected
|
||||
|
||||
// Connect URL (transient): primary "connect_url", legacy aliases "connectUrl", "auth_url", "authUrl"
|
||||
val connectUrl =
|
||||
parseString(obj["connect_url"])
|
||||
?: parseString(obj["connectUrl"])
|
||||
?: parseString(obj["auth_url"])
|
||||
?: parseString(obj["authUrl"])
|
||||
|
||||
return ConnectorItem(
|
||||
connector = slug,
|
||||
name = name,
|
||||
connected = effectiveConnected,
|
||||
enabled = enabled,
|
||||
connectionStatus = rawStatus,
|
||||
status = status,
|
||||
connectUrl = connectUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseConnectItem(obj: JsonObject): ConnectorConnectItem? {
|
||||
val slug =
|
||||
parseIdentifier(obj["connector"])
|
||||
?: parseIdentifier(obj["slug"])
|
||||
?: parseIdentifier(obj["id"])
|
||||
?: return null
|
||||
|
||||
val rawStatus =
|
||||
parseString(obj["status"])
|
||||
?: parseString(obj["connectionStatus"])
|
||||
?: parseString(obj["connection_status"])
|
||||
|
||||
val status = ConnectorConnectStatus.from(rawStatus)
|
||||
|
||||
val connectUrl =
|
||||
parseString(obj["connect_url"])
|
||||
?: parseString(obj["connectUrl"])
|
||||
?: parseString(obj["auth_url"])
|
||||
?: parseString(obj["authUrl"])
|
||||
|
||||
return ConnectorConnectItem(
|
||||
connector = slug,
|
||||
status = status,
|
||||
connectUrl = connectUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseConnectSummary(obj: JsonObject?): ConnectorConnectSummary {
|
||||
if (obj == null) return ConnectorConnectSummary()
|
||||
return ConnectorConnectSummary(
|
||||
total = parseInt(obj["total"], default = 0),
|
||||
active = parseInt(obj["active"], default = 0),
|
||||
initiated = parseInt(obj["initiated"], default = 0),
|
||||
failed = parseInt(obj["failed"], default = 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map RPC error codes and reasons into typed [ConnectorError]s.
|
||||
* Raw server strings and sensitive parameters are never forwarded or exposed in error messages.
|
||||
*/
|
||||
fun mapRpcError(
|
||||
code: Int,
|
||||
message: String? = null,
|
||||
data: JsonElement? = null,
|
||||
): ConnectorError {
|
||||
val reason =
|
||||
when (data) {
|
||||
is JsonObject -> (data["reason"] as? JsonPrimitive)?.content
|
||||
is JsonPrimitive -> data.content
|
||||
else -> null
|
||||
}?.uppercase()?.trim()
|
||||
|
||||
return when {
|
||||
code == 4000 || reason == "INVALID_PARAMS" -> {
|
||||
ConnectorError.InvalidParams()
|
||||
}
|
||||
|
||||
code == 4001 || reason == "NOT_OWNER" -> {
|
||||
ConnectorError.NotOwner()
|
||||
}
|
||||
|
||||
code == 4031 || reason == "CONNECTORS_UNAVAILABLE" -> {
|
||||
ConnectorError.Unavailable()
|
||||
}
|
||||
|
||||
code == 5033 || reason == "UNSUPPORTED_RUNTIME" -> {
|
||||
ConnectorError.UnsupportedRuntime()
|
||||
}
|
||||
|
||||
code == 5034 && reason == "INVALID_CONNECTOR_RESPONSE" -> {
|
||||
ConnectorError.InvalidResponse()
|
||||
}
|
||||
|
||||
code == 5034 -> {
|
||||
ConnectorError.RequestFailed()
|
||||
}
|
||||
|
||||
code == -32601 -> {
|
||||
ConnectorError.UnsupportedBackend()
|
||||
}
|
||||
|
||||
else -> {
|
||||
ConnectorError.Other(code = code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseStrictString(element: JsonElement?): String? {
|
||||
if (element == null || element is JsonNull) return null
|
||||
if (element is JsonPrimitive && element.isString) {
|
||||
return element.content
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun parseIdentifier(element: JsonElement?): String? {
|
||||
val str = parseStrictString(element)?.trim() ?: return null
|
||||
return if (ConnectorRepository.isValidSlug(str)) str else null
|
||||
}
|
||||
|
||||
internal fun parseString(element: JsonElement?): String? {
|
||||
if (element == null || element is JsonNull) return null
|
||||
if (element is JsonPrimitive) {
|
||||
if (!element.isString && element.content.isBlank()) return null
|
||||
return element.content
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun parseBooleanOrNull(element: JsonElement?): Boolean? {
|
||||
if (element == null || element is JsonNull) return null
|
||||
if (element is JsonPrimitive) {
|
||||
element.booleanOrNull?.let { return it }
|
||||
val content = element.content.trim()
|
||||
if (content.equals("true", ignoreCase = true) || content == "1") return true
|
||||
if (content.equals("false", ignoreCase = true) || content == "0") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun parseBoolean(
|
||||
element: JsonElement?,
|
||||
default: Boolean,
|
||||
): Boolean = parseBooleanOrNull(element) ?: default
|
||||
|
||||
internal fun parseInt(
|
||||
element: JsonElement?,
|
||||
default: Int,
|
||||
): Int {
|
||||
if (element == null || element is JsonNull) return default
|
||||
if (element is JsonPrimitive) {
|
||||
element.intOrNull?.let { return it }
|
||||
element.content.toIntOrNull()?.let { return it }
|
||||
}
|
||||
return default
|
||||
}
|
||||
|
||||
fun toJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> {
|
||||
JsonNull
|
||||
}
|
||||
|
||||
is JsonElement -> {
|
||||
value
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
JsonObject(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(value as Map<String, Any?>).mapValues { (_, v) -> toJsonElement(v) },
|
||||
)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(value.map { toJsonElement(it) })
|
||||
}
|
||||
|
||||
is String -> {
|
||||
val trimmed = value.trim()
|
||||
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
) {
|
||||
try {
|
||||
json.parseToJsonElement(value)
|
||||
} catch (_: Exception) {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
} else {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
}
|
||||
|
||||
is Boolean -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Number -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
else -> {
|
||||
JsonPrimitive(value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* Repository interface for connector operations: `connectors.list` and `connectors.connect` (issue #1091).
|
||||
* Public injectable interface enabling isolated testing in ViewModels and controllers.
|
||||
*/
|
||||
interface ConnectorRepository {
|
||||
/**
|
||||
* Request connector catalog and authorization status for [sessionId].
|
||||
*/
|
||||
suspend fun listConnectors(sessionId: String): ConnectorListResult
|
||||
|
||||
/**
|
||||
* Initiate authorization flow for [connectors] slugs in [sessionId].
|
||||
* If [reconnect] is true, restarts authorization flow even if already connected.
|
||||
*/
|
||||
suspend fun connect(
|
||||
sessionId: String,
|
||||
connectors: List<String>,
|
||||
reconnect: Boolean = false,
|
||||
): ConnectorConnectResult
|
||||
|
||||
companion object : ConnectorRepository by HermesConnectorRepository() {
|
||||
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]*$")
|
||||
|
||||
fun isValidSlug(slug: String?): Boolean {
|
||||
if (slug.isNullOrBlank()) return false
|
||||
return SLUG_REGEX.matches(slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
|
||||
*/
|
||||
class HermesConnectorRepository(
|
||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
||||
val deferred = HermesWsClient.request(method, params)
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (e: CancellationException) {
|
||||
deferred.cancel(e)
|
||||
throw e
|
||||
} finally {
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.cancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
) : ConnectorRepository {
|
||||
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
|
||||
if (sessionId.isBlank()) {
|
||||
return ConnectorListResult.Error(
|
||||
ConnectorError.InvalidParams("session_id required"),
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
val params = mapOf("session_id" to sessionId)
|
||||
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
|
||||
ConnectorParser.parseListResult(result)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
ConnectorListResult.Error(
|
||||
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ConnectorListResult.Error(
|
||||
ConnectorError.NetworkError("Failed to communicate with gateway."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun connect(
|
||||
sessionId: String,
|
||||
connectors: List<String>,
|
||||
reconnect: Boolean,
|
||||
): ConnectorConnectResult {
|
||||
if (sessionId.isBlank()) {
|
||||
return ConnectorConnectResult.Error(
|
||||
ConnectorError.InvalidParams("session_id required"),
|
||||
)
|
||||
}
|
||||
|
||||
if (connectors.isEmpty()) {
|
||||
return ConnectorConnectResult.Error(
|
||||
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
|
||||
)
|
||||
}
|
||||
|
||||
for (slug in connectors) {
|
||||
if (!ConnectorRepository.isValidSlug(slug)) {
|
||||
return ConnectorConnectResult.Error(
|
||||
ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val params =
|
||||
mapOf(
|
||||
"session_id" to sessionId,
|
||||
"connectors" to connectors,
|
||||
"reconnect" to reconnect,
|
||||
)
|
||||
val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
|
||||
ConnectorParser.parseConnectResult(result)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
ConnectorConnectResult.Error(
|
||||
ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ConnectorConnectResult.Error(
|
||||
ConnectorError.NetworkError("Failed to communicate with gateway."),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ object EventParser {
|
||||
"message.complete" -> {
|
||||
val text = payload?.get("text") as? String ?: ""
|
||||
val reasoning = payload?.get("reasoning") as? String
|
||||
WsEvent.MessageComplete(text, sessionId, reasoning)
|
||||
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
|
||||
}
|
||||
|
||||
"message.done" -> {
|
||||
@@ -341,6 +341,43 @@ object EventParser {
|
||||
WsEvent.SecretExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
// ── Credential Vault prompts (issue #1090) ──────────────────
|
||||
"vault.unlock.request" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
val backend = payload?.get("backend") as? String
|
||||
val displayName = payload?.get("display_name") as? String
|
||||
WsEvent.VaultUnlockRequest(requestId, sessionId, backend, displayName)
|
||||
}
|
||||
|
||||
"vault.unlock.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.VaultUnlockExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
"vault.save_login.request" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
val origin = payload?.get("origin") as? String
|
||||
val site = payload?.get("site") as? String
|
||||
WsEvent.VaultSaveLoginRequest(requestId, sessionId, origin, site)
|
||||
}
|
||||
|
||||
"vault.save_login.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.VaultSaveLoginExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
"vault.code.request" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
val site = payload?.get("site") as? String
|
||||
val hint = payload?.get("hint") as? String
|
||||
WsEvent.VaultCodeRequest(requestId, sessionId, site, hint)
|
||||
}
|
||||
|
||||
"vault.code.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.VaultCodeExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown event type: $eventType")
|
||||
WsEvent.Unknown(rawJson)
|
||||
|
||||
@@ -9,10 +9,12 @@ import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
@@ -25,9 +27,12 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
@@ -62,34 +67,48 @@ enum class ConnectionStatus {
|
||||
object HermesWsClient {
|
||||
private const val TAG = "HermesWsClient"
|
||||
|
||||
// ── Backoff settings ─────────────────────────────────────────────────
|
||||
// ── Monotonic time helper (immune to NTP and wall-clock jumps) ─────────
|
||||
private fun monotonicTimeMs(): Long = System.nanoTime() / 1_000_000L
|
||||
|
||||
// ── Backoff settings (desktop parity: reconnect-backoff.ts full-jitter) ─
|
||||
|
||||
private var initialBackoffMs = 1_000L
|
||||
private const val BACKOFF_BASE_MS = 300L
|
||||
private const val BACKOFF_CAP_MS = 15_000L
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
private var testBackoffOverrideMs: Long? = null
|
||||
|
||||
/**
|
||||
* Test hook: the initial reconnect backoff, overridable so reconnect
|
||||
* tests are deterministic instead of racing real time (CI runners
|
||||
* routinely exceed fixed latch windows). Production keeps 1s.
|
||||
* routinely exceed fixed latch windows). Production keeps full-jitter backoff.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun setReconnectBackoffForTest(initialMillis: Long) {
|
||||
initialBackoffMs = initialMillis
|
||||
currentBackoff = initialMillis
|
||||
testBackoffOverrideMs = initialMillis
|
||||
}
|
||||
|
||||
private fun nextBackoffDelayMs(): Long {
|
||||
val override = testBackoffOverrideMs
|
||||
if (override != null) return override
|
||||
val ceiling = currentBackoff
|
||||
if (ceiling <= 0L) return 0L
|
||||
return (Math.random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling)
|
||||
}
|
||||
|
||||
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
|
||||
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
||||
|
||||
/**
|
||||
* No inbound frame for this long means the link is dead even though the
|
||||
* TCP session may still look open (silent NAT/VPN drop). OkHttp pings
|
||||
* every 30s (OkHttpProvider.websocket) and any inbound frame refreshes
|
||||
* [lastPongTimestamp], so a healthy link never exceeds ~30s of silence.
|
||||
* 90s tolerates exactly one missed ping cycle before acting.
|
||||
* Liveness thresholds aligned with reference clients (desktop/web):
|
||||
* Ping every 15s using gateway.ping; 45s inbound silence deadline.
|
||||
*/
|
||||
private const val STALE_THRESHOLD_MS = 90_000L
|
||||
private const val HEARTBEAT_INTERVAL_MS = 15_000L
|
||||
private const val STALE_THRESHOLD_MS = 45_000L
|
||||
private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
|
||||
|
||||
// ── Internal state (all access through synchronized / atomic) ────────
|
||||
|
||||
@@ -150,7 +169,7 @@ object HermesWsClient {
|
||||
private set
|
||||
|
||||
val isHealthy: Boolean
|
||||
get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
|
||||
get() = isConnected && (monotonicTimeMs() - lastPongTimestamp < STALE_THRESHOLD_MS)
|
||||
|
||||
// ── Observable latency tracking (issue #1017) ─────────────────────────
|
||||
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
|
||||
@@ -162,14 +181,14 @@ object HermesWsClient {
|
||||
|
||||
private fun startHealthTracking() {
|
||||
healthJob?.cancel()
|
||||
lastPongTimestamp = System.currentTimeMillis()
|
||||
lastPongTimestamp = monotonicTimeMs()
|
||||
healthJob =
|
||||
wsScope.launch {
|
||||
while (connected.get()) {
|
||||
delay(30_000L)
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
if (connected.get()) {
|
||||
try {
|
||||
ping(timeoutMs = 10_000L)
|
||||
ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Health check ping failed: ${e.message}")
|
||||
}
|
||||
@@ -181,14 +200,15 @@ object HermesWsClient {
|
||||
|
||||
/**
|
||||
* Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017).
|
||||
* Bypasses agent queues on the backend; measures round-trip time in milliseconds.
|
||||
* Uses [WsMethods.GATEWAY_PING] which is answered inline in the WebSocket read
|
||||
* loop (ws.py:350) and never queues behind worker dispatch pools.
|
||||
* Updates [lastLatencyMs] and [lastPongTimestamp].
|
||||
*/
|
||||
suspend fun ping(timeoutMs: Long = 5_000L): Long {
|
||||
val start = System.currentTimeMillis()
|
||||
request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await()
|
||||
val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L)
|
||||
lastPongTimestamp = System.currentTimeMillis()
|
||||
suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long {
|
||||
val start = monotonicTimeMs()
|
||||
request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await()
|
||||
val latency = (monotonicTimeMs() - start).coerceAtLeast(0L)
|
||||
lastPongTimestamp = monotonicTimeMs()
|
||||
_lastLatencyMs.value = latency
|
||||
return latency
|
||||
}
|
||||
@@ -207,7 +227,7 @@ object HermesWsClient {
|
||||
*/
|
||||
private fun runHealthCheckPass(): Boolean {
|
||||
if (!connected.get()) return false
|
||||
val staleMs = System.currentTimeMillis() - lastPongTimestamp
|
||||
val staleMs = monotonicTimeMs() - lastPongTimestamp
|
||||
if (staleMs <= STALE_THRESHOLD_MS) return true
|
||||
Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
|
||||
synchronized(outboundLock) {
|
||||
@@ -220,11 +240,11 @@ object HermesWsClient {
|
||||
/**
|
||||
* Test hook: backdate liveness past the staleness threshold and force one
|
||||
* synchronous watchdog pass, so reconnect tests are deterministic instead
|
||||
* of racing the real 30s/90s cadence.
|
||||
* of racing the real 15s/45s cadence.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun forceHealthCheckForTest(staleMillis: Long) {
|
||||
lastPongTimestamp = System.currentTimeMillis() - staleMillis
|
||||
lastPongTimestamp = monotonicTimeMs() - staleMillis
|
||||
runHealthCheckPass()
|
||||
}
|
||||
|
||||
@@ -238,7 +258,7 @@ object HermesWsClient {
|
||||
|
||||
private val parsedEvents =
|
||||
MutableSharedFlow<WsEvent>(
|
||||
extraBufferCapacity = 512,
|
||||
extraBufferCapacity = 2048,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
@@ -346,9 +366,34 @@ object HermesWsClient {
|
||||
@VisibleForTesting
|
||||
val isConnected: Boolean get() = connected.get()
|
||||
|
||||
private var foregroundProbeJob: Job? = null
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
appInForeground.set(foreground)
|
||||
if (!foreground) disconnectIfIdleInBackground()
|
||||
if (!foreground) {
|
||||
disconnectIfIdleInBackground()
|
||||
return
|
||||
}
|
||||
probeLivenessOnWake()
|
||||
}
|
||||
|
||||
private fun probeLivenessOnWake(deferredOnce: Boolean = false) {
|
||||
if (!connected.get()) return
|
||||
foregroundProbeJob?.cancel()
|
||||
foregroundProbeJob =
|
||||
wsScope.launch {
|
||||
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
|
||||
if (alive) return@launch
|
||||
if (pendingReply && !deferredOnce) {
|
||||
delay(3_000L)
|
||||
probeLivenessOnWake(deferredOnce = true)
|
||||
return@launch
|
||||
}
|
||||
Log.w(TAG, "Foreground liveness probe failed — cancelling socket")
|
||||
synchronized(outboundLock) {
|
||||
if (connected.get()) webSocket?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acquireExternalActivityConnectionLease() {
|
||||
@@ -629,6 +674,8 @@ object HermesWsClient {
|
||||
reconnectJob = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
foregroundProbeJob?.cancel()
|
||||
foregroundProbeJob = null
|
||||
stopHealthTracking()
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
@@ -658,6 +705,8 @@ object HermesWsClient {
|
||||
/** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */
|
||||
class HermesRpcException(
|
||||
message: String,
|
||||
val code: Int = 0,
|
||||
val data: JsonElement? = null,
|
||||
) : Exception(message)
|
||||
|
||||
/**
|
||||
@@ -697,6 +746,15 @@ object HermesWsClient {
|
||||
send(method, params) { reqId ->
|
||||
pendingCalls[reqId] = PendingCall(method, deferred)
|
||||
}
|
||||
deferred.invokeOnCompletion { cause ->
|
||||
if (cause is CancellationException) {
|
||||
pendingCalls.remove(id)?.let { call ->
|
||||
call.timeoutJob?.cancel()
|
||||
removeQueuedMessage(id)
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arm the per-request timeout (fires if the server never answers).
|
||||
pendingCalls[id]?.timeoutJob =
|
||||
wsScope.launch {
|
||||
@@ -716,7 +774,13 @@ object HermesWsClient {
|
||||
removeQueuedMessage(id)
|
||||
call.timeoutJob?.cancel()
|
||||
if (error != null) {
|
||||
call.deferred.completeExceptionally(HermesRpcException(error.message))
|
||||
call.deferred.completeExceptionally(
|
||||
HermesRpcException(
|
||||
message = error.message,
|
||||
code = error.code,
|
||||
data = error.data,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
call.deferred.complete(result)
|
||||
}
|
||||
@@ -764,7 +828,9 @@ object HermesWsClient {
|
||||
params = decoratedParams.mapValues { it.value.toJsonElement() },
|
||||
)
|
||||
val json = OkHttpProvider.json.encodeToString(request)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
|
||||
}
|
||||
var reconnect = false
|
||||
synchronized(outboundLock) {
|
||||
if (method == WsMethods.PROMPT_SUBMIT) {
|
||||
@@ -890,21 +956,23 @@ object HermesWsClient {
|
||||
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
|
||||
|
||||
private fun triggerReplay() {
|
||||
// Arm the hold SYNCHRONOUSLY (still under onOpen's outboundLock) so a
|
||||
// live frame racing the replay coroutine is parked instead of advancing
|
||||
// lastSeenSeq past events the replay would then skip-drop (G2).
|
||||
if (lastSeenSeq.isEmpty() || replayInFlight) return
|
||||
replayJob?.cancel()
|
||||
replayJob =
|
||||
wsScope.launch {
|
||||
fetchReplay()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchReplay() {
|
||||
if (replayInFlight || lastSeenSeq.isEmpty()) return
|
||||
replayInFlight = true
|
||||
val sessionsToReplay = lastSeenSeq.keys().toList()
|
||||
for (sid in sessionsToReplay) {
|
||||
replayHold[sid] = Collections.synchronizedList(mutableListOf())
|
||||
}
|
||||
replayJob?.cancel()
|
||||
replayJob =
|
||||
wsScope.launch {
|
||||
fetchReplay(sessionsToReplay)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchReplay(sessionsToReplay: List<String> = lastSeenSeq.keys().toList()) {
|
||||
try {
|
||||
for (sid in sessionsToReplay) {
|
||||
val lastSeen = lastSeenSeq[sid] ?: continue
|
||||
@@ -926,13 +994,21 @@ object HermesWsClient {
|
||||
}
|
||||
if (resultMap != null) {
|
||||
val epoch = resultMap["epoch"] as? String
|
||||
if (!epoch.isNullOrEmpty()) {
|
||||
if (replayEpoch != null && replayEpoch != epoch) {
|
||||
replayEpoch = epoch
|
||||
lastSeenSeq.clear()
|
||||
continue
|
||||
}
|
||||
replayEpoch = epoch
|
||||
val epochChanged =
|
||||
!epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
|
||||
val truncated = resultMap["truncated"] == true
|
||||
val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt()
|
||||
|
||||
if (epochChanged) replayEpoch = epoch
|
||||
|
||||
if (epochChanged || truncated) {
|
||||
// Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
|
||||
// Partial replay would silently hole the transcript — fast-forward watermark
|
||||
// and request full transcript resync.
|
||||
if (epochChanged) lastSeenSeq.clear()
|
||||
if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
|
||||
parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
|
||||
continue
|
||||
}
|
||||
val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance<Map<String, Any?>>()
|
||||
if (eventsList != null) {
|
||||
@@ -962,25 +1038,28 @@ object HermesWsClient {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Replay failed for session $sid: ${e.message}")
|
||||
parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (sid in sessionsToReplay) {
|
||||
val held = replayHold.remove(sid)
|
||||
if (held != null) {
|
||||
synchronized(held) {
|
||||
for ((heldSeq, heldEvent) in held) {
|
||||
if (heldSeq != null) {
|
||||
val prev = lastSeenSeq[sid] ?: 0
|
||||
if (heldSeq <= prev) continue
|
||||
lastSeenSeq[sid] = heldSeq
|
||||
withContext(NonCancellable) {
|
||||
for (sid in sessionsToReplay) {
|
||||
val held = replayHold.remove(sid)
|
||||
if (held != null) {
|
||||
synchronized(held) {
|
||||
for ((heldSeq, heldEvent) in held) {
|
||||
if (heldSeq != null) {
|
||||
val prev = lastSeenSeq[sid] ?: 0
|
||||
if (heldSeq <= prev) continue
|
||||
lastSeenSeq[sid] = heldSeq
|
||||
}
|
||||
parsedEvents.tryEmit(heldEvent)
|
||||
}
|
||||
parsedEvents.tryEmit(heldEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
replayInFlight = false
|
||||
}
|
||||
replayInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,7 +1144,7 @@ object HermesWsClient {
|
||||
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||
return
|
||||
}
|
||||
val reconnectDelay = currentBackoff
|
||||
val reconnectDelay = nextBackoffDelayMs()
|
||||
currentBackoff =
|
||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||
.toLong()
|
||||
@@ -1092,6 +1171,14 @@ object HermesWsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTerminalAuthClose(
|
||||
code: Int,
|
||||
reason: String,
|
||||
): Boolean =
|
||||
code == 4401 || code == 4403 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
|
||||
// ── Listener ─────────────────────────────────────────────────────────
|
||||
|
||||
private class WsListenerImpl(
|
||||
@@ -1126,7 +1213,7 @@ object HermesWsClient {
|
||||
markQueuedMessageSent(msg)
|
||||
continue
|
||||
}
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
|
||||
if (!webSocket.send(msg)) {
|
||||
recoverRejectedSocket(webSocket)
|
||||
break
|
||||
@@ -1144,8 +1231,8 @@ object HermesWsClient {
|
||||
text: String,
|
||||
) {
|
||||
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
|
||||
lastPongTimestamp = System.currentTimeMillis()
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
|
||||
lastPongTimestamp = monotonicTimeMs()
|
||||
// Resolve any in-flight `request()` awaiting this RPC result/error
|
||||
// (issue #526) before fanning the parsed event out to collectors.
|
||||
val event =
|
||||
@@ -1173,6 +1260,7 @@ object HermesWsClient {
|
||||
if (!sid.isNullOrBlank() && seq != null) {
|
||||
val prev = lastSeenSeq[sid] ?: 0
|
||||
if (seq <= prev) {
|
||||
// Frame is already at or behind watermark — drop duplicate
|
||||
return
|
||||
}
|
||||
val parsed = EventParser.parse(rpc, text)
|
||||
@@ -1203,7 +1291,7 @@ object HermesWsClient {
|
||||
parsed
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse message", e)
|
||||
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
|
||||
WsEvent.Unknown(text)
|
||||
}
|
||||
when (event) {
|
||||
@@ -1219,17 +1307,17 @@ object HermesWsClient {
|
||||
pendingPromptSubmits.isEmpty()
|
||||
) {
|
||||
pendingReply = false
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
}
|
||||
removeQueuedMessage(event.id)
|
||||
resolvePending(event.id, null, event.error)
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
// tryEmit on a DROP_OLDEST flow only returns false when the
|
||||
// buffer is full AND no subscriber is draining; with extraBuffer=512
|
||||
// buffer is full AND no subscriber is draining; with extraBuffer=2048
|
||||
// and always-on init collectors this is unreachable in practice.
|
||||
parsedEvents.tryEmit(event)
|
||||
}
|
||||
@@ -1247,10 +1335,7 @@ object HermesWsClient {
|
||||
closingSocket = webSocket
|
||||
// Do NOT log [reason] — it may carry server-side context.
|
||||
Log.d(TAG, "WebSocket closing: $code")
|
||||
if (code == 4001 || code == 4401 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
) {
|
||||
if (isTerminalAuthClose(code, reason)) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
}
|
||||
webSocket.close(code, reason)
|
||||
@@ -1275,10 +1360,7 @@ object HermesWsClient {
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
stopHealthTracking()
|
||||
if (code == 4001 || code == 4401 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
) {
|
||||
if (isTerminalAuthClose(code, reason)) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
@@ -1307,9 +1389,11 @@ object HermesWsClient {
|
||||
ActiveSessionHolder.clear()
|
||||
stopHealthTracking()
|
||||
val code = response?.code ?: 0
|
||||
if (code == 401 || t.message?.contains(
|
||||
"401",
|
||||
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||
if (code == 401 || code == 4401 || code == 4403 ||
|
||||
t.message?.contains("401") == true ||
|
||||
t.message?.contains("4401") == true ||
|
||||
t.message?.contains("4403") == true ||
|
||||
t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||
@@ -1319,4 +1403,60 @@ object HermesWsClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
|
||||
_connectionStatus.value = status
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatSafeOutgoingFrameLog(
|
||||
id: String,
|
||||
method: String,
|
||||
paramsKeys: Collection<String>?,
|
||||
byteLength: Int,
|
||||
queued: Boolean = false,
|
||||
): String {
|
||||
val prefix = if (queued) "→ (queued)" else "→"
|
||||
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
|
||||
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatSafeQueuedFrameLog(msg: String): String =
|
||||
try {
|
||||
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
|
||||
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
|
||||
} catch (_: Throwable) {
|
||||
"→ (queued) frame bytes=${msg.length}"
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatSafeIncomingFrameLog(text: String): String =
|
||||
try {
|
||||
val element = OkHttpProvider.json.parseToJsonElement(text)
|
||||
if (element is JsonObject) {
|
||||
val id = (element["id"] as? JsonPrimitive)?.content
|
||||
val method = (element["method"] as? JsonPrimitive)?.content
|
||||
val hasResult = element.containsKey("result")
|
||||
val resultKeys =
|
||||
(element["result"] as? JsonObject)?.keys?.joinToString(
|
||||
",",
|
||||
prefix = "[",
|
||||
postfix = "]",
|
||||
)
|
||||
val errorObj = element["error"] as? JsonObject
|
||||
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
|
||||
val sb = StringBuilder("← ")
|
||||
if (id != null) sb.append("id=").append(id).append(" ")
|
||||
if (method != null) sb.append("method=").append(method).append(" ")
|
||||
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
|
||||
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
|
||||
sb.append("bytes=").append(text.length).toString()
|
||||
} else {
|
||||
"← non-object frame bytes=${text.length}"
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
"← frame bytes=${text.length}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
|
||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.serializer
|
||||
|
||||
/**
|
||||
* Interface for polling and observing live session statuses.
|
||||
*/
|
||||
interface SessionLiveStatusSource {
|
||||
suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot?
|
||||
|
||||
val events: Flow<WsEvent>
|
||||
val connectionStatus: StateFlow<ConnectionStatus>
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes untyped RPC responses from `session.active_list` into a [LiveSessionSnapshot].
|
||||
*/
|
||||
object SessionLiveStatusDecoder {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
fun decodeSnapshot(rawResult: Any?): LiveSessionSnapshot? {
|
||||
if (rawResult == null) return null
|
||||
val response =
|
||||
try {
|
||||
decodeResponse(rawResult)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
} ?: return null
|
||||
|
||||
val statusByStoredId = mutableMapOf<String, SessionLiveStatus>()
|
||||
val storedIdByRuntimeId = mutableMapOf<String, String>()
|
||||
|
||||
for (item in response.sessions) {
|
||||
val runtimeId = item.id?.trim()
|
||||
val storedId = item.sessionKey?.trim()
|
||||
if (runtimeId.isNullOrEmpty() || storedId.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
storedIdByRuntimeId[runtimeId] = storedId
|
||||
when (item.status?.trim()?.lowercase()) {
|
||||
"working" -> {
|
||||
statusByStoredId[storedId] = SessionLiveStatus.WORKING
|
||||
}
|
||||
|
||||
"waiting" -> {
|
||||
statusByStoredId[storedId] = SessionLiveStatus.WAITING
|
||||
}
|
||||
|
||||
else -> {
|
||||
// idle, starting, unknown -> no live indicator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LiveSessionSnapshot(
|
||||
statusByStoredId = statusByStoredId,
|
||||
storedIdByRuntimeId = storedIdByRuntimeId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeResponse(result: Any): ActiveSessionsResponse? {
|
||||
val element: JsonElement =
|
||||
when (result) {
|
||||
is JsonElement -> {
|
||||
result
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
anyToJsonElement(result)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(result.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
else -> {
|
||||
val str = result.toString()
|
||||
try {
|
||||
json.parseToJsonElement(str)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (element !is JsonObject || !element.containsKey("sessions")) {
|
||||
return null
|
||||
}
|
||||
return json.decodeFromJsonElement(serializer<ActiveSessionsResponse>(), element)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> {
|
||||
JsonNull
|
||||
}
|
||||
|
||||
is JsonElement -> {
|
||||
value
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
JsonObject(
|
||||
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
|
||||
)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(value.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
is String -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Boolean -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Number -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
else -> {
|
||||
JsonPrimitive(value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
|
||||
*/
|
||||
class HermesSessionLiveStatusSource(
|
||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
||||
HermesWsClient.request(method, params).await()
|
||||
},
|
||||
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
|
||||
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
|
||||
) : SessionLiveStatusSource {
|
||||
override val events: Flow<WsEvent> by lazy { eventsProvider() }
|
||||
override val connectionStatus: StateFlow<ConnectionStatus> by lazy { connectionStatusProvider() }
|
||||
|
||||
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
|
||||
try {
|
||||
val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap())
|
||||
SessionLiveStatusDecoder.decodeSnapshot(raw)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.model.SubagentListResponse
|
||||
import com.m57.hermescontrol.data.model.SubagentTailResponse
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.serializer
|
||||
|
||||
/**
|
||||
* WebSocket RPC repository for subagent operations: `subagent.list` and `subagent.tail` (issue #1089).
|
||||
*/
|
||||
object SubagentRepository {
|
||||
private const val TAG = "SubagentRepository"
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the current active subagent roster for a given [sessionId].
|
||||
*
|
||||
* Returns null if RPC fails or is unsupported by server.
|
||||
*/
|
||||
suspend fun listSubagents(sessionId: String): SubagentListResponse? {
|
||||
if (sessionId.isBlank()) return null
|
||||
return try {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SUBAGENT_LIST,
|
||||
mapOf("session_id" to sessionId),
|
||||
).await()
|
||||
decode<SubagentListResponse>(result)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a rolling live transcript tail for [subagentId].
|
||||
*
|
||||
* Supplies [sessionId] when available so gateway authority checks pass cleanly.
|
||||
* Throws or returns null if unavailable.
|
||||
*/
|
||||
suspend fun tailSubagent(
|
||||
sessionId: String,
|
||||
subagentId: String,
|
||||
maxBytes: Int = 16384,
|
||||
): SubagentTailResponse? {
|
||||
if (subagentId.isBlank()) return null
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"subagent_id" to subagentId,
|
||||
"max_bytes" to maxBytes,
|
||||
)
|
||||
if (sessionId.isNotBlank()) {
|
||||
params["session_id"] = sessionId
|
||||
}
|
||||
val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await()
|
||||
return decode<SubagentTailResponse>(result)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal inline fun <reified T> decode(result: Any?): T? {
|
||||
if (result == null) return null
|
||||
val element: JsonElement =
|
||||
when (result) {
|
||||
is JsonElement -> {
|
||||
result
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
anyToJsonElement(result)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(result.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
else -> {
|
||||
val str = result.toString()
|
||||
try {
|
||||
json.parseToJsonElement(str)
|
||||
} catch (_: Exception) {
|
||||
JsonPrimitive(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
return json.decodeFromJsonElement(serializer<T>(), element)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> {
|
||||
JsonNull
|
||||
}
|
||||
|
||||
is JsonElement -> {
|
||||
value
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
JsonObject(
|
||||
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
|
||||
)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(value.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
is String -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Boolean -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Number -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
else -> {
|
||||
JsonPrimitive(value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.VaultItem
|
||||
import com.m57.hermescontrol.data.model.VaultListResponse
|
||||
import com.m57.hermescontrol.data.model.VaultLockResponse
|
||||
import com.m57.hermescontrol.data.model.VaultSource
|
||||
import com.m57.hermescontrol.data.model.VaultSourceSetResponse
|
||||
import com.m57.hermescontrol.data.model.VaultSourcesResponse
|
||||
import com.m57.hermescontrol.data.model.VaultUnlockResponse
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.serializer
|
||||
|
||||
/**
|
||||
* Repository for Credential Vault RPC operations over WebSocket (issue #1090).
|
||||
*/
|
||||
object VaultRepository {
|
||||
private val json get() = OkHttpProvider.json
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> decode(result: Any?): T? {
|
||||
if (result == null) return null
|
||||
val element: JsonElement =
|
||||
when (result) {
|
||||
is JsonElement -> result
|
||||
is Map<*, *> -> anyToJsonElement(result)
|
||||
is List<*> -> JsonArray(result.map { anyToJsonElement(it) })
|
||||
else -> JsonPrimitive(result.toString())
|
||||
}
|
||||
return json.decodeFromJsonElement(serializer<T>(), element)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> value
|
||||
is Map<*, *> -> JsonObject((value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) })
|
||||
is List<*> -> JsonArray(value.map { anyToJsonElement(it) })
|
||||
is String -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value)
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
|
||||
suspend fun getSources(): List<VaultSource> {
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await()
|
||||
return decode<VaultSourcesResponse>(result)?.sources ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun setSourceEnabled(
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
): Boolean {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.VAULT_SOURCE_SET,
|
||||
mapOf("name" to name, "enabled" to enabled),
|
||||
).await()
|
||||
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
|
||||
}
|
||||
|
||||
suspend fun unlockSource(
|
||||
name: String,
|
||||
password: String,
|
||||
): Boolean {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.VAULT_UNLOCK,
|
||||
mapOf("name" to name, "password" to password),
|
||||
).await()
|
||||
return decode<VaultUnlockResponse>(result)?.unlocked == true
|
||||
}
|
||||
|
||||
suspend fun lockSource(name: String? = null): Boolean {
|
||||
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
|
||||
return decode<VaultLockResponse>(result)?.locked == true
|
||||
}
|
||||
|
||||
suspend fun listItems(): List<VaultItem> {
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_LIST).await()
|
||||
return decode<VaultListResponse>(result)?.items ?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ sealed class WsEvent {
|
||||
val reasoning: String? = null,
|
||||
/** Stored session id captured before background disconnect clears the active mapping. */
|
||||
val storedSessionId: String? = null,
|
||||
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
|
||||
val rawPayload: Map<String, Any?>? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageDone(
|
||||
@@ -301,6 +303,44 @@ sealed class WsEvent {
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Credential Vault prompts (issue #1090) ──────────────────────────
|
||||
|
||||
data class VaultUnlockRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val backend: String? = null,
|
||||
val displayName: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultUnlockExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultSaveLoginRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val origin: String? = null,
|
||||
val site: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultSaveLoginExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultCodeRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val site: String? = null,
|
||||
val hint: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultCodeExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Gateway-level errors ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -355,6 +395,13 @@ sealed class WsEvent {
|
||||
val kind: String = "",
|
||||
) : WsEvent()
|
||||
|
||||
// ── Replay resync (internal) ──────────────────────────────────────────
|
||||
|
||||
/** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */
|
||||
data class TranscriptResyncRequired(
|
||||
val sessionId: String,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Fallback ─────────────────────────────────────────────────────────
|
||||
|
||||
data class Unknown(
|
||||
|
||||
@@ -43,11 +43,24 @@ object WsMethods {
|
||||
const val SUDO_RESPOND = "sudo.respond"
|
||||
const val SECRET_RESPOND = "secret.respond"
|
||||
|
||||
// ── Credential Vault (issue #1090) ────────────────────────────────────
|
||||
const val VAULT_UNLOCK_RESPOND = "vault.unlock.respond"
|
||||
const val VAULT_SAVE_LOGIN_RESPOND = "vault.save_login.respond"
|
||||
const val VAULT_CODE_RESPOND = "vault.code.respond"
|
||||
const val VAULT_LIST = "vault.list"
|
||||
const val VAULT_SOURCES = "vault.sources"
|
||||
const val VAULT_SOURCE_SET = "vault.source.set"
|
||||
const val VAULT_UNLOCK = "vault.unlock"
|
||||
const val VAULT_LOCK = "vault.lock"
|
||||
|
||||
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
|
||||
|
||||
/** Ultra-lightweight liveness check and round-trip latency measurement. */
|
||||
const val PING = "ping"
|
||||
|
||||
/** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */
|
||||
const val GATEWAY_PING = "gateway.ping"
|
||||
|
||||
// ── Commands catalog ──────────────────────────────────────────────────
|
||||
const val COMMANDS_CATALOG = "commands.catalog"
|
||||
const val COMMAND_DISPATCH = "command.dispatch"
|
||||
@@ -70,6 +83,22 @@ object WsMethods {
|
||||
/** Kill a single background process (scoped to the active session). */
|
||||
const val PROCESS_KILL = "process.kill"
|
||||
|
||||
// ── Subagents (issue #1089) ───────────────────────────────────────────
|
||||
|
||||
/** List active subagents and delegations for a session. */
|
||||
const val SUBAGENT_LIST = "subagent.list"
|
||||
|
||||
/** Rolling tail snapshot of a subagent's live execution transcript. */
|
||||
const val SUBAGENT_TAIL = "subagent.tail"
|
||||
|
||||
// ── Connectors (issue #1091) ───────────────────────────────────────────
|
||||
|
||||
/** List available third-party connectors and authorization state for a session. */
|
||||
const val CONNECTORS_LIST = "connectors.list"
|
||||
|
||||
/** Initiate or restart authorization flow for one or more connectors. */
|
||||
const val CONNECTORS_CONNECT = "connectors.connect"
|
||||
|
||||
// ── Billing / subscription (issue #628) ─────────────────────────────
|
||||
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
||||
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
||||
|
||||
@@ -140,6 +140,13 @@ class ChatNotificationService : Service() {
|
||||
showReplyNotification(preview.take(100), null)
|
||||
}
|
||||
|
||||
is WsEvent.VaultUnlockRequest,
|
||||
is WsEvent.VaultSaveLoginRequest,
|
||||
is WsEvent.VaultCodeRequest,
|
||||
-> {
|
||||
showReplyNotification(getString(R.string.notif_input_needed), null)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ data class GroupChatMessage(
|
||||
val isSystem: Boolean = false,
|
||||
val isPass: Boolean = false,
|
||||
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
||||
val tokenCount: Int? = null,
|
||||
val tps: Double? = null,
|
||||
)
|
||||
|
||||
const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -33,6 +33,7 @@ import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class GroupChatUiState(
|
||||
val groupName: String = "",
|
||||
@@ -74,22 +75,22 @@ class GroupChatViewModel(
|
||||
private var activeEpoch: String = ""
|
||||
|
||||
// Map of bot.name -> seen count of non-system messages
|
||||
private val memberWatermarks = mutableMapOf<String, Int>()
|
||||
private val memberWatermarks = ConcurrentHashMap<String, Int>()
|
||||
|
||||
// Map of bot.name -> active session info in this group
|
||||
private val memberSessions = mutableMapOf<String, MemberSession>()
|
||||
private val memberSessions = ConcurrentHashMap<String, MemberSession>()
|
||||
|
||||
// In-flight turn completion deferreds keyed by session_id
|
||||
private val inFlightTurns = mutableMapOf<String, CompletableDeferred<String>>()
|
||||
private val inFlightTurns = ConcurrentHashMap<String, CompletableDeferred<String>>()
|
||||
|
||||
// Map of session_id -> active streaming message ID
|
||||
private val activeStreamMsgId = mutableMapOf<String, String>()
|
||||
private val activeStreamMsgId = ConcurrentHashMap<String, String>()
|
||||
|
||||
// Map of session_id -> bot profile
|
||||
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
|
||||
private val sessionToBot = ConcurrentHashMap<String, ProfileInfo>()
|
||||
|
||||
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
|
||||
private val sessionLastActivity = mutableMapOf<String, Long>()
|
||||
private val sessionLastActivity = ConcurrentHashMap<String, Long>()
|
||||
|
||||
init {
|
||||
if (groupName.isNotBlank()) {
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package com.m57.hermescontrol.ui.bots.group.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||
import com.m57.hermescontrol.ui.common.BotAvatar
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
@Composable
|
||||
fun GroupChatComposer(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputValueChange: (TextFieldValue) -> Unit,
|
||||
groupBots: List<ProfileInfo>,
|
||||
groupName: String,
|
||||
activeSpeaker: String?,
|
||||
isFocused: Boolean,
|
||||
onFocusChanged: (Boolean) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val mentionQuery =
|
||||
remember(inputFieldValue.text, inputFieldValue.selection) {
|
||||
ChatInputPolicy.extractMentionQuery(
|
||||
inputFieldValue.text,
|
||||
inputFieldValue.selection.end,
|
||||
)
|
||||
}
|
||||
|
||||
val showAllOption =
|
||||
remember(mentionQuery, groupBots) {
|
||||
if (mentionQuery == null || groupBots.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
"all".startsWith(mentionQuery, ignoreCase = true) ||
|
||||
"everyone".startsWith(mentionQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
val filteredBots =
|
||||
remember(mentionQuery, groupBots) {
|
||||
if (mentionQuery == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
groupBots.filter { bot ->
|
||||
bot.name.startsWith(mentionQuery, ignoreCase = true) ||
|
||||
bot.effectiveTitle.contains(mentionQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
LazyRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||
.testTag("group_chat_mention_row"),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showAllOption) {
|
||||
item(key = "mention_all") {
|
||||
SuggestionChip(
|
||||
onClick = {
|
||||
onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, "all"))
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "@all",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.testTag("mention_chip_all"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(filteredBots, key = { it.name }) { bot ->
|
||||
SuggestionChip(
|
||||
onClick = {
|
||||
onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, bot.name))
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "@${bot.name}",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
BotAvatar(
|
||||
name = bot.name,
|
||||
avatar = bot.botMeta()?.avatar,
|
||||
size = 18.dp,
|
||||
showPresence = false,
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.testTag("mention_chip_${bot.name}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
|
||||
}
|
||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = onInputValueChange,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.onFocusChanged { onFocusChanged(it.isFocused) }
|
||||
.testTag("group_chat_input"),
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 4,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
decorationBox = { innerTextField ->
|
||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
border =
|
||||
BorderStroke(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color =
|
||||
if (isFocused) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||
},
|
||||
),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment =
|
||||
if (isInputRtl) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
if (inputFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.group_chat_composer_hint,
|
||||
groupName,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.6f,
|
||||
),
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (activeSpeaker != null) {
|
||||
IconButton(
|
||||
onClick = onStop,
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.testTag("group_chat_composer_stop_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = stringResource(R.string.group_chat_action_stop),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = inputFieldValue.text.isNotBlank(),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.testTag("group_chat_send_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
package com.m57.hermescontrol.ui.bots.group.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_BOT_MESSAGES
|
||||
import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_CONTINUATION_PASSES
|
||||
import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_BOT_MESSAGES
|
||||
import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_CONTINUATION_PASSES
|
||||
import com.m57.hermescontrol.ui.bots.group.WARN_MAX_BOT_MESSAGES
|
||||
import com.m57.hermescontrol.ui.bots.group.WARN_MAX_CONTINUATION_PASSES
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun GroupChatSettingsDialog(
|
||||
currentMaxMessages: Int,
|
||||
currentMaxPasses: Int,
|
||||
currentSystemPrompt: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Int, Int, String?) -> Unit,
|
||||
) {
|
||||
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
|
||||
var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) }
|
||||
var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) }
|
||||
var showHighLimitsConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
val isHighLimit =
|
||||
maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES ||
|
||||
maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Max Bot Messages Slider
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_max_messages),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "${maxMessages.roundToInt()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color =
|
||||
if (maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.group_chat_settings_max_messages_desc,
|
||||
maxMessages.roundToInt(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Slider(
|
||||
value = maxMessages,
|
||||
onValueChange = { maxMessages = it },
|
||||
valueRange = 1f..MAX_ALLOWED_BOT_MESSAGES.toFloat(),
|
||||
steps = MAX_ALLOWED_BOT_MESSAGES - 2,
|
||||
modifier = Modifier.testTag("max_messages_slider"),
|
||||
)
|
||||
}
|
||||
|
||||
// Max Continuation Handoffs Slider
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_max_handoffs),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "${maxPasses.roundToInt()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color =
|
||||
if (maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.group_chat_settings_max_handoffs_desc,
|
||||
maxPasses.roundToInt(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Slider(
|
||||
value = maxPasses,
|
||||
onValueChange = { maxPasses = it },
|
||||
valueRange = 0f..MAX_ALLOWED_CONTINUATION_PASSES.toFloat(),
|
||||
steps = MAX_ALLOWED_CONTINUATION_PASSES - 1,
|
||||
modifier = Modifier.testTag("max_handoffs_slider"),
|
||||
)
|
||||
}
|
||||
|
||||
// Live In-Dialog Warning Banner (Option C)
|
||||
AnimatedVisibility(
|
||||
visible = isHighLimit,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f),
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("high_limits_warning_banner"),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.group_chat_settings_high_limits_warning_desc,
|
||||
maxMessages.roundToInt(),
|
||||
maxPasses.roundToInt(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Room Instructions / System Prompt
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_system_prompt),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_system_prompt_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = systemPrompt,
|
||||
onValueChange = { systemPrompt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 80.dp, max = 160.dp)
|
||||
.testTag("room_system_prompt_input"),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (isHighLimit) {
|
||||
showHighLimitsConfirmation = true
|
||||
} else {
|
||||
onSave(
|
||||
maxMessages.roundToInt(),
|
||||
maxPasses.roundToInt(),
|
||||
systemPrompt.trim().ifBlank { null },
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("save_settings_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.group_chat_settings_save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(
|
||||
onClick = {
|
||||
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
|
||||
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
|
||||
systemPrompt = ""
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.group_chat_settings_reset_default))
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (showHighLimitsConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showHighLimitsConfirmation = false },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_settings_high_limits_warning_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.group_chat_settings_high_limits_warning_desc,
|
||||
maxMessages.roundToInt(),
|
||||
maxPasses.roundToInt(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
showHighLimitsConfirmation = false
|
||||
onSave(
|
||||
maxMessages.roundToInt(),
|
||||
maxPasses.roundToInt(),
|
||||
systemPrompt.trim().ifBlank { null },
|
||||
)
|
||||
},
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
modifier = Modifier.testTag("confirm_high_limits_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.group_chat_settings_high_limits_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showHighLimitsConfirmation = false },
|
||||
modifier = Modifier.testTag("dismiss_high_limits_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.group_chat_settings_high_limits_adjust))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package com.m57.hermescontrol.ui.bots.group.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.bots.group.GroupChatToolCall
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
@Composable
|
||||
fun GroupChatToolChip(
|
||||
tool: GroupChatToolCall,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by rememberSaveable(tool.id) { mutableStateOf(false) }
|
||||
val isTerminal =
|
||||
tool.name.equals("terminal", ignoreCase = true) ||
|
||||
tool.name.equals("execute_code", ignoreCase = true)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (tool.isError) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
},
|
||||
),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val icon =
|
||||
when (tool.name.lowercase()) {
|
||||
"terminal", "execute_code" -> Icons.Filled.Terminal
|
||||
"web_search", "search_files" -> Icons.Filled.Search
|
||||
"read_file", "write_file", "patch" -> Icons.Filled.Description
|
||||
else -> Icons.Filled.Build
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = tool.name,
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint =
|
||||
if (tool.isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
val label =
|
||||
if (!tool.summary.isNullOrBlank()) {
|
||||
"${tool.name}: ${tool.summary}"
|
||||
} else {
|
||||
tool.name
|
||||
}
|
||||
val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) }
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (tool.isRunning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(10.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (!tool.command.isNullOrBlank()) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = if (isTerminal) "$ ${tool.command}" else tool.command,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
textDirection = TextDirection.Ltr,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val outputText =
|
||||
when {
|
||||
tool.isRunning -> stringResource(R.string.group_chat_tool_running)
|
||||
!tool.output.isNullOrBlank() -> tool.output.trim()
|
||||
tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success)
|
||||
else -> stringResource(R.string.group_chat_tool_no_output)
|
||||
}
|
||||
val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) }
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = outputText,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
textDirection =
|
||||
if (isOutputRtl) {
|
||||
TextDirection.Rtl
|
||||
} else {
|
||||
TextDirection.Ltr
|
||||
},
|
||||
),
|
||||
color =
|
||||
if (tool.isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 15,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.m57.hermescontrol.ui.bots.group.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.FastForward
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.bots.group.CAPPED_SYSTEM_TEXT
|
||||
import com.m57.hermescontrol.ui.bots.group.GroupChatMessage
|
||||
import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT
|
||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
||||
import com.m57.hermescontrol.ui.chat.formatTimestamp
|
||||
import com.m57.hermescontrol.ui.common.BotAvatar
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GroupMessageCard(
|
||||
message: GroupChatMessage,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
if (message.isSystem) {
|
||||
val displayText =
|
||||
when {
|
||||
message.isPass -> {
|
||||
val name = message.senderDisplayName.ifBlank { message.senderName }
|
||||
stringResource(R.string.group_chat_bot_passed, name)
|
||||
}
|
||||
|
||||
message.text == STOPPED_SYSTEM_TEXT -> {
|
||||
stringResource(R.string.group_chat_stopped_by_user)
|
||||
}
|
||||
|
||||
message.text == CAPPED_SYSTEM_TEXT -> {
|
||||
stringResource(R.string.group_chat_capped_limit)
|
||||
}
|
||||
|
||||
else -> {
|
||||
message.text
|
||||
}
|
||||
}
|
||||
val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) }
|
||||
val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
|
||||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides systemDirection) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (message.isPass) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FastForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = displayText,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
textDirection =
|
||||
if (isSystemRtl) {
|
||||
TextDirection.Rtl
|
||||
} else {
|
||||
TextDirection.Ltr
|
||||
},
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.isUser) {
|
||||
val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) }
|
||||
val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.text,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
textDirection =
|
||||
if (isUserRtl) {
|
||||
TextDirection.Rtl
|
||||
} else {
|
||||
TextDirection.Ltr
|
||||
},
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(null, message.text)),
|
||||
)
|
||||
}
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = stringResource(R.string.content_desc_copy),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text =
|
||||
formatTimestamp(
|
||||
message.timestamp,
|
||||
DateFormat.is24HourFormat(LocalContext.current),
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
val userTokens =
|
||||
message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 }
|
||||
if (userTokens != null && userTokens > 0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tokens,
|
||||
TokenEstimator.formatTokenCount(userTokens),
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) }
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
BotAvatar(
|
||||
name = message.senderName,
|
||||
avatar = message.avatarMeta,
|
||||
size = 32.dp,
|
||||
showPresence = false,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = message.senderDisplayName,
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
textDirection =
|
||||
if (isBotNameRtl) {
|
||||
TextDirection.Rtl
|
||||
} else {
|
||||
TextDirection.Ltr
|
||||
},
|
||||
),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
if (message.toolCalls.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp),
|
||||
) {
|
||||
message.toolCalls.forEach { tool ->
|
||||
GroupChatToolChip(tool = tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_typing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
} else if (message.text.isNotBlank()) {
|
||||
SelectionContainer {
|
||||
MarkdownText(
|
||||
text = message.text,
|
||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!message.isStreaming && message.text.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(null, message.text)),
|
||||
)
|
||||
}
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = stringResource(R.string.content_desc_copy),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text =
|
||||
formatTimestamp(
|
||||
message.timestamp,
|
||||
DateFormat.is24HourFormat(LocalContext.current),
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
val botTokens =
|
||||
message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 }
|
||||
if (botTokens != null && botTokens > 0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tokens,
|
||||
TokenEstimator.formatTokenCount(botTokens),
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
if (message.tps != null && message.tps > 0.0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tps,
|
||||
TokenEstimator.formatTps(message.tps),
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns approval rendering, deduplication, user response submission, and
|
||||
* reconnect/resume pending approval replay.
|
||||
*
|
||||
* Extracted behavior-preservingly from [ChatViewModel].
|
||||
*/
|
||||
class ChatApprovalsDelegate(
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val runtimeSessionId: () -> String?,
|
||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||
private val trackRequest: (id: String, method: String) -> Unit,
|
||||
private val addSystemMessage: (text: String) -> Unit,
|
||||
) {
|
||||
fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
|
||||
val description = event.description ?: event.command ?: "Unknown command"
|
||||
val content = "**Approval Required**\n$description"
|
||||
val msg =
|
||||
ChatMessage(
|
||||
role = MessageRole.SYSTEM,
|
||||
content = content,
|
||||
approvalInfo =
|
||||
ApprovalInfo(
|
||||
command = event.command,
|
||||
description = event.description,
|
||||
patternKeys = event.patternKeys,
|
||||
requestId = event.requestId,
|
||||
choices = event.choices,
|
||||
allowPermanent = event.allowPermanent,
|
||||
smartDenied = event.smartDenied,
|
||||
),
|
||||
)
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
messages = state.messages + msg,
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
// Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render
|
||||
// so the backend knows this client holds the prompt. Fire-and-forget.
|
||||
val requestId = event.requestId
|
||||
val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId
|
||||
if (requestId != null && sessionId != null) {
|
||||
scope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
wsSend(
|
||||
WsMethods.APPROVAL_RECEIVED,
|
||||
mapOf(
|
||||
"session_id" to sessionId,
|
||||
"request_id" to requestId,
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun replayPendingApproval(sessionId: String) {
|
||||
val targetSessionId = runtimeSessionId() ?: sessionId
|
||||
scope.launch(ioDispatcher) {
|
||||
wsSend(
|
||||
WsMethods.APPROVAL_PENDING,
|
||||
mapOf("session_id" to targetSessionId),
|
||||
) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }
|
||||
}
|
||||
}
|
||||
|
||||
fun handleApprovalPendingResult(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*>
|
||||
val first = approvals?.filterIsInstance<Map<*, *>>()?.firstOrNull() ?: return
|
||||
val requestId = first["request_id"] as? String ?: return
|
||||
val sessionId = runtimeSessionId() ?: uiState.value.currentSessionId
|
||||
val alreadyShown =
|
||||
uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
|
||||
if (alreadyShown) return
|
||||
handleApprovalRequest(parseApprovalMap(first, sessionId))
|
||||
}
|
||||
|
||||
fun handleApprovalRespondResult(result: Any?) {
|
||||
val map = result as? Map<*, *>
|
||||
val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0
|
||||
if (resolved > 0) {
|
||||
addSystemMessage("Approval submitted")
|
||||
}
|
||||
}
|
||||
|
||||
fun maybeSurfacePendingApproval(
|
||||
map: Map<*, *>,
|
||||
sessionId: String?,
|
||||
) {
|
||||
val requestId = map["request_id"] as? String
|
||||
val alreadyShown =
|
||||
requestId != null &&
|
||||
uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
|
||||
if (!alreadyShown) {
|
||||
handleApprovalRequest(parseApprovalMap(map, sessionId))
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToApproval(action: String) {
|
||||
val state = uiState.value
|
||||
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
|
||||
val sessionId = runtimeSessionId() ?: state.currentSessionId ?: return
|
||||
// Desktop sends `once` for a single run; legacy mobile sent `approve`
|
||||
// (any non-deny still unblocks, but stay on-spec going forward).
|
||||
val choice = if (action == "approve") "once" else action
|
||||
val requestId = approvalMsg.approvalInfo?.requestId
|
||||
|
||||
// Clear buttons immediately
|
||||
uiState.update { s ->
|
||||
s.copy(
|
||||
messages =
|
||||
s.messages.map {
|
||||
if (it.id == approvalMsg.id) {
|
||||
it.copy(approvalInfo = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"choice" to choice,
|
||||
"all" to false,
|
||||
)
|
||||
if (requestId != null) params["request_id"] = requestId
|
||||
wsSend(
|
||||
WsMethods.APPROVAL_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }
|
||||
// The queue can hold more pendings — surface the next one.
|
||||
replayPendingApproval(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a backend approval map (snake_case, from `approval.pending` or
|
||||
* `session.info` `pending_approval`) into a typed event. Mirrors
|
||||
* [EventParser] defaults so replay and live paths agree.
|
||||
*/
|
||||
internal fun parseApprovalMap(
|
||||
map: Map<*, *>,
|
||||
sessionId: String?,
|
||||
): WsEvent.ApprovalRequest {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance<String>()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>()
|
||||
val allowPermanent = map["allow_permanent"] as? Boolean
|
||||
val allowSession = map["allow_session"] as? Boolean
|
||||
val smartDenied = map["smart_denied"] as? Boolean
|
||||
val choices =
|
||||
rawChoices ?: run {
|
||||
if (smartDenied == true) {
|
||||
listOf("once", "deny")
|
||||
} else {
|
||||
buildList {
|
||||
add("once")
|
||||
if (allowSession != false) {
|
||||
add("session")
|
||||
if (allowPermanent != false) add("always")
|
||||
}
|
||||
add("deny")
|
||||
}
|
||||
}
|
||||
}
|
||||
return WsEvent.ApprovalRequest(
|
||||
command = map["command"] as? String,
|
||||
description = map["description"] as? String,
|
||||
patternKeys = patternKeys,
|
||||
sessionId = sessionId,
|
||||
requestId = map["request_id"] as? String,
|
||||
choices = choices,
|
||||
allowPermanent = allowPermanent,
|
||||
smartDenied = smartDenied,
|
||||
)
|
||||
}
|
||||
@@ -257,6 +257,44 @@ fun ChatBubble(
|
||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
if (message.tokenCount != null && message.tokenCount > 0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = userBubbleTextColor.copy(alpha = 0.4f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tokens,
|
||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||
),
|
||||
color = userBubbleTextColor.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.testTag("bubble_token_count"),
|
||||
)
|
||||
}
|
||||
if (message.tps != null && message.tps > 0.0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = userBubbleTextColor.copy(alpha = 0.4f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tps,
|
||||
TokenEstimator.formatTps(message.tps),
|
||||
),
|
||||
color = userBubbleTextColor.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.testTag("bubble_tps"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChatClarifyDelegate(
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val persistMessage: suspend (ChatMessage, String) -> Unit,
|
||||
private val wsClient: HermesWsClient = HermesWsClient,
|
||||
private val trackRequest: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
fun respondToClarify(option: String) {
|
||||
val clarify = uiState.value.clarifyRequest
|
||||
// Only use synthesized qid when this is a true batch or legacy with explicit qid.
|
||||
val qid =
|
||||
if (clarify != null &&
|
||||
(clarify.questionId != null || clarify.questions.isNotEmpty())
|
||||
) {
|
||||
clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (qid != null && clarify?.resolvedQuestions?.size == 1) {
|
||||
respondToClarifyBatch(mapOf(qid to option))
|
||||
} else {
|
||||
respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option)
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToClarifyBatch(
|
||||
answers: Map<String, String>,
|
||||
singleFallbackAnswer: String? = null,
|
||||
) {
|
||||
val sessionId = uiState.value.currentSessionId ?: return
|
||||
val clarify = uiState.value.clarifyRequest
|
||||
val clarifyId = clarify?.clarifyId
|
||||
val isBatch = !clarify?.questions.isNullOrEmpty()
|
||||
val questions = clarify?.resolvedQuestions.orEmpty()
|
||||
uiState.update { it.copy(clarifyRequest = null) }
|
||||
|
||||
val displayContent =
|
||||
if (questions.size > 1) {
|
||||
questions
|
||||
.mapIndexed { index, q ->
|
||||
val ans = answers[q.qid]?.trim().orEmpty()
|
||||
"${index + 1}. ${ans.ifEmpty { "(Skipped)" }}"
|
||||
}.joinToString("\n")
|
||||
} else {
|
||||
val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty()
|
||||
loneAns
|
||||
}
|
||||
|
||||
val userMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.USER,
|
||||
content = displayContent,
|
||||
)
|
||||
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
messages = state.messages + userMessage,
|
||||
isAgentTyping = true,
|
||||
)
|
||||
}
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
persistMessage(userMessage, sessionId)
|
||||
}
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
if (isBatch) {
|
||||
for (q in questions) {
|
||||
val ans = answers[q.qid]?.trim().orEmpty()
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"response" to ans,
|
||||
"answer" to ans,
|
||||
"question_id" to q.qid,
|
||||
)
|
||||
if (clarifyId != null) {
|
||||
params["clarify_id"] = clarifyId
|
||||
params["request_id"] = clarifyId
|
||||
}
|
||||
wsClient.send(
|
||||
method = WsMethods.CLARIFY_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Legacy single: only include question_id when explicitly present.
|
||||
val qid = clarify?.questionId
|
||||
val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty()
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"response" to ans,
|
||||
"answer" to ans,
|
||||
)
|
||||
if (clarifyId != null) {
|
||||
params["clarify_id"] = clarifyId
|
||||
params["request_id"] = clarifyId
|
||||
}
|
||||
if (qid != null) {
|
||||
params["question_id"] = qid
|
||||
}
|
||||
wsClient.send(
|
||||
method = WsMethods.CLARIFY_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorItem
|
||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorStatus
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.ConnectorRepository
|
||||
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Explicit load phases for connector list rendering.
|
||||
*/
|
||||
sealed interface ConnectorsLoadPhase {
|
||||
data object Initial : ConnectorsLoadPhase
|
||||
|
||||
data object Loading : ConnectorsLoadPhase
|
||||
|
||||
data class Refreshing(
|
||||
val previousItems: List<ConnectorUiItem>,
|
||||
) : ConnectorsLoadPhase
|
||||
|
||||
data class Loaded(
|
||||
val available: Boolean,
|
||||
) : ConnectorsLoadPhase
|
||||
|
||||
data class Error(
|
||||
val error: ConnectorError,
|
||||
val canRetry: Boolean = true,
|
||||
) : ConnectorsLoadPhase
|
||||
|
||||
data class Unavailable(
|
||||
val message: String,
|
||||
) : ConnectorsLoadPhase
|
||||
|
||||
data class UnsupportedRuntime(
|
||||
val message: String,
|
||||
) : ConnectorsLoadPhase
|
||||
|
||||
data class UnsupportedBackend(
|
||||
val message: String,
|
||||
) : ConnectorsLoadPhase
|
||||
|
||||
data class NotOwner(
|
||||
val message: String,
|
||||
) : ConnectorsLoadPhase
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutating action phases for individual connector operations.
|
||||
*/
|
||||
sealed interface ConnectorActionState {
|
||||
data object Idle : ConnectorActionState
|
||||
|
||||
data class Connecting(
|
||||
val slug: String,
|
||||
val isReconnect: Boolean,
|
||||
) : ConnectorActionState
|
||||
|
||||
data class AwaitingAuthorization(
|
||||
val slug: String,
|
||||
) : ConnectorActionState
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable safe UI item representation. Raw connect URLs are strictly omitted.
|
||||
*/
|
||||
data class ConnectorUiItem(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
val isConnected: Boolean,
|
||||
val isEnabled: Boolean,
|
||||
val status: ConnectorStatus,
|
||||
val connectionStatus: String?,
|
||||
) {
|
||||
companion object {
|
||||
fun from(item: ConnectorItem): ConnectorUiItem =
|
||||
ConnectorUiItem(
|
||||
slug = item.connector,
|
||||
name = item.name,
|
||||
isConnected = item.connected,
|
||||
isEnabled = item.enabled,
|
||||
status = item.status,
|
||||
connectionStatus = item.connectionStatus,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient one-shot browser launch event. Redacts URL in [toString].
|
||||
*/
|
||||
data class ConnectBrowserEvent(
|
||||
val slug: String,
|
||||
val url: String,
|
||||
val eventId: Long = System.nanoTime(),
|
||||
val sessionId: String? = null,
|
||||
val generation: Long = 0L,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"ConnectBrowserEvent(slug=$slug, url=[REDACTED], eventId=$eventId, sessionId=$sessionId, generation=$generation)"
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete UI state exposed to the session integrations view.
|
||||
*/
|
||||
data class ChatConnectorsUiState(
|
||||
val isVisible: Boolean = false,
|
||||
val loadPhase: ConnectorsLoadPhase = ConnectorsLoadPhase.Initial,
|
||||
val items: List<ConnectorUiItem> = emptyList(),
|
||||
val actionState: ConnectorActionState = ConnectorActionState.Idle,
|
||||
val browserLaunchEvent: ConnectBrowserEvent? = null,
|
||||
val errorMessage: String? = null,
|
||||
val isOldBackend: Boolean = false,
|
||||
val sessionId: String? = null,
|
||||
val generation: Long = 0L,
|
||||
) {
|
||||
val isLoading: Boolean
|
||||
get() = loadPhase is ConnectorsLoadPhase.Loading || actionState is ConnectorActionState.Connecting
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller / Delegate managing session Integrations (connectors) state,
|
||||
* lifecycle, deduplication, and authorization flows (issue #1091).
|
||||
*/
|
||||
class ChatConnectorsDelegate(
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val repository: ConnectorRepository = ConnectorRepository,
|
||||
private val runtimeSessionId: () -> String? = { ActiveSessionHolder.activeSessionId.value },
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(ChatConnectorsUiState())
|
||||
val uiState: StateFlow<ChatConnectorsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var isForeground: Boolean = false
|
||||
private var sessionGeneration: Long = 0L
|
||||
private var refreshRequestId: Long = 0L
|
||||
private var refreshJob: Job? = null
|
||||
private var connectJob: Job? = null
|
||||
private var pendingRefreshAfterMutation: Boolean = false
|
||||
private var pendingTrailingRefresh: Boolean = false
|
||||
private var pendingBrowserEvent: ConnectBrowserEvent? = null
|
||||
private var browserLaunched: Boolean = false
|
||||
|
||||
/**
|
||||
* Show integrations panel and trigger load if in foreground.
|
||||
*/
|
||||
fun show() {
|
||||
if (_uiState.value.isVisible) return
|
||||
val sid = runtimeSessionId()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isVisible = true,
|
||||
sessionId = sid,
|
||||
generation = sessionGeneration,
|
||||
)
|
||||
}
|
||||
if (isForeground) {
|
||||
if (!sid.isNullOrBlank() && !_uiState.value.isOldBackend) {
|
||||
refresh(force = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide integrations panel, cancelling ongoing jobs, incrementing generation,
|
||||
* and clearing transient state.
|
||||
*/
|
||||
fun hide() {
|
||||
sessionGeneration++
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
connectJob?.cancel()
|
||||
connectJob = null
|
||||
pendingRefreshAfterMutation = false
|
||||
pendingTrailingRefresh = false
|
||||
pendingBrowserEvent = null
|
||||
browserLaunched = false
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isVisible = false,
|
||||
actionState = ConnectorActionState.Idle,
|
||||
browserLaunchEvent = null,
|
||||
generation = sessionGeneration,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle resume hook. Refreshes once when returning from external browser authorization.
|
||||
* Guarded to only execute on true paused -> resume transitions.
|
||||
*/
|
||||
fun onResume() {
|
||||
val wasForeground = isForeground
|
||||
isForeground = true
|
||||
if (wasForeground) return
|
||||
if (!_uiState.value.isVisible) return
|
||||
|
||||
val sid = runtimeSessionId()
|
||||
val pending = pendingBrowserEvent
|
||||
if (pending != null) {
|
||||
pendingBrowserEvent = null
|
||||
if (pending.generation == sessionGeneration &&
|
||||
pending.sessionId == sid &&
|
||||
_uiState.value.actionState is ConnectorActionState.AwaitingAuthorization
|
||||
) {
|
||||
_uiState.update { it.copy(browserLaunchEvent = pending) }
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRefreshAfterMutation || pendingTrailingRefresh) {
|
||||
pendingRefreshAfterMutation = false
|
||||
pendingTrailingRefresh = false
|
||||
refresh(force = true)
|
||||
return
|
||||
}
|
||||
|
||||
val currentAction = _uiState.value.actionState
|
||||
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
|
||||
if (browserLaunched) {
|
||||
// Actual return from external browser authorization: refresh once to confirm authorization
|
||||
browserLaunched = false
|
||||
refresh(force = true)
|
||||
return
|
||||
}
|
||||
// Minted unconsumed event waiting to be launched: do NOT prematurely refresh
|
||||
return
|
||||
}
|
||||
|
||||
// Returned from background: initial load or reload interrupted read if needed and not old backend
|
||||
val currentPhase = _uiState.value.loadPhase
|
||||
val needsCatalogLoad =
|
||||
(
|
||||
currentPhase is ConnectorsLoadPhase.Initial ||
|
||||
currentPhase is ConnectorsLoadPhase.Loading ||
|
||||
currentPhase is ConnectorsLoadPhase.Refreshing
|
||||
) &&
|
||||
refreshJob?.isActive != true &&
|
||||
!_uiState.value.isOldBackend
|
||||
|
||||
if (needsCatalogLoad) {
|
||||
refresh(force = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle pause hook. Retains unconsumed browser event bound to session + generation.
|
||||
*/
|
||||
fun onPause() {
|
||||
isForeground = false
|
||||
val unconsumed = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
|
||||
if (unconsumed != null &&
|
||||
unconsumed.generation == sessionGeneration &&
|
||||
unconsumed.sessionId == runtimeSessionId()
|
||||
) {
|
||||
pendingBrowserEvent = unconsumed
|
||||
}
|
||||
_uiState.update { it.copy(browserLaunchEvent = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify of active session change with generation guard (A -> B -> A).
|
||||
*/
|
||||
fun onActiveSessionChanged(newSessionId: String?) {
|
||||
sessionGeneration++
|
||||
val currentGen = sessionGeneration
|
||||
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
connectJob?.cancel()
|
||||
connectJob = null
|
||||
pendingRefreshAfterMutation = false
|
||||
pendingTrailingRefresh = false
|
||||
pendingBrowserEvent = null
|
||||
browserLaunched = false
|
||||
|
||||
if (newSessionId.isNullOrBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Initial,
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
browserLaunchEvent = null,
|
||||
errorMessage = null,
|
||||
isOldBackend = false,
|
||||
sessionId = null,
|
||||
generation = currentGen,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (_uiState.value.isVisible && isForeground) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Loading,
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
browserLaunchEvent = null,
|
||||
errorMessage = null,
|
||||
isOldBackend = false,
|
||||
sessionId = newSessionId,
|
||||
generation = currentGen,
|
||||
)
|
||||
}
|
||||
executeRefresh(newSessionId, currentGen)
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Initial,
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
browserLaunchEvent = null,
|
||||
errorMessage = null,
|
||||
isOldBackend = false,
|
||||
sessionId = newSessionId,
|
||||
generation = currentGen,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport reconnected notification. Refreshes if visible, foreground, and backend supports connectors.
|
||||
*/
|
||||
fun onTransportReconnected() {
|
||||
if (_uiState.value.isVisible && isForeground && !_uiState.value.isOldBackend) {
|
||||
val sessionId = runtimeSessionId()
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
refresh(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request connector catalog and authorization status.
|
||||
* Deduplicated and coalesced for both normal and forced calls, and blocked in background.
|
||||
*/
|
||||
fun refresh(force: Boolean = false) {
|
||||
if (!_uiState.value.isVisible || !isForeground) return
|
||||
if (_uiState.value.isOldBackend && !force) return
|
||||
|
||||
val sessionId = runtimeSessionId()
|
||||
if (sessionId.isNullOrBlank()) return
|
||||
|
||||
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
|
||||
pendingRefreshAfterMutation = true
|
||||
return
|
||||
}
|
||||
|
||||
if (refreshJob?.isActive == true) {
|
||||
if (force) {
|
||||
pendingTrailingRefresh = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val gen = sessionGeneration
|
||||
val previousItems = _uiState.value.items
|
||||
_uiState.update { current ->
|
||||
if (previousItems.isEmpty()) {
|
||||
current.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Loading,
|
||||
sessionId = sessionId,
|
||||
generation = gen,
|
||||
)
|
||||
} else {
|
||||
current.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Refreshing(previousItems),
|
||||
sessionId = sessionId,
|
||||
generation = gen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
executeRefresh(sessionId, gen)
|
||||
}
|
||||
|
||||
private fun executeRefresh(
|
||||
targetSessionId: String,
|
||||
generation: Long,
|
||||
) {
|
||||
val requestId = ++refreshRequestId
|
||||
refreshJob =
|
||||
scope.launch {
|
||||
var currentSession = targetSessionId
|
||||
try {
|
||||
var result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
|
||||
|
||||
// Bounded NOT_OWNER reconciliation: re-read active session once (READ ONLY)
|
||||
if (result is ConnectorListResult.Error && result.error is ConnectorError.NotOwner) {
|
||||
val freshSession = runtimeSessionId()
|
||||
if (!freshSession.isNullOrBlank() && freshSession != currentSession) {
|
||||
currentSession = freshSession
|
||||
result = withContext(ioDispatcher) { repository.listConnectors(currentSession) }
|
||||
}
|
||||
}
|
||||
|
||||
// Check target sid AND generation AND request token AND visibility before applying
|
||||
val activeSid = runtimeSessionId()
|
||||
if (requestId != refreshRequestId ||
|
||||
generation != sessionGeneration ||
|
||||
currentSession != activeSid ||
|
||||
!_uiState.value.isVisible
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is ConnectorListResult.Success -> {
|
||||
if (!result.available) {
|
||||
pendingRefreshAfterMutation = false
|
||||
pendingTrailingRefresh = false
|
||||
pendingBrowserEvent = null
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
loadPhase =
|
||||
ConnectorsLoadPhase.Unavailable(
|
||||
"Connectors are unavailable for this session.",
|
||||
),
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
browserLaunchEvent = null,
|
||||
errorMessage = null,
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val uiItems = result.connectors.map { ConnectorUiItem.from(it) }
|
||||
_uiState.update { current ->
|
||||
val currentAction = current.actionState
|
||||
val updatedAction =
|
||||
if (currentAction is ConnectorActionState.AwaitingAuthorization) {
|
||||
val matchingItem = uiItems.find { it.slug == currentAction.slug }
|
||||
// End authorization if connector removed from catalog or now connected
|
||||
if (matchingItem == null || matchingItem.isConnected) {
|
||||
ConnectorActionState.Idle
|
||||
} else {
|
||||
currentAction
|
||||
}
|
||||
} else {
|
||||
currentAction
|
||||
}
|
||||
|
||||
current.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Loaded(result.available),
|
||||
items = uiItems,
|
||||
actionState = updatedAction,
|
||||
isOldBackend = false,
|
||||
errorMessage = null, // error clears on refresh success
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorListResult.Error -> {
|
||||
when (val err = result.error) {
|
||||
is ConnectorError.UnsupportedBackend -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
|
||||
isOldBackend = true,
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorError.Unavailable -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorError.UnsupportedRuntime -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorError.NotOwner -> {
|
||||
val notOwnerMsg =
|
||||
"Session is not owned by this transport. Please reconnect the session."
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase =
|
||||
ConnectorsLoadPhase.NotOwner(
|
||||
notOwnerMsg,
|
||||
),
|
||||
items = emptyList(),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Error(err, canRetry = true),
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase =
|
||||
ConnectorsLoadPhase.Error(
|
||||
ConnectorError.NetworkError(t.message ?: "Failed to load connectors"),
|
||||
canRetry = true,
|
||||
),
|
||||
sessionId = currentSession,
|
||||
generation = generation,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
refreshJob = null
|
||||
if (pendingTrailingRefresh &&
|
||||
generation == sessionGeneration &&
|
||||
_uiState.value.isVisible
|
||||
) {
|
||||
if (isForeground) {
|
||||
pendingTrailingRefresh = false
|
||||
refresh(force = true)
|
||||
}
|
||||
} else {
|
||||
pendingTrailingRefresh = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate authorization flow for connector [slug].
|
||||
* Guarded by visibility, foreground state, catalog readiness, and item enabled status.
|
||||
* Never replays connect mutations into a different session.
|
||||
*/
|
||||
fun connect(
|
||||
slug: String,
|
||||
reconnect: Boolean = false,
|
||||
) {
|
||||
if (!_uiState.value.isVisible || !isForeground) return
|
||||
|
||||
if (_uiState.value.isOldBackend || _uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) {
|
||||
_uiState.update { it.copy(errorMessage = "Connectors are not supported by this backend.") }
|
||||
return
|
||||
}
|
||||
|
||||
val currentPhase = _uiState.value.loadPhase
|
||||
if (currentPhase !is ConnectorsLoadPhase.Loaded && currentPhase !is ConnectorsLoadPhase.Refreshing) {
|
||||
_uiState.update { it.copy(errorMessage = "Cannot connect: connectors catalog is not ready.") }
|
||||
return
|
||||
}
|
||||
|
||||
val targetSession = runtimeSessionId()
|
||||
if (targetSession.isNullOrBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "No active session.") }
|
||||
return
|
||||
}
|
||||
|
||||
if (!ConnectorRepository.isValidSlug(slug)) {
|
||||
_uiState.update { it.copy(errorMessage = "Invalid connector slug.") }
|
||||
return
|
||||
}
|
||||
|
||||
val catalogItem = _uiState.value.items.firstOrNull { it.slug == slug }
|
||||
if (catalogItem == null) {
|
||||
_uiState.update { it.copy(errorMessage = "Connector '$slug' not found in catalog.") }
|
||||
return
|
||||
}
|
||||
if (!catalogItem.isEnabled) {
|
||||
_uiState.update { it.copy(errorMessage = "Connector '$slug' is disabled.") }
|
||||
return
|
||||
}
|
||||
|
||||
if (_uiState.value.actionState is ConnectorActionState.Connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any prior in-flight read before starting mutation
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
pendingTrailingRefresh = false
|
||||
|
||||
val gen = sessionGeneration
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.Connecting(slug, reconnect),
|
||||
errorMessage = null,
|
||||
sessionId = targetSession,
|
||||
generation = gen,
|
||||
)
|
||||
}
|
||||
|
||||
connectJob =
|
||||
scope.launch {
|
||||
var needsRefresh = false
|
||||
try {
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
repository.connect(targetSession, listOf(slug), reconnect)
|
||||
}
|
||||
|
||||
// Verify generation, session equality, and visibility before applying
|
||||
val activeSid = runtimeSessionId()
|
||||
if (gen != sessionGeneration ||
|
||||
targetSession != activeSid ||
|
||||
!_uiState.value.isVisible
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is ConnectorConnectResult.Success -> {
|
||||
// Require exact slug match; never fallback to unrelated connector
|
||||
val item = result.results.firstOrNull { it.connector == slug }
|
||||
if (item == null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = "Connector '$slug' not found in response.",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
when (item.status) {
|
||||
ConnectorConnectStatus.ACTIVE -> {
|
||||
_uiState.update { it.copy(actionState = ConnectorActionState.Idle) }
|
||||
needsRefresh = true
|
||||
}
|
||||
|
||||
ConnectorConnectStatus.INITIATED -> {
|
||||
val safeUrl = item.safeConnectUrl
|
||||
if (safeUrl != null && ConnectorUrlValidator.isValidHttpsUrl(safeUrl)) {
|
||||
val event =
|
||||
ConnectBrowserEvent(
|
||||
slug = slug,
|
||||
url = safeUrl,
|
||||
sessionId = targetSession,
|
||||
generation = gen,
|
||||
)
|
||||
browserLaunched = false
|
||||
if (isForeground) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.AwaitingAuthorization(slug),
|
||||
browserLaunchEvent = event,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// In background: hold transiently for foreground resume
|
||||
pendingBrowserEvent = event
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.AwaitingAuthorization(slug),
|
||||
browserLaunchEvent = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unsafe or absent URL is a retryable generic failure
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = "Invalid or unsafe authorization URL received.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConnectorConnectStatus.FAILED -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = "Connector authorization failed. Please try again.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = "Connector operation returned unexpected status.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorConnectResult.Error -> {
|
||||
when (val err = result.error) {
|
||||
is ConnectorError.UnsupportedBackend -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
isOldBackend = true,
|
||||
items = emptyList(),
|
||||
errorMessage = err.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorError.Unavailable -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.Unavailable(err.message),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
items = emptyList(),
|
||||
errorMessage = err.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorError.UnsupportedRuntime -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
items = emptyList(),
|
||||
errorMessage = err.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectorError.NotOwner -> {
|
||||
val msg = "Session is not owned by this transport. Please reconnect the session."
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loadPhase = ConnectorsLoadPhase.NotOwner(msg),
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = msg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = err.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
_uiState.update {
|
||||
if (it.actionState is ConnectorActionState.Connecting) {
|
||||
it.copy(actionState = ConnectorActionState.Idle)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = t.message ?: "Connector connection failed.",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
connectJob = null
|
||||
_uiState.update {
|
||||
if (it.actionState is ConnectorActionState.Connecting) {
|
||||
it.copy(actionState = ConnectorActionState.Idle)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
val shouldRefresh = needsRefresh || pendingRefreshAfterMutation
|
||||
if (shouldRefresh &&
|
||||
gen == sessionGeneration &&
|
||||
_uiState.value.isVisible
|
||||
) {
|
||||
if (isForeground) {
|
||||
pendingRefreshAfterMutation = false
|
||||
refresh(force = true)
|
||||
} else {
|
||||
pendingRefreshAfterMutation = true
|
||||
}
|
||||
} else if (gen != sessionGeneration || !_uiState.value.isVisible) {
|
||||
pendingRefreshAfterMutation = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume transient browser launch event (legacy no-arg API).
|
||||
*/
|
||||
fun consumeBrowserEvent() {
|
||||
val currentSid = runtimeSessionId()
|
||||
val event = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent
|
||||
if (event != null &&
|
||||
isForeground &&
|
||||
_uiState.value.isVisible &&
|
||||
event.generation == sessionGeneration &&
|
||||
event.sessionId == currentSid
|
||||
) {
|
||||
browserLaunched = true
|
||||
}
|
||||
pendingBrowserEvent = null
|
||||
_uiState.update { it.copy(browserLaunchEvent = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume transient browser launch event validating identity, session, generation, and foreground state.
|
||||
*/
|
||||
fun consumeBrowserEvent(eventId: Long): ConnectBrowserEvent? {
|
||||
var consumed: ConnectBrowserEvent? = null
|
||||
val currentSid = runtimeSessionId()
|
||||
val pending = pendingBrowserEvent
|
||||
if (pending != null && pending.eventId == eventId) {
|
||||
if (isForeground &&
|
||||
_uiState.value.isVisible &&
|
||||
pending.generation == sessionGeneration &&
|
||||
pending.sessionId == currentSid
|
||||
) {
|
||||
consumed = pending
|
||||
pendingBrowserEvent = null
|
||||
} else {
|
||||
pendingBrowserEvent = null
|
||||
}
|
||||
}
|
||||
_uiState.update { current ->
|
||||
val event = current.browserLaunchEvent
|
||||
if (event != null && event.eventId == eventId) {
|
||||
if (isForeground &&
|
||||
current.isVisible &&
|
||||
event.generation == sessionGeneration &&
|
||||
event.sessionId == currentSid
|
||||
) {
|
||||
consumed = event
|
||||
current.copy(browserLaunchEvent = null)
|
||||
} else {
|
||||
// Invalid context: clear stale event and reject handoff
|
||||
current.copy(browserLaunchEvent = null)
|
||||
}
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
if (consumed != null) {
|
||||
browserLaunched = true
|
||||
}
|
||||
return consumed
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely take transient browser launch event if valid.
|
||||
*/
|
||||
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? {
|
||||
val targetId =
|
||||
eventId
|
||||
?: _uiState.value.browserLaunchEvent?.eventId
|
||||
?: pendingBrowserEvent?.eventId
|
||||
?: return null
|
||||
return consumeBrowserEvent(targetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error launching the external browser intent.
|
||||
*/
|
||||
fun launchError(message: String) {
|
||||
pendingBrowserEvent = null
|
||||
browserLaunched = false
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
browserLaunchEvent = null,
|
||||
actionState = ConnectorActionState.Idle,
|
||||
errorMessage = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onLaunchError(message: String) = launchError(message)
|
||||
|
||||
/**
|
||||
* Clear active error message.
|
||||
*/
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown ongoing coroutines and invalidate state generation.
|
||||
*/
|
||||
fun destroy() {
|
||||
sessionGeneration++
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
connectJob?.cancel()
|
||||
connectJob = null
|
||||
pendingRefreshAfterMutation = false
|
||||
pendingTrailingRefresh = false
|
||||
pendingBrowserEvent = null
|
||||
browserLaunched = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Scoped ViewModel managing session connectors / integrations lifecycle (issue #1091).
|
||||
*
|
||||
* Hosts [ChatConnectorsDelegate] across configuration changes, wires [ActiveSessionHolder]
|
||||
* and server/profile context transitions to invalidate context on changes, and forwards
|
||||
* lifecycle and browser launch events safely.
|
||||
*/
|
||||
class ChatConnectorsViewModel(
|
||||
delegate: ChatConnectorsDelegate? = null,
|
||||
) : ViewModel() {
|
||||
private val delegateInstance: ChatConnectorsDelegate =
|
||||
delegate ?: ChatConnectorsDelegate(
|
||||
scope = viewModelScope,
|
||||
runtimeSessionId = { ActiveSessionHolder.activeSessionId.value },
|
||||
)
|
||||
|
||||
val uiState: StateFlow<ChatConnectorsUiState> = delegateInstance.uiState
|
||||
|
||||
init {
|
||||
var lastContext: Triple<String?, String?, String?>? = null
|
||||
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
ActiveSessionHolder.activeSessionId,
|
||||
AuthManager.activeProfileId,
|
||||
AuthManager.baseUrlFlow,
|
||||
) { sessionId, profileId, baseUrl ->
|
||||
Triple(sessionId, profileId, baseUrl)
|
||||
}.collect { current ->
|
||||
if (lastContext == null || current != lastContext) {
|
||||
lastContext = current
|
||||
delegateInstance.onActiveSessionChanged(current.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
HermesWsClient.connectionStatus
|
||||
.filter { it == ConnectionStatus.CONNECTED }
|
||||
.collect {
|
||||
delegateInstance.onTransportReconnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun show() = delegateInstance.show()
|
||||
|
||||
fun hide() = delegateInstance.hide()
|
||||
|
||||
fun onResume() = delegateInstance.onResume()
|
||||
|
||||
fun onPause() = delegateInstance.onPause()
|
||||
|
||||
fun refresh(force: Boolean = false) = delegateInstance.refresh(force)
|
||||
|
||||
fun connect(
|
||||
slug: String,
|
||||
reconnect: Boolean = false,
|
||||
) = delegateInstance.connect(slug, reconnect)
|
||||
|
||||
fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? = delegateInstance.takeBrowserEvent(eventId)
|
||||
|
||||
fun launchError(message: String) = delegateInstance.launchError(message)
|
||||
|
||||
fun clearError() = delegateInstance.clearError()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
delegateInstance.destroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs.
|
||||
*
|
||||
* Extracted behavior-preservingly from [ChatViewModel] (issue #524).
|
||||
*/
|
||||
class ChatCredentialPromptsDelegate(
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||
private val trackRequest: (id: String, method: String) -> Unit,
|
||||
) {
|
||||
/**
|
||||
* The agent needs the user's sudo password. Surface a secure dialog and
|
||||
* reply via sudo.respond.
|
||||
*/
|
||||
fun handleSudoRequest(event: WsEvent.SudoRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend sudo timeout (120s) — clear only the matching dialog so a late
|
||||
* expire for an old prompt never kills the current one.
|
||||
*/
|
||||
fun handleSudoExpire(event: WsEvent.SudoExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.sudoPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(sudoPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent needs a secret value (token/password). Surface a secure dialog
|
||||
* and reply via secret.respond.
|
||||
*/
|
||||
fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
secretPrompt =
|
||||
SecretPromptUi(
|
||||
event.requestId,
|
||||
event.sessionId,
|
||||
event.envVar,
|
||||
event.prompt,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend secret timeout — match-only clear like [handleSudoExpire].
|
||||
*/
|
||||
fun handleSecretExpire(event: WsEvent.SecretExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.secretPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(secretPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty password (desktop parity). Backend treats empty sudo as
|
||||
* failed sudo (no command runs), unblocking the turn instantly.
|
||||
*/
|
||||
fun dismissSudo() {
|
||||
val prompt = uiState.value.sudoPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(sudoPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SUDO_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty value (desktop parity). Backend secret_cb returns
|
||||
* skipped=True on empty, unblocking the turn instantly.
|
||||
*/
|
||||
fun dismissSecret() {
|
||||
val prompt = uiState.value.secretPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(secretPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"value" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SECRET_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's sudo password back to the gateway. Clear prompt immediately,
|
||||
* then fire the RPC.
|
||||
*/
|
||||
fun respondToSudo(password: String) {
|
||||
val prompt = uiState.value.sudoPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (password.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(sudoPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to password,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SUDO_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user's secret value back to the gateway.
|
||||
*/
|
||||
fun respondToSecret(value: String) {
|
||||
val prompt = uiState.value.secretPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (value.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(secretPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"value" to value,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.SECRET_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vault prompts (issue #1090) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The agent needs to unlock a password manager (1Password, Bitwarden).
|
||||
* Surface an interactive prompt card in chat and reply via vault.unlock.respond.
|
||||
*/
|
||||
fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
vaultUnlockPrompt =
|
||||
VaultUnlockPromptUi(
|
||||
requestId = event.requestId,
|
||||
sessionId = event.sessionId,
|
||||
backend = event.backend,
|
||||
displayName = event.displayName,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultUnlockPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultUnlockPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/dismiss → send empty password (keeps manager locked, unblocks turn).
|
||||
*/
|
||||
fun dismissVaultUnlock() {
|
||||
val prompt = uiState.value.vaultUnlockPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(vaultUnlockPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToVaultUnlock(password: String) {
|
||||
val prompt = uiState.value.vaultUnlockPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (password.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(vaultUnlockPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to password,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent captured credentials on a login page and asks whether to store them in the vault.
|
||||
*/
|
||||
fun handleVaultSaveLoginRequest(event: WsEvent.VaultSaveLoginRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
vaultSaveLoginPrompt =
|
||||
VaultSaveLoginPromptUi(
|
||||
requestId = event.requestId,
|
||||
sessionId = event.sessionId,
|
||||
origin = event.origin,
|
||||
site = event.site,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultSaveLoginPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultSaveLoginPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline saving → send empty login (backend treats empty login as declined).
|
||||
*/
|
||||
fun dismissVaultSaveLogin() {
|
||||
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"login" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToVaultSaveLogin(
|
||||
identifier: String,
|
||||
password: String,
|
||||
) {
|
||||
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (identifier.isBlank() || password.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val loginJson =
|
||||
buildJsonObject {
|
||||
put("identifier", identifier)
|
||||
put("password", password)
|
||||
}.toString()
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"login" to loginJson,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The site requested a 2FA/MFA verification code.
|
||||
*/
|
||||
fun handleVaultCodeRequest(event: WsEvent.VaultCodeRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
vaultCodePrompt =
|
||||
VaultCodePromptUi(
|
||||
requestId = event.requestId,
|
||||
sessionId = event.sessionId,
|
||||
site = event.site,
|
||||
hint = event.hint,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultCodePrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultCodePrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip entering code → send empty code.
|
||||
*/
|
||||
fun dismissVaultCode() {
|
||||
val prompt = uiState.value.vaultCodePrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
|
||||
uiState.update { it.copy(vaultCodePrompt = null) }
|
||||
if (sessionId == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"code" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_CODE_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
||||
}
|
||||
}
|
||||
|
||||
fun respondToVaultCode(code: String) {
|
||||
val prompt = uiState.value.vaultCodePrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
|
||||
if (code.isBlank()) return
|
||||
|
||||
uiState.update { it.copy(vaultCodePrompt = null) }
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"code" to code,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
WsMethods.VAULT_CODE_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,33 +25,66 @@ import androidx.compose.ui.unit.sp
|
||||
*/
|
||||
const val TOOL_CALL_DIVIDER_INTERVAL = 5
|
||||
|
||||
private const val MAX_ITERATIONS_SYSTEM_MARKER =
|
||||
"You've reached the maximum number of tool-calling iterations allowed."
|
||||
|
||||
/**
|
||||
* Returns the message indices that complete a tool-call milestone — a [MessageRole.TOOL]
|
||||
* A genuine user message that establishes a new turn boundary.
|
||||
* Timeline markers (`displayKind != null`) and synthetic system rows
|
||||
* (such as max-iterations nudges) ride with `role == MessageRole.USER`
|
||||
* in storage but belong to the surrounding agent turn.
|
||||
*/
|
||||
internal fun ChatMessage.isUserTurnBoundary(): Boolean =
|
||||
role == MessageRole.USER &&
|
||||
displayKind == null &&
|
||||
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||
|
||||
/**
|
||||
* Returns the message IDs 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
|
||||
* The counter resets at each genuine user turn boundary ([isUserTurnBoundary]), 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>()
|
||||
fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
|
||||
val milestones = mutableMapOf<String, Int>()
|
||||
var toolCount = 0
|
||||
messages.forEachIndexed { index, message ->
|
||||
when (message.role) {
|
||||
MessageRole.USER -> {
|
||||
messages.forEach { message ->
|
||||
when {
|
||||
message.isUserTurnBoundary() -> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
MessageRole.TOOL -> {
|
||||
message.role == MessageRole.TOOL -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[message.id] = toolCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return milestones
|
||||
}
|
||||
|
||||
/**
|
||||
* Index-based milestone map for position-based callers and backwards compatibility.
|
||||
*/
|
||||
fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
|
||||
val milestones = mutableMapOf<Int, Int>()
|
||||
var toolCount = 0
|
||||
messages.forEachIndexed { index, message ->
|
||||
when {
|
||||
message.isUserTurnBoundary() -> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
message.role == MessageRole.TOOL -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[index] = toolCount
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
return milestones
|
||||
|
||||
@@ -79,6 +79,10 @@ data class ChatMessage(
|
||||
* When present, rendered at the bottom beside the copy button.
|
||||
*/
|
||||
val finishTimestamp: Long? = null,
|
||||
/** Token count used by this message (persisted to SQLite). */
|
||||
val tokenCount: Int? = null,
|
||||
/** Tokens per second generation speed (persisted to SQLite). */
|
||||
val tps: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -124,6 +128,7 @@ data class SubagentIndicator(
|
||||
val logs: List<SubagentLogLine> = emptyList(),
|
||||
val durationSeconds: Double? = null,
|
||||
val model: String? = null,
|
||||
val lastEventTimestamp: Long = 0L,
|
||||
) {
|
||||
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
||||
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
||||
@@ -133,6 +138,20 @@ data class SubagentIndicator(
|
||||
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
|
||||
}
|
||||
|
||||
/**
|
||||
* State for inspecting a live subagent's rolling execution transcript (issue #1089).
|
||||
*/
|
||||
data class SubagentTranscriptUiState(
|
||||
val subagentId: String,
|
||||
val text: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isTruncated: Boolean = false,
|
||||
val bytesRead: Long? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isEmpty: Boolean get() = text.isEmpty() && !isLoading && error == null
|
||||
}
|
||||
|
||||
enum class MessageRole {
|
||||
USER,
|
||||
ASSISTANT,
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
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.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Tracks an in-flight `config.set key=model` RPC and the model it would replace
|
||||
* so confirmation dialog dismiss can roll back optimistically.
|
||||
*/
|
||||
private data class ActiveModelSwitch(
|
||||
val spec: String,
|
||||
val previousModel: String?,
|
||||
val sequence: Long,
|
||||
)
|
||||
|
||||
private data class ActiveFastSwitch(
|
||||
val targetFast: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Owns model picker loading/caching, in-session model switches, model capabilities,
|
||||
* pinned model toggling, and expensive model switch confirmation dialogs.
|
||||
*
|
||||
* Extracted behavior-preservingly from [ChatViewModel] (issue #589).
|
||||
*/
|
||||
class ChatModelSwitchDelegate(
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val runtimeSessionId: () -> String?,
|
||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||
private val trackRequest: (id: String, method: String) -> Unit,
|
||||
private val addAssistantMessage: (text: String) -> Unit,
|
||||
private val handleSlashCommand: (command: String) -> Unit,
|
||||
private val fetchContextUsage: () -> Unit,
|
||||
private val onModelSwitchInitiated: () -> Unit = {},
|
||||
private val getModelOptionsCall: suspend (
|
||||
refresh: Boolean,
|
||||
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
|
||||
{ refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh) } },
|
||||
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
|
||||
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
|
||||
) {
|
||||
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
|
||||
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
|
||||
private val fastRejectedModels = ConcurrentHashMap.newKeySet<String>()
|
||||
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
|
||||
private var optimisticPreviousModel: String? = null
|
||||
private var lastConfirmedModel: String? = null
|
||||
private var unconfirmedTargetModel: String? = null
|
||||
private var modelSwitchSequence: Long = 0L
|
||||
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
||||
|
||||
fun isModelPickerCommand(command: String): Boolean {
|
||||
val trimmed = command.trim()
|
||||
if (!trimmed.startsWith("/", ignoreCase = true)) return false
|
||||
val body = trimmed.removePrefix("/").trimStart()
|
||||
return body.equals("model", ignoreCase = true) ||
|
||||
(
|
||||
body.startsWith("model ", ignoreCase = true) &&
|
||||
body.substringAfter("model").trim().isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
fun preloadModelOptions() {
|
||||
scope.launch(ioDispatcher) {
|
||||
val result = getModelOptionsCall(false)
|
||||
if (result is NetworkResult.Success) {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
uiState.update { it.copy(modelPickerPinned = getPinnedModels()) }
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openModelPicker() {
|
||||
val hasCached = cachedModelOptions.isNotEmpty()
|
||||
uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = true,
|
||||
modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(),
|
||||
modelPickerPinned = getPinnedModels(),
|
||||
modelPickerLoading = !hasCached,
|
||||
)
|
||||
}
|
||||
if (!hasCached) {
|
||||
refreshModelOptions()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshModelOptions() {
|
||||
uiState.update { it.copy(modelPickerLoading = true) }
|
||||
scope.launch(ioDispatcher) {
|
||||
val result = getModelOptionsCall(true)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = cachedModelOptions,
|
||||
modelPickerLoading = false,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerLoading = false,
|
||||
errorMessage = "Failed to load models: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeModelPicker() {
|
||||
uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
|
||||
}
|
||||
|
||||
fun togglePinModel(
|
||||
providerSlug: String,
|
||||
modelName: String,
|
||||
) {
|
||||
val currentPinned = getPinnedModels().toMutableList()
|
||||
val target = PinnedModel(providerSlug, modelName)
|
||||
if (currentPinned.contains(target)) {
|
||||
currentPinned.remove(target)
|
||||
} else {
|
||||
currentPinned.add(target)
|
||||
}
|
||||
savePinnedModels(currentPinned)
|
||||
uiState.update { it.copy(modelPickerPinned = currentPinned) }
|
||||
}
|
||||
|
||||
fun consumeOptimisticPreviousModel(): String? = optimisticPreviousModel.also { optimisticPreviousModel = null }
|
||||
|
||||
fun sendSlashModel(
|
||||
provider: String,
|
||||
model: String,
|
||||
) {
|
||||
val targetModelLabel = "$provider/$model"
|
||||
val isDifferent = !targetModelLabel.equals(uiState.value.currentSessionModel, ignoreCase = true)
|
||||
if (!isDifferent) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = false,
|
||||
modelPickerLoading = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (optimisticPreviousModel == null) {
|
||||
optimisticPreviousModel = uiState.value.currentSessionModel
|
||||
}
|
||||
unconfirmedTargetModel = targetModelLabel
|
||||
uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = false,
|
||||
modelPickerLoading = false,
|
||||
currentSessionModel = targetModelLabel,
|
||||
fullContextTokens = null,
|
||||
)
|
||||
}
|
||||
onModelSwitchInitiated()
|
||||
syncCurrentModelCapabilities()
|
||||
handleSlashCommand("/model $model --provider $provider --session")
|
||||
}
|
||||
|
||||
fun handleModelSwitch(
|
||||
command: String,
|
||||
confirmExpensive: Boolean = false,
|
||||
) {
|
||||
val sessionId = runtimeSessionId()
|
||||
if (sessionId == null) {
|
||||
addAssistantMessage("No active session. Use `/new` to create one.")
|
||||
return
|
||||
}
|
||||
val spec =
|
||||
if (command.startsWith("/model", ignoreCase = true)) {
|
||||
command.substring(6).trim()
|
||||
} else {
|
||||
command.trim()
|
||||
}
|
||||
val previousModel = lastConfirmedModel ?: optimisticPreviousModel ?: uiState.value.currentSessionModel
|
||||
// Retain optimisticPreviousModel for consumeOptimisticPreviousModel() when SessionInfo arrives.
|
||||
if (optimisticPreviousModel == null && !spec.equals(uiState.value.currentSessionModel, ignoreCase = true)) {
|
||||
optimisticPreviousModel = uiState.value.currentSessionModel
|
||||
uiState.update { it.copy(fullContextTokens = null) }
|
||||
onModelSwitchInitiated()
|
||||
}
|
||||
unconfirmedTargetModel = spec
|
||||
val switchSeq = ++modelSwitchSequence
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"key" to "model",
|
||||
"value" to spec,
|
||||
"session_id" to sessionId,
|
||||
)
|
||||
if (confirmExpensive) {
|
||||
params["confirm_expensive_model"] = true
|
||||
}
|
||||
scope.launch(ioDispatcher) {
|
||||
wsSend(
|
||||
WsMethods.CONFIG_SET,
|
||||
params,
|
||||
) { id ->
|
||||
trackRequest(id, WsMethods.CONFIG_SET)
|
||||
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleConfigSetResult(
|
||||
id: String,
|
||||
result: Any?,
|
||||
) {
|
||||
val pending = pendingModelSwitchRequests.remove(id)
|
||||
val pendingFast = pendingFastSwitchRequests.remove(id)
|
||||
val map = result as? Map<*, *> ?: return
|
||||
val key = map["key"] as? String
|
||||
if (key == "model") {
|
||||
val confirmRequired = map["confirm_required"] as? Boolean ?: false
|
||||
if (confirmRequired) {
|
||||
if (pending != null && pending.sequence == modelSwitchSequence) {
|
||||
val confirmMessage =
|
||||
(map["confirm_message"] as? String)
|
||||
?: (map["warning"] as? String)
|
||||
?: "This model requires confirmation to switch. Continue?"
|
||||
activeModelSwitchConfirmation = pending
|
||||
uiState.update {
|
||||
it.copy(modelSwitchConfirmMessage = confirmMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key == "fast") {
|
||||
val rawVal = map["value"] as? String
|
||||
val confirmedFast =
|
||||
if (rawVal != null) {
|
||||
rawVal == "fast" || rawVal == "priority"
|
||||
} else {
|
||||
pendingFast?.targetFast ?: uiState.value.fastMode
|
||||
}
|
||||
uiState.update {
|
||||
it.copy(
|
||||
fastMode = confirmedFast,
|
||||
isFastModeChanging = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissModelSwitchConfirm() {
|
||||
val pending = activeModelSwitchConfirmation
|
||||
activeModelSwitchConfirmation = null
|
||||
optimisticPreviousModel = null
|
||||
unconfirmedTargetModel = null
|
||||
val revertModel = lastConfirmedModel ?: pending?.previousModel
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelSwitchConfirmMessage = null,
|
||||
currentSessionModel = revertModel ?: it.currentSessionModel,
|
||||
fullContextTokens = null,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
onModelSwitchInitiated()
|
||||
fetchContextUsage()
|
||||
}
|
||||
|
||||
fun confirmModelSwitchExpensive() {
|
||||
val pending = activeModelSwitchConfirmation
|
||||
activeModelSwitchConfirmation = null
|
||||
uiState.update { it.copy(modelSwitchConfirmMessage = null) }
|
||||
if (pending != null) {
|
||||
handleModelSwitch(pending.spec, confirmExpensive = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFastMode() {
|
||||
val state = uiState.value
|
||||
val sessionId = runtimeSessionId() ?: return
|
||||
if (state.currentModelCapabilities?.fast != true) return
|
||||
if (state.isFastModeChanging) return
|
||||
|
||||
val target = !state.fastMode
|
||||
uiState.update { it.copy(isFastModeChanging = true) }
|
||||
scope.launch(ioDispatcher) {
|
||||
wsSend(
|
||||
WsMethods.CONFIG_SET,
|
||||
mapOf(
|
||||
"key" to "fast",
|
||||
"value" to if (target) "fast" else "normal",
|
||||
"session_id" to sessionId,
|
||||
),
|
||||
) { id ->
|
||||
trackRequest(id, WsMethods.CONFIG_SET)
|
||||
pendingFastSwitchRequests[id] = ActiveFastSwitch(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleConfigSetError(
|
||||
id: String,
|
||||
error: Any?,
|
||||
) {
|
||||
val pendingModel = pendingModelSwitchRequests.remove(id)
|
||||
if (pendingModel != null && pendingModel.sequence == modelSwitchSequence) {
|
||||
val revertModel = lastConfirmedModel ?: pendingModel.previousModel
|
||||
optimisticPreviousModel = null
|
||||
unconfirmedTargetModel = null
|
||||
activeModelSwitchConfirmation = null
|
||||
if (revertModel != null) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
currentSessionModel = revertModel,
|
||||
fullContextTokens = null,
|
||||
modelSwitchConfirmMessage = null,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
onModelSwitchInitiated()
|
||||
fetchContextUsage()
|
||||
} else {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
fullContextTokens = null,
|
||||
modelSwitchConfirmMessage = null,
|
||||
)
|
||||
}
|
||||
onModelSwitchInitiated()
|
||||
}
|
||||
}
|
||||
val pendingFast = pendingFastSwitchRequests.remove(id)
|
||||
if (pendingFast != null) {
|
||||
val errorMsg =
|
||||
when (error) {
|
||||
is Map<*, *> -> error["message"] as? String ?: error.toString()
|
||||
else -> error.toString()
|
||||
}.lowercase()
|
||||
val currentModel = uiState.value.currentSessionModel
|
||||
if (currentModel != null &&
|
||||
(errorMsg.contains("fast mode is not available") || errorMsg.contains("unknown fast mode"))
|
||||
) {
|
||||
fastRejectedModels.add(currentModel)
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
uiState.update { it.copy(isFastModeChanging = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun isSwitchPending(): Boolean =
|
||||
optimisticPreviousModel != null ||
|
||||
unconfirmedTargetModel != null ||
|
||||
activeModelSwitchConfirmation != null
|
||||
|
||||
fun onModelConfirmed(modelLabel: String) {
|
||||
lastConfirmedModel = modelLabel
|
||||
optimisticPreviousModel = null
|
||||
unconfirmedTargetModel = null
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
pendingModelSwitchRequests.clear()
|
||||
pendingFastSwitchRequests.clear()
|
||||
activeModelSwitchConfirmation = null
|
||||
optimisticPreviousModel = null
|
||||
lastConfirmedModel = null
|
||||
unconfirmedTargetModel = null
|
||||
}
|
||||
|
||||
fun setReasoningLevel(level: String?) {
|
||||
uiState.update { it.copy(reasoningLevel = level) }
|
||||
val sessionId = runtimeSessionId() ?: return
|
||||
if (level == null) return
|
||||
scope.launch(ioDispatcher) {
|
||||
wsSend(
|
||||
WsMethods.CONFIG_SET,
|
||||
mapOf(
|
||||
"key" to "reasoning",
|
||||
"value" to level,
|
||||
"session_id" to sessionId,
|
||||
),
|
||||
) { id -> trackRequest(id, WsMethods.CONFIG_SET) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getModelCapabilities(
|
||||
providerSlug: String,
|
||||
modelName: String,
|
||||
): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName)
|
||||
|
||||
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
||||
val label = uiState.value.currentSessionModel ?: return null
|
||||
val idx = label.indexOf('/')
|
||||
if (idx <= 0) return null
|
||||
val provider = label.substring(0, idx)
|
||||
val model = label.substring(idx + 1)
|
||||
return getModelCapabilities(provider, model)
|
||||
}
|
||||
|
||||
fun syncCurrentModelCapabilities() {
|
||||
val label = uiState.value.currentSessionModel
|
||||
val caps =
|
||||
if (label == null) {
|
||||
null
|
||||
} else {
|
||||
val idx = label.indexOf('/')
|
||||
if (idx <= 0) {
|
||||
null
|
||||
} else {
|
||||
val provider = label.substring(0, idx)
|
||||
val model = label.substring(idx + 1)
|
||||
val baseCaps = cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
|
||||
if (fastRejectedModels.contains(label) && baseCaps?.fast == true) {
|
||||
baseCaps.copy(fast = false)
|
||||
} else {
|
||||
baseCaps
|
||||
}
|
||||
}
|
||||
}
|
||||
uiState.update { state ->
|
||||
if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.speech.RecognizerIntent
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -36,9 +32,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -64,14 +65,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
@@ -82,13 +82,11 @@ import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||
@@ -101,29 +99,26 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
||||
import com.m57.hermescontrol.ui.common.AppUpdateDialog
|
||||
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
||||
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
||||
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
||||
|
||||
@@ -159,10 +154,60 @@ fun ChatScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
||||
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
||||
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
// Snapshot-backed search state — read directly so only the scopes that
|
||||
// read its fields recompose on search changes (bar, matched bubbles).
|
||||
val searchState = viewModel.searchState
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
||||
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> {
|
||||
if (browserAuthInFlight) {
|
||||
browserAuthDeparted = true
|
||||
}
|
||||
connectorsViewModel.onPause()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
connectorsViewModel.onResume()
|
||||
if (browserAuthInFlight && browserAuthDeparted) {
|
||||
browserAuthInFlight = false
|
||||
browserAuthDeparted = false
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
val activity =
|
||||
generateSequence(context) { (it as? ContextWrapper)?.baseContext }
|
||||
.filterIsInstance<Activity>()
|
||||
.firstOrNull()
|
||||
val isChangingConfigs = activity?.isChangingConfigurations == true
|
||||
if (!isChangingConfigs) {
|
||||
if (browserAuthInFlight && browserAuthDeparted) {
|
||||
browserAuthInFlight = false
|
||||
browserAuthDeparted = false
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
}
|
||||
connectorsViewModel.onPause()
|
||||
connectorsViewModel.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val browserEvent = connectorsState.browserLaunchEvent
|
||||
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||
val scrollScope = rememberCoroutineScope()
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
@@ -304,14 +349,17 @@ fun ChatScreen(
|
||||
viewModel.consumeComposerTextRestore()
|
||||
}
|
||||
}
|
||||
var isListening by rememberSaveable { mutableStateOf(false) }
|
||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(showSubagentInspectionSheet) {
|
||||
if (showSubagentInspectionSheet) {
|
||||
viewModel.hydrateSubagents()
|
||||
}
|
||||
}
|
||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||
@@ -322,137 +370,63 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
||||
val invalidResponseError = stringResource(R.string.session_integrations_err_invalid_response)
|
||||
val browserLaunchError = stringResource(R.string.session_integrations_err_browser_launch)
|
||||
|
||||
// Speech-to-text recognition launcher (issue #194)
|
||||
val speechLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
isListening = false
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val spokenText =
|
||||
result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
if (spokenText.isNotBlank()) {
|
||||
val merged =
|
||||
if (inputFieldValue.text.isBlank()) {
|
||||
spokenText
|
||||
} else {
|
||||
"${inputFieldValue.text} $spokenText"
|
||||
}
|
||||
inputFieldValue = ChatInputPolicy.commandFieldValue(merged)
|
||||
LaunchedEffect(browserEvent) {
|
||||
if (browserEvent != null) {
|
||||
val eventId = browserEvent.eventId
|
||||
val taken = connectorsViewModel.takeBrowserEvent(eventId)
|
||||
if (taken != null) {
|
||||
if (!ConnectorUrlValidator.isValidHttpsUrl(taken.url)) {
|
||||
connectorsViewModel.launchError(invalidResponseError)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mic permission launcher
|
||||
val micPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
if (granted) {
|
||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||
isListening = true
|
||||
launchExternalActivity {
|
||||
try {
|
||||
speechLauncher.launch(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
isListening = false
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttPermissionDeniedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-file picker for attachments (issue #195).
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetMultipleContents(),
|
||||
) { uris: List<Uri> ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
val attachments =
|
||||
uris.mapNotNull { uri ->
|
||||
runCatching {
|
||||
var name = uri.lastPathSegment ?: "file"
|
||||
var size = 0L
|
||||
context.contentResolver
|
||||
.query(
|
||||
uri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
|
||||
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
|
||||
}
|
||||
}
|
||||
Attachment(
|
||||
uri = uri.toString(),
|
||||
name = name,
|
||||
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
|
||||
size = size,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
|
||||
}.getOrNull()
|
||||
}
|
||||
viewModel.addAttachments(attachments)
|
||||
}
|
||||
|
||||
// Camera photo launcher (issue #195)
|
||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
|
||||
val cameraLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.TakePicture(),
|
||||
) { success ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
val uri = pendingCameraUri
|
||||
pendingCameraUri = null
|
||||
if (success && uri != null) {
|
||||
try {
|
||||
val fileName =
|
||||
"photo_${
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
|
||||
Date(),
|
||||
)
|
||||
}.jpg"
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val size = inputStream?.use { it.available().toLong() } ?: 0L
|
||||
viewModel.addAttachment(uri.toString(), fileName, "image/jpeg", size)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatScreen", "Camera capture failed", e)
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
cameraErrorMsg,
|
||||
)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(taken.url)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
launchExternalActivity {
|
||||
browserAuthDeparted = false
|
||||
browserAuthInFlight = true
|
||||
context.startActivity(intent)
|
||||
}
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
browserAuthInFlight = false
|
||||
browserAuthDeparted = false
|
||||
connectorsViewModel.launchError(browserLaunchError)
|
||||
} catch (_: SecurityException) {
|
||||
browserAuthInFlight = false
|
||||
browserAuthDeparted = false
|
||||
connectorsViewModel.launchError(browserLaunchError)
|
||||
} catch (_: Exception) {
|
||||
browserAuthInFlight = false
|
||||
browserAuthDeparted = false
|
||||
connectorsViewModel.launchError(browserLaunchError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mediaLaunchers =
|
||||
rememberChatMediaLaunchers(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputFieldValueChange = { inputFieldValue = it },
|
||||
onAddAttachment = { uri, name, mimeType, size ->
|
||||
viewModel.addAttachment(uri, name, mimeType, size)
|
||||
},
|
||||
onAddAttachments = { attachments ->
|
||||
viewModel.addAttachments(attachments)
|
||||
},
|
||||
onShowMessage = { msg ->
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
},
|
||||
launchExternalActivity = launchExternalActivity,
|
||||
context = context,
|
||||
)
|
||||
|
||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||
ChatLifecycleEffects(
|
||||
@@ -553,6 +527,39 @@ fun ChatScreen(
|
||||
contentDescription = stringResource(R.string.content_desc_new_chat),
|
||||
)
|
||||
}
|
||||
|
||||
// Session actions overflow menu (issue #1091)
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { showSessionMenu = true },
|
||||
modifier = Modifier.testTag("chat_session_menu_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.session_integrations_menu_item),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSessionMenu,
|
||||
onDismissRequest = { showSessionMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Extension,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showSessionMenu = false
|
||||
connectorsViewModel.show()
|
||||
},
|
||||
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { _ ->
|
||||
Column(
|
||||
@@ -592,80 +599,7 @@ fun ChatScreen(
|
||||
|
||||
// Issue #890: launch update check — non-blocking banner when a
|
||||
// newer release exists. Tapping "Update" opens the in-place dialog.
|
||||
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
||||
val appUpdateViewModel: AppUpdateViewModel =
|
||||
viewModel {
|
||||
val app =
|
||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
||||
?: error("Application not available")
|
||||
AppUpdateViewModel(app)
|
||||
}
|
||||
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) {
|
||||
val observer =
|
||||
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
||||
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
|
||||
appUpdateViewModel.resumeInstallAfterPermission()
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
updateLifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
||||
val noticeTag =
|
||||
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
||||
?: UpdateNoticeManager.noticeTag()
|
||||
if (noticeTag != null) {
|
||||
com.m57.hermescontrol.ui.common.UpdateNoticeBanner(
|
||||
latestTag = noticeTag,
|
||||
onUpdate = { AppUpdateCache.showDialog() },
|
||||
onDismiss = { AppUpdateCache.dismiss() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (AppUpdateCache.isDialogVisible) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val dialogState =
|
||||
if (appUpdateState !is AppUpdateState.Idle) {
|
||||
appUpdateState
|
||||
} else {
|
||||
updateNotice
|
||||
}
|
||||
AppUpdateDialog(
|
||||
state = dialogState,
|
||||
onDismiss = { AppUpdateCache.hideDialog() },
|
||||
onStartUpdate = { appUpdateViewModel.startUpdate() },
|
||||
onCancelDownload = { appUpdateViewModel.cancelDownload() },
|
||||
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
||||
onOpenSettings = {
|
||||
val intent =
|
||||
android.content
|
||||
.Intent(
|
||||
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
android.net.Uri.parse("package:${context.packageName}"),
|
||||
).apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
context.startActivity(
|
||||
android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ChatAppUpdateSection()
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = searchState.isActive,
|
||||
@@ -751,6 +685,15 @@ fun ChatScreen(
|
||||
onRespondClarify = viewModel::respondToClarify,
|
||||
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||
onDismissClarify = viewModel::dismissClarify,
|
||||
vaultUnlockPrompt = state.vaultUnlockPrompt,
|
||||
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
||||
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
||||
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt,
|
||||
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
||||
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
||||
vaultCodePrompt = state.vaultCodePrompt,
|
||||
onRespondVaultCode = viewModel::respondToVaultCode,
|
||||
onDismissVaultCode = viewModel::dismissVaultCode,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
openingAttachmentPath = state.openingAttachmentPath,
|
||||
@@ -808,75 +751,16 @@ fun ChatScreen(
|
||||
// Jump to bottom after send (serialized through the controller).
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
},
|
||||
onMicTap = {
|
||||
if (isListening) {
|
||||
isListening = false
|
||||
} else if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||
isListening = true
|
||||
launchExternalActivity {
|
||||
try {
|
||||
speechLauncher.launch(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
isListening = false
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollScope.launch {
|
||||
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchExternalActivity {
|
||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
},
|
||||
isListening = isListening,
|
||||
onMicTap = mediaLaunchers.onMicTap,
|
||||
isListening = mediaLaunchers.isListening,
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isConnected = state.isConnected,
|
||||
commandCatalog = state.commandCatalog,
|
||||
slashUsageCounts = state.slashUsageCounts,
|
||||
pendingAttachments = state.pendingAttachments,
|
||||
onCameraTap = {
|
||||
try {
|
||||
val timeStamp =
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val photoFile =
|
||||
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
|
||||
val uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
photoFile,
|
||||
)
|
||||
pendingCameraUri = uri
|
||||
launchExternalActivity {
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatScreen", "Camera launch failed", e)
|
||||
}
|
||||
},
|
||||
onImageTap = {
|
||||
launchExternalActivity {
|
||||
filePickerLauncher.launch("image/*")
|
||||
}
|
||||
},
|
||||
onFileTap = {
|
||||
launchExternalActivity {
|
||||
filePickerLauncher.launch("*/*")
|
||||
}
|
||||
},
|
||||
onCameraTap = mediaLaunchers.onCameraTap,
|
||||
onImageTap = mediaLaunchers.onImageTap,
|
||||
onFileTap = mediaLaunchers.onFileTap,
|
||||
onRemoveAttachment = viewModel::removeAttachment,
|
||||
onPreviewAttachment = { attachment ->
|
||||
if (attachment.isImage) {
|
||||
@@ -895,6 +779,10 @@ fun ChatScreen(
|
||||
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
||||
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
||||
supportsReasoning = state.currentModelCapabilities?.reasoning,
|
||||
fastMode = state.fastMode,
|
||||
fastSupported = state.currentModelCapabilities?.fast == true,
|
||||
isFastModeChanging = state.isFastModeChanging,
|
||||
onToggleFastMode = { viewModel.toggleFastMode() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -981,9 +869,16 @@ fun ChatScreen(
|
||||
SubagentInspectionSheet(
|
||||
indicators = state.subagentIndicators,
|
||||
todos = state.todos,
|
||||
inspectingSubagentId = state.inspectingSubagentId,
|
||||
subagentTranscript = state.subagentTranscript,
|
||||
onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) },
|
||||
onRetryTranscript = { viewModel.retrySubagentTranscript() },
|
||||
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
|
||||
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
|
||||
onDismiss = { showSubagentInspectionSheet = false },
|
||||
onDismiss = {
|
||||
showSubagentInspectionSheet = false
|
||||
viewModel.closeSubagentTranscript()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1000,5 +895,14 @@ fun ChatScreen(
|
||||
onDismiss = { viewingImage = null },
|
||||
)
|
||||
}
|
||||
|
||||
SessionIntegrationsSheet(
|
||||
uiState = connectorsState,
|
||||
sessionId = connectorsState.sessionId,
|
||||
onDismiss = connectorsViewModel::hide,
|
||||
onRefresh = { connectorsViewModel.refresh(force = true) },
|
||||
onConnect = { slug, reconnect -> connectorsViewModel.connect(slug, reconnect) },
|
||||
onClearError = connectorsViewModel::clearError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,7 @@ internal fun mapServerMessages(
|
||||
}
|
||||
}
|
||||
|
||||
val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
|
||||
mapped.add(
|
||||
ChatMessage(
|
||||
id = restId,
|
||||
@@ -182,6 +183,7 @@ internal fun mapServerMessages(
|
||||
timestamp = timestamp,
|
||||
isStreaming = false,
|
||||
displayKind = msg.display_kind,
|
||||
tokenCount = tokenCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Holds chat message-streaming state and logic, extracted from [ChatViewModel]
|
||||
@@ -27,14 +30,23 @@ class ChatStreamingController(
|
||||
private val isCurrentSession: (String?) -> Boolean,
|
||||
private val isTestEnvironment: () -> Boolean,
|
||||
) {
|
||||
private companion object {
|
||||
const val FLUSH_INTERVAL_MS = 33L
|
||||
}
|
||||
|
||||
private val streamingBuffer = java.lang.StringBuilder()
|
||||
private var lastFlushMs = 0L
|
||||
private var trailingMessageFlushJob: Job? = null
|
||||
|
||||
private val thinkingBuffer = java.lang.StringBuilder()
|
||||
private var lastThinkingFlushMs = 0L
|
||||
private var trailingThinkingFlushJob: Job? = null
|
||||
|
||||
private val reasoningBuffer = java.lang.StringBuilder()
|
||||
private var lastReasoningFlushMs = 0L
|
||||
private var trailingReasoningFlushJob: Job? = null
|
||||
|
||||
private fun nowMs(): Long = System.nanoTime() / 1_000_000L
|
||||
|
||||
/**
|
||||
* Resets all streaming buffers. Centralizes the clear logic that was
|
||||
@@ -42,6 +54,9 @@ class ChatStreamingController(
|
||||
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
|
||||
*/
|
||||
fun resetStreaming() {
|
||||
trailingMessageFlushJob?.cancel()
|
||||
trailingThinkingFlushJob?.cancel()
|
||||
trailingReasoningFlushJob?.cancel()
|
||||
flushReasoning()
|
||||
streamingBuffer.clear()
|
||||
thinkingBuffer.clear()
|
||||
@@ -73,6 +88,9 @@ class ChatStreamingController(
|
||||
* buffer-only clear is safe and prevents stale deltas from re-flushing.
|
||||
*/
|
||||
fun clearStreamingBuffers() {
|
||||
trailingMessageFlushJob?.cancel()
|
||||
trailingThinkingFlushJob?.cancel()
|
||||
trailingReasoningFlushJob?.cancel()
|
||||
streamingBuffer.clear()
|
||||
thinkingBuffer.clear()
|
||||
reasoningBuffer.clear()
|
||||
@@ -94,6 +112,7 @@ class ChatStreamingController(
|
||||
* synchronously so the finalized message carries the latest reasoning.
|
||||
*/
|
||||
fun flushPendingReasoning() {
|
||||
trailingReasoningFlushJob?.cancel()
|
||||
flushReasoning()
|
||||
}
|
||||
|
||||
@@ -111,8 +130,10 @@ class ChatStreamingController(
|
||||
* tokens synchronously first makes the sealed orphan complete.
|
||||
*/
|
||||
fun flushPendingTokens() {
|
||||
trailingMessageFlushJob?.cancel()
|
||||
val currentContent = streamingBuffer.toString()
|
||||
if (currentContent.isEmpty()) return
|
||||
lastFlushMs = nowMs()
|
||||
streamingState.update { state ->
|
||||
val current = state.streamingMessage ?: return@update state
|
||||
state.copy(
|
||||
@@ -130,63 +151,97 @@ class ChatStreamingController(
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
|
||||
streamingBuffer.append(event.token)
|
||||
val now = System.currentTimeMillis()
|
||||
val now = nowMs()
|
||||
|
||||
// Always flush in tests, or if enough time has passed
|
||||
val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment()
|
||||
val shouldFlush = (now - lastFlushMs >= FLUSH_INTERVAL_MS) || lastFlushMs == 0L || isTestEnvironment()
|
||||
if (shouldFlush) {
|
||||
val currentContent = streamingBuffer.toString()
|
||||
lastFlushMs = now
|
||||
streamingState.update { state ->
|
||||
val current = state.streamingMessage
|
||||
if (current != null) {
|
||||
val currentReasoning =
|
||||
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
|
||||
state.copy(
|
||||
streamingMessage =
|
||||
current.copy(
|
||||
content = currentContent,
|
||||
reasoningText = currentReasoning,
|
||||
),
|
||||
isThinking = false,
|
||||
)
|
||||
} else {
|
||||
// Fallback: no MessageStart was received — create one now
|
||||
val msg =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = currentContent,
|
||||
reasoningText = state.reasoningText,
|
||||
isStreaming = true,
|
||||
)
|
||||
state.copy(
|
||||
streamingMessage = msg,
|
||||
isThinking = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.update { it.copy(isAgentTyping = true) }
|
||||
trailingMessageFlushJob?.cancel()
|
||||
flushTokens(now)
|
||||
} else {
|
||||
scheduleTrailingMessageFlush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleTrailingMessageFlush() {
|
||||
if (trailingMessageFlushJob?.isActive == true) return
|
||||
trailingMessageFlushJob =
|
||||
scope.launch {
|
||||
delay(FLUSH_INTERVAL_MS)
|
||||
flushTokens(nowMs())
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushTokens(now: Long) {
|
||||
val currentContent = streamingBuffer.toString()
|
||||
if (currentContent.isEmpty()) return
|
||||
lastFlushMs = now
|
||||
streamingState.update { state ->
|
||||
val current = state.streamingMessage
|
||||
if (current != null) {
|
||||
val currentReasoning =
|
||||
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
|
||||
state.copy(
|
||||
streamingMessage =
|
||||
current.copy(
|
||||
content = currentContent,
|
||||
reasoningText = currentReasoning,
|
||||
),
|
||||
isThinking = false,
|
||||
)
|
||||
} else {
|
||||
// Fallback: no MessageStart was received — create one now
|
||||
val msg =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = currentContent,
|
||||
reasoningText = state.reasoningText,
|
||||
isStreaming = true,
|
||||
)
|
||||
state.copy(
|
||||
streamingMessage = msg,
|
||||
isThinking = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.update { it.copy(isAgentTyping = true) }
|
||||
}
|
||||
|
||||
fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
|
||||
thinkingBuffer.append(event.token)
|
||||
val now = System.currentTimeMillis()
|
||||
val now = nowMs()
|
||||
|
||||
// Always flush in tests, or if enough time has passed
|
||||
val shouldFlush =
|
||||
(now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment()
|
||||
(now - lastThinkingFlushMs >= FLUSH_INTERVAL_MS) || lastThinkingFlushMs == 0L || isTestEnvironment()
|
||||
if (shouldFlush) {
|
||||
val currentContent = thinkingBuffer.toString()
|
||||
lastThinkingFlushMs = now
|
||||
streamingState.update { state ->
|
||||
state.copy(
|
||||
isThinking = true,
|
||||
thinkingText = currentContent,
|
||||
)
|
||||
trailingThinkingFlushJob?.cancel()
|
||||
flushThinking(now)
|
||||
} else {
|
||||
scheduleTrailingThinkingFlush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleTrailingThinkingFlush() {
|
||||
if (trailingThinkingFlushJob?.isActive == true) return
|
||||
trailingThinkingFlushJob =
|
||||
scope.launch {
|
||||
delay(FLUSH_INTERVAL_MS)
|
||||
flushThinking(nowMs())
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushThinking(now: Long) {
|
||||
val currentContent = thinkingBuffer.toString()
|
||||
if (currentContent.isEmpty()) return
|
||||
lastThinkingFlushMs = now
|
||||
streamingState.update { state ->
|
||||
state.copy(
|
||||
isThinking = true,
|
||||
thinkingText = currentContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,17 +249,29 @@ class ChatStreamingController(
|
||||
if (!isCurrentSession(event.sessionId)) return
|
||||
|
||||
reasoningBuffer.append(event.token)
|
||||
val now = System.currentTimeMillis()
|
||||
val now = nowMs()
|
||||
|
||||
// Always flush in tests, or if enough time has passed
|
||||
val shouldFlush =
|
||||
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
|
||||
(now - lastReasoningFlushMs >= FLUSH_INTERVAL_MS) || lastReasoningFlushMs == 0L || isTestEnvironment()
|
||||
if (shouldFlush) {
|
||||
trailingReasoningFlushJob?.cancel()
|
||||
flushReasoning(now)
|
||||
} else {
|
||||
scheduleTrailingReasoningFlush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushReasoning(now: Long = System.currentTimeMillis()) {
|
||||
private fun scheduleTrailingReasoningFlush() {
|
||||
if (trailingReasoningFlushJob?.isActive == true) return
|
||||
trailingReasoningFlushJob =
|
||||
scope.launch {
|
||||
delay(FLUSH_INTERVAL_MS)
|
||||
flushReasoning(nowMs())
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushReasoning(now: Long = nowMs()) {
|
||||
val currentContent = reasoningBuffer.toString()
|
||||
if (currentContent.isEmpty()) return
|
||||
lastReasoningFlushMs = now
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.model.SubagentListItem
|
||||
import com.m57.hermescontrol.data.ws.SubagentRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Delegate managing subagent roster hydration via `subagent.list` and live transcript
|
||||
* tail polling via `subagent.tail` (issue #1089).
|
||||
*/
|
||||
class ChatSubagentsDelegate(
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val runtimeSessionId: () -> String?,
|
||||
private val subagentRepository: SubagentRepository = SubagentRepository,
|
||||
private val pollingIntervalMs: Long = 1500L,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ChatSubagentsDelegate"
|
||||
const val SUBAGENT_TAIL_MAX_BYTES = 16384
|
||||
}
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
private var hydrationJob: Job? = null
|
||||
|
||||
/**
|
||||
* Request the authoritative subagent roster from the gateway and merge it into [ChatUiState].
|
||||
*
|
||||
* A failed request degrades gracefully without clearing existing push-event indicators.
|
||||
*/
|
||||
fun hydrateSubagents(sessionId: String? = runtimeSessionId()) {
|
||||
val targetSession = sessionId?.trim()
|
||||
if (targetSession.isNullOrEmpty()) return
|
||||
|
||||
val requestTime = System.currentTimeMillis()
|
||||
hydrationJob?.cancel()
|
||||
hydrationJob =
|
||||
scope.launch(ioDispatcher) {
|
||||
try {
|
||||
val response = subagentRepository.listSubagents(targetSession) ?: return@launch
|
||||
uiState.update { current ->
|
||||
val merged = mergeSubagentList(current.subagentIndicators, response.subagents, requestTime)
|
||||
current.copy(subagentIndicators = merged)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to hydrate subagents for session $targetSession: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle transcript inspection for [subagentId].
|
||||
*
|
||||
* If already inspecting [subagentId], closes the transcript view; otherwise opens it.
|
||||
*/
|
||||
fun toggleSubagentTranscript(subagentId: String) {
|
||||
if (uiState.value.inspectingSubagentId == subagentId) {
|
||||
closeSubagentTranscript()
|
||||
} else {
|
||||
inspectSubagentTranscript(subagentId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start inspecting a live subagent's execution transcript, initiating bounded polling.
|
||||
*/
|
||||
fun inspectSubagentTranscript(subagentId: String) {
|
||||
if (subagentId.isBlank()) return
|
||||
pollingJob?.cancel()
|
||||
|
||||
uiState.update { current ->
|
||||
current.copy(
|
||||
inspectingSubagentId = subagentId,
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pollingJob =
|
||||
scope.launch(ioDispatcher) {
|
||||
val sessionId = runtimeSessionId() ?: return@launch
|
||||
pollTranscriptLoop(sessionId, subagentId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry loading the transcript for the currently inspected subagent.
|
||||
*/
|
||||
fun retryTranscript() {
|
||||
val subagentId = uiState.value.inspectingSubagentId ?: return
|
||||
inspectSubagentTranscript(subagentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop transcript polling and dismiss the transcript disclosure.
|
||||
*/
|
||||
fun closeSubagentTranscript() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
uiState.update { current ->
|
||||
current.copy(
|
||||
inspectingSubagentId = null,
|
||||
subagentTranscript = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollTranscriptLoop(
|
||||
sessionId: String,
|
||||
subagentId: String,
|
||||
) {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
val tailResponse =
|
||||
subagentRepository.tailSubagent(
|
||||
sessionId = sessionId,
|
||||
subagentId = subagentId,
|
||||
maxBytes = SUBAGENT_TAIL_MAX_BYTES,
|
||||
)
|
||||
|
||||
if (tailResponse != null) {
|
||||
val content = tailResponse.content()
|
||||
val truncated = tailResponse.truncated ?: false
|
||||
val bytesRead = tailResponse.bytesRead
|
||||
|
||||
uiState.update { current ->
|
||||
if (current.inspectingSubagentId != subagentId) return@update current
|
||||
val prev = current.subagentTranscript
|
||||
if (prev == null || prev.text != content || prev.isLoading || prev.error != null) {
|
||||
current.copy(
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
text = content,
|
||||
isLoading = false,
|
||||
isTruncated = truncated,
|
||||
bytesRead = bytesRead,
|
||||
error = null,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uiState.update { current ->
|
||||
if (current.inspectingSubagentId != subagentId) return@update current
|
||||
val prev = current.subagentTranscript
|
||||
if (prev == null || prev.text.isEmpty()) {
|
||||
current.copy(
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
isLoading = false,
|
||||
error = "Unable to load transcript",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
current.copy(subagentTranscript = prev.copy(isLoading = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error fetching transcript tail for subagent $subagentId: ${e.message}")
|
||||
uiState.update { current ->
|
||||
if (current.inspectingSubagentId != subagentId) return@update current
|
||||
val prev = current.subagentTranscript
|
||||
if (prev == null || prev.text.isEmpty()) {
|
||||
current.copy(
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
isLoading = false,
|
||||
error = e.message ?: "Unable to load transcript",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
current.copy(subagentTranscript = prev.copy(isLoading = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling once the subagent reaches a terminal state (one final refresh is enough)
|
||||
val currentIndicator = uiState.value.subagentIndicators.firstOrNull { it.subagentId == subagentId }
|
||||
if (currentIndicator != null && !currentIndicator.isRunning) {
|
||||
break
|
||||
}
|
||||
|
||||
delay(pollingIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges snapshot items from `subagent.list` with current indicators.
|
||||
*
|
||||
* Rules:
|
||||
* - `subagent_id` is the primary identity.
|
||||
* - Protects against races: if an indicator received a push event with timestamp > [requestTime],
|
||||
* its push-event status/type is preserved.
|
||||
* - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot.
|
||||
* - Does not discard locally known indicators absent from the snapshot.
|
||||
*/
|
||||
internal fun mergeSubagentList(
|
||||
current: List<SubagentIndicator>,
|
||||
listItems: List<SubagentListItem>,
|
||||
requestTime: Long,
|
||||
): List<SubagentIndicator> {
|
||||
val merged = current.toMutableList()
|
||||
|
||||
for (item in listItems) {
|
||||
val subagentId = item.subagentId.trim()
|
||||
if (subagentId.isEmpty() && item.goal.isNullOrBlank()) continue
|
||||
|
||||
val idx =
|
||||
if (subagentId.isNotEmpty()) {
|
||||
merged.indexOfFirst { it.subagentId == subagentId }
|
||||
} else {
|
||||
merged.indexOfFirst { it.subagentId == null && it.goal == item.goal }
|
||||
}
|
||||
|
||||
if (idx >= 0) {
|
||||
val existing = merged[idx]
|
||||
val isPushEventNewer = existing.lastEventTimestamp > requestTime
|
||||
|
||||
val finalStatus =
|
||||
if (isPushEventNewer) {
|
||||
existing.status ?: item.status ?: "running"
|
||||
} else if (existing.isComplete || existing.isFailed || existing.isCancelled) {
|
||||
// Terminal state guard: don't revert to running
|
||||
if (item.status == "completed" || item.status == "failed" ||
|
||||
item.status == "cancelled" || item.status == "stopped"
|
||||
) {
|
||||
item.status
|
||||
} else {
|
||||
existing.status
|
||||
}
|
||||
} else {
|
||||
item.status ?: existing.status ?: "running"
|
||||
}
|
||||
|
||||
val finalType =
|
||||
if (isPushEventNewer) {
|
||||
existing.type
|
||||
} else if (finalStatus == "completed" || finalStatus == "done" || existing.isComplete) {
|
||||
"subagent.complete"
|
||||
} else if (existing.type.isNotBlank()) {
|
||||
existing.type
|
||||
} else {
|
||||
"subagent.progress"
|
||||
}
|
||||
|
||||
merged[idx] =
|
||||
existing.copy(
|
||||
type = finalType,
|
||||
goal = item.goal ?: existing.goal,
|
||||
status = finalStatus,
|
||||
subagentId = if (subagentId.isNotEmpty()) subagentId else existing.subagentId,
|
||||
durationSeconds =
|
||||
if (isPushEventNewer) {
|
||||
existing.durationSeconds ?: item.elapsedSeconds
|
||||
} else {
|
||||
item.elapsedSeconds ?: existing.durationSeconds
|
||||
},
|
||||
model = item.model ?: existing.model,
|
||||
lastEventTimestamp = maxOf(existing.lastEventTimestamp, requestTime),
|
||||
)
|
||||
} else {
|
||||
val status = item.status ?: "running"
|
||||
val isComp = status == "completed" || status == "done"
|
||||
val type = if (isComp) "subagent.complete" else "subagent.progress"
|
||||
|
||||
merged.add(
|
||||
SubagentIndicator(
|
||||
type = type,
|
||||
goal = item.goal,
|
||||
status = status,
|
||||
subagentId = subagentId.takeIf { it.isNotEmpty() },
|
||||
durationSeconds = item.elapsedSeconds,
|
||||
model = item.model,
|
||||
lastEventTimestamp = requestTime,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Canonical comparison key for a tool message's result payload.
|
||||
*
|
||||
* WS tool messages store the full tool.complete payload
|
||||
* (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST
|
||||
* transcript rows store just the result object (`{"output":..., "exit_code":...}`).
|
||||
* This key normalizes both sides — preferring the `result` field when present,
|
||||
* and treating int/float JSON numbers as equal — so the two representations of
|
||||
* the SAME tool call can be matched regardless of position or pagination.
|
||||
* Returns null for unparseable content (no match possible).
|
||||
*/
|
||||
internal fun canonicalToolResultKey(content: String): String? {
|
||||
val element =
|
||||
try {
|
||||
OkHttpProvider.json.parseToJsonElement(content)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
fun canon(e: JsonElement): String =
|
||||
when (e) {
|
||||
is JsonObject -> {
|
||||
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
|
||||
}
|
||||
|
||||
is JsonArray -> {
|
||||
e.joinToString(",") { canon(it) }
|
||||
}
|
||||
|
||||
is JsonPrimitive -> {
|
||||
// Canonicalize ALL numbers through double, collapsing int/float
|
||||
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
|
||||
val s = e.content
|
||||
val d = s.toDoubleOrNull()
|
||||
if (d != null) {
|
||||
if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d"
|
||||
} else {
|
||||
"s$s"
|
||||
}
|
||||
}
|
||||
}
|
||||
return when (element) {
|
||||
is JsonObject -> {
|
||||
element["result"]?.let { canon(it) } ?: canon(element)
|
||||
}
|
||||
|
||||
else -> {
|
||||
canon(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [a] and [b] are the same logical message (the WS-persisted and
|
||||
* REST-persisted copies of one row — they carry different ids, see #771).
|
||||
* Tool messages match on their normalized result payload; other roles on
|
||||
* exact content, IGNORING leading/trailing whitespace.
|
||||
*
|
||||
* Issue #842: the app seals the RAW streamed text (which can carry leading
|
||||
* blank lines the model emits before its narration), while the backend
|
||||
* persists a CLEANED copy (leading whitespace stripped). An exact-content
|
||||
* compare made the reload merge treat them as different messages and add
|
||||
* the REST copy on top — the commentary duplicated ~10s after the stream
|
||||
* ended. Trim closes the drift: the sealed live bubble is covered by the
|
||||
* REST row and the duplicate never renders.
|
||||
*/
|
||||
internal fun sameLogicalMessage(
|
||||
a: ChatMessage,
|
||||
b: ChatMessage,
|
||||
): Boolean {
|
||||
if (a.role != b.role) return false
|
||||
if (a.role == MessageRole.TOOL) {
|
||||
// Issue #842: prefer the gateway's tool call id when both sides carry
|
||||
// it — the REST transcript stores `tool_call_id` and the live WS
|
||||
// bubble keeps it from `tool.start`. Content canonicalization cannot
|
||||
// cover MCP/web tools: the REST side stores the payload as raw
|
||||
// `<untrusted_tool_result>` text (not JSON), so it has no key at all.
|
||||
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) {
|
||||
return a.toolCallId == b.toolCallId
|
||||
}
|
||||
val ka = canonicalToolResultKey(a.content)
|
||||
val kb = canonicalToolResultKey(b.content)
|
||||
return ka != null && ka == kb
|
||||
}
|
||||
val ta = a.content.trim()
|
||||
val tb = b.content.trim()
|
||||
if (ta == tb) return true
|
||||
// Attachment dedupe: when the user sends an image/file, the backend
|
||||
// persists an ENRICHED copy of the prompt — the real caption plus
|
||||
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
|
||||
// (tui_gateway _build_image_ref_message / run_agent flattening). The
|
||||
// optimistic bubble carries the raw caption only, so the exact compare
|
||||
// above fails and the REST sync renders the same message twice. Compare
|
||||
// USER rows on the caption with injection lines stripped (both sides —
|
||||
// stripping a clean string is a no-op). USER-only so assistant text that
|
||||
// legitimately mentions these tokens is never collapsed.
|
||||
if (a.role == MessageRole.USER) {
|
||||
val ca = stripAttachmentRefLines(ta)
|
||||
val cb = stripAttachmentRefLines(tb)
|
||||
if (ca == cb) return true
|
||||
}
|
||||
// Issue #842: a seal race can leave the live orphan a few trailing tokens
|
||||
// short of the streamed narration (the last delta was still in the
|
||||
// throttled buffer when tool.start sealed the message). The backend
|
||||
// persists the COMPLETE copy, so the orphan is a strict prefix of the
|
||||
// REST row. Accept prefix-covering only for substantial texts (>=40
|
||||
// chars) so a short reply can never be swallowed by a longer message
|
||||
// that merely starts with it.
|
||||
return ta.length >= 40 &&
|
||||
tb.length >= 40 &&
|
||||
(tb.startsWith(ta) || ta.startsWith(tb))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove backend attachment-injection lines from a user message so the
|
||||
* optimistic bubble and the server-enriched REST copy compare equal.
|
||||
* Lines the gateway adds on top of the user's caption: `@image:<path>`,
|
||||
* `@file:<ref>`, and `[screenshot]` (multipart image placeholder written by
|
||||
* run_agent). Blank lines left behind are dropped too.
|
||||
*/
|
||||
internal fun stripAttachmentRefLines(content: String): String =
|
||||
content
|
||||
.lines()
|
||||
.map { it.trim() }
|
||||
.filterNot { line ->
|
||||
line.startsWith("@image:") ||
|
||||
line.startsWith("@file:") ||
|
||||
line == "[screenshot]"
|
||||
}.joinToString("\n")
|
||||
.trim()
|
||||
|
||||
/**
|
||||
* Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload,
|
||||
* tool name) and the REST-persisted copy (`rest-` id, result-only payload,
|
||||
* no tool name) of every message. Painting the cache verbatim renders the
|
||||
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
|
||||
* logical message exists (issue #771).
|
||||
*/
|
||||
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||
val rest = messages.filter { it.id.startsWith("rest-") }
|
||||
if (rest.isEmpty()) return messages
|
||||
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
|
||||
if (nonRest.isEmpty()) return messages
|
||||
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
|
||||
return (nonRest + keepRest).sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* A transcript reload must NOT yank live WS bubbles the server has not
|
||||
* persisted yet. The gateway stores a tool row only once the tool
|
||||
* COMPLETES server-side, so a reload that lands while a tool is running
|
||||
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
|
||||
* returns a page without the tool row — replacing the list outright made
|
||||
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
|
||||
* message to update (issue #771).
|
||||
*
|
||||
* Merge instead of replace: append any current message the REST page does
|
||||
* not already cover (checked by id AND logical content via
|
||||
* [sameLogicalMessage]) and keep chronological order.
|
||||
*/
|
||||
internal fun mergeTranscriptWithLive(
|
||||
restMessages: List<ChatMessage>,
|
||||
currentMessages: List<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
val dedupedRest = restMessages.dedupeById()
|
||||
val dedupedCurrent = currentMessages.dedupeById()
|
||||
// User rows can carry live/cache-only metadata (for example whether the
|
||||
// bubble continues the active turn). Keep that richer copy when REST
|
||||
// returns the same logical row.
|
||||
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
|
||||
val mergedRest =
|
||||
dedupedRest.map { rest ->
|
||||
if (rest.role != MessageRole.USER) return@map rest
|
||||
val matchIndex =
|
||||
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
|
||||
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
|
||||
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
|
||||
}
|
||||
val restIds = dedupedRest.map { it.id }.toSet()
|
||||
val liveTail =
|
||||
dedupedCurrent.filter { old ->
|
||||
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
|
||||
}
|
||||
if (liveTail.isEmpty()) return mergedRest.dedupeById()
|
||||
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
/** Merge a REST page without matching it against already-settled transcript rows. */
|
||||
internal fun mergeIncrementalTranscriptPage(
|
||||
restMessages: List<ChatMessage>,
|
||||
currentMessages: List<ChatMessage>,
|
||||
sessionId: String,
|
||||
offset: Int,
|
||||
): List<ChatMessage> {
|
||||
val settledEnd =
|
||||
currentMessages.indexOfLast { message ->
|
||||
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
|
||||
}
|
||||
return (
|
||||
currentMessages.take(settledEnd + 1) +
|
||||
mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1))
|
||||
).dedupeById()
|
||||
}
|
||||
|
||||
internal fun serverMessageIndex(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
|
||||
|
||||
internal fun List<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -74,6 +74,13 @@ object ChatWsEventReducer {
|
||||
is WsEvent.ReviewSummary -> event.sessionId
|
||||
is WsEvent.BtwComplete -> event.sessionId
|
||||
is WsEvent.SessionUsage -> event.sessionId
|
||||
is WsEvent.TranscriptResyncRequired -> event.sessionId
|
||||
is WsEvent.VaultUnlockRequest -> event.sessionId
|
||||
is WsEvent.VaultUnlockExpire -> event.sessionId
|
||||
is WsEvent.VaultSaveLoginRequest -> event.sessionId
|
||||
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
||||
is WsEvent.VaultCodeRequest -> event.sessionId
|
||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||
@@ -169,11 +176,23 @@ object ChatWsEventReducer {
|
||||
|
||||
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// Vault prompts are handled by the ViewModel (issue #1090)
|
||||
is WsEvent.VaultUnlockRequest,
|
||||
is WsEvent.VaultUnlockExpire,
|
||||
is WsEvent.VaultSaveLoginRequest,
|
||||
is WsEvent.VaultSaveLoginExpire,
|
||||
is WsEvent.VaultCodeRequest,
|
||||
is WsEvent.VaultCodeExpire,
|
||||
-> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
||||
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// Change events (issue #784) are consumed by their screens' ViewModels
|
||||
is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// TranscriptResyncRequired is handled by ChatViewModel directly (refetch history on replay gap)
|
||||
is WsEvent.TranscriptResyncRequired -> ReducerResult(state = state, streamingState = streamingState)
|
||||
}
|
||||
|
||||
// ── GatewayReady ──────────────────────────────────────────────────
|
||||
@@ -217,6 +236,8 @@ object ChatWsEventReducer {
|
||||
streamingState.streamingMessage.copy(
|
||||
isStreaming = false,
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
||||
tps = state.latestTps?.takeIf { it > 0.0 },
|
||||
)
|
||||
orphan = finalized
|
||||
state.copy(
|
||||
@@ -375,17 +396,25 @@ object ChatWsEventReducer {
|
||||
streamingState = streamingState.copy(streamingMessage = null),
|
||||
)
|
||||
}
|
||||
val rawUsage = event.rawPayload?.get("usage") as? Map<*, *>
|
||||
val tpsFromPayload = (rawUsage?.get("avg_tps") as? Number)?.toDouble()
|
||||
val tps = (tpsFromPayload ?: state.latestTps)?.takeIf { it > 0.0 }
|
||||
val tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }
|
||||
val msg =
|
||||
streaming?.copy(
|
||||
content = text,
|
||||
isStreaming = false,
|
||||
reasoningText = reasoning,
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
) ?: ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = text,
|
||||
reasoningText = reasoning,
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
@@ -431,11 +460,14 @@ object ChatWsEventReducer {
|
||||
} else {
|
||||
streaming.reasoningText
|
||||
}
|
||||
val tokenCount = TokenEstimator.estimate(streaming.content).takeIf { it > 0 }
|
||||
val msg =
|
||||
streaming.copy(
|
||||
isStreaming = false,
|
||||
reasoningText = reasoning,
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = streaming.tokenCount ?: tokenCount,
|
||||
tps = streaming.tps ?: state.latestTps?.takeIf { it > 0.0 },
|
||||
)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
@@ -500,6 +532,8 @@ object ChatWsEventReducer {
|
||||
isStreaming = false,
|
||||
reasoningText = reasoning,
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
||||
tps = state.latestTps?.takeIf { it > 0.0 },
|
||||
)
|
||||
orphanToPersist = finalized
|
||||
state.copy(
|
||||
@@ -912,6 +946,7 @@ object ChatWsEventReducer {
|
||||
logs = trimmedLogs,
|
||||
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
|
||||
model = model ?: (if (idx >= 0) indicators[idx].model else null),
|
||||
lastEventTimestamp = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
if (idx >= 0) {
|
||||
@@ -958,6 +993,9 @@ object ChatWsEventReducer {
|
||||
if (snapshot.contextMax != null && snapshot.contextMax > 0L) {
|
||||
updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax)
|
||||
}
|
||||
if (snapshot.avgTps != null && snapshot.avgTps > 0.0) {
|
||||
updatedState = updatedState.copy(latestTps = snapshot.avgTps)
|
||||
}
|
||||
return ReducerResult(state = updatedState, streamingState = streamingState)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Token count estimation and formatting utilities.
|
||||
*
|
||||
* The estimation algorithm matches the backend's `estimate_tokens_rough`
|
||||
* in `agent/model_metadata.py`:
|
||||
* - CJK ideographs, Hangul, Kana count as ~1 token each.
|
||||
* - Non-CJK characters count as ceil(UTF-8 bytes / 4).
|
||||
*/
|
||||
object TokenEstimator {
|
||||
fun estimate(text: String?): Int {
|
||||
if (text.isNullOrEmpty()) return 0
|
||||
var dense = 0
|
||||
var nonDenseByteCount = 0
|
||||
for (i in 0 until text.length) {
|
||||
val ch = text[i]
|
||||
if (isCjkDense(ch)) {
|
||||
dense++
|
||||
} else {
|
||||
val code = ch.code
|
||||
nonDenseByteCount +=
|
||||
when {
|
||||
code <= 0x7F -> 1
|
||||
code <= 0x7FF -> 2
|
||||
code <= 0xFFFF -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
}
|
||||
return dense + ((nonDenseByteCount + 3) / 4)
|
||||
}
|
||||
|
||||
private fun isCjkDense(ch: Char): Boolean {
|
||||
val code = ch.code
|
||||
return (code in 0x4E00..0x9FFF) ||
|
||||
(code in 0x3400..0x4DBF) ||
|
||||
(code in 0xAC00..0xD7AF) ||
|
||||
(code in 0x1100..0x11FF) ||
|
||||
(code in 0x3040..0x309F) ||
|
||||
(code in 0x30A0..0x30FF)
|
||||
}
|
||||
|
||||
fun formatTokenCount(tokens: Int): String =
|
||||
when {
|
||||
tokens >= 1_000_000 -> String.format(Locale.US, "%.1fM", tokens / 1_000_000.0)
|
||||
tokens >= 10_000 -> String.format(Locale.US, "%.1fk", tokens / 1_000.0)
|
||||
tokens >= 1_000 -> String.format(Locale.US, "%,d", tokens)
|
||||
else -> tokens.toString()
|
||||
}
|
||||
|
||||
fun formatTps(tps: Double): String = Math.round(tps).toString()
|
||||
}
|
||||
@@ -16,12 +16,15 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
@@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -58,6 +63,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
@@ -84,6 +90,7 @@ internal fun composeToolSummaryLines(
|
||||
): Pair<String, String?>? {
|
||||
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
|
||||
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
|
||||
val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: ""
|
||||
|
||||
val firstLine: String
|
||||
val secondLine: String?
|
||||
@@ -93,6 +100,7 @@ internal fun composeToolSummaryLines(
|
||||
if (subtitle != null) {
|
||||
append(subtitle)
|
||||
}
|
||||
append(statsSuffix)
|
||||
view.countLabel?.let { append(" ($it)") }
|
||||
}
|
||||
secondLine = null
|
||||
@@ -100,6 +108,7 @@ internal fun composeToolSummaryLines(
|
||||
firstLine =
|
||||
buildString {
|
||||
append(view.title)
|
||||
append(statsSuffix)
|
||||
view.countLabel?.let { append(" ($it)") }
|
||||
}
|
||||
secondLine = subtitle
|
||||
@@ -233,6 +242,11 @@ internal fun ToolBubble(
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
@@ -301,6 +315,11 @@ internal fun ToolBubble(
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
@@ -383,6 +402,11 @@ private fun ExpandedToolContent(
|
||||
view.stdout?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
@@ -394,6 +418,11 @@ private fun ExpandedToolContent(
|
||||
view.stderr?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
@@ -408,13 +437,27 @@ private fun ExpandedToolContent(
|
||||
text = stringResource(R.string.chat_tool_exit_code, code),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = statusColors.error,
|
||||
color = statusColors.warning,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Generated/resolved image preview ──
|
||||
view.imageUrl?.let { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = view.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
// ── File diff ──
|
||||
if (view.inlineDiff != null) {
|
||||
DiffViewCard(
|
||||
@@ -479,6 +522,11 @@ private fun ExpandedToolContent(
|
||||
if (view.detail.isNotBlank() && view.inlineDiff == null) {
|
||||
Text(
|
||||
text = view.detail,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
|
||||
@@ -131,6 +131,12 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Checklist,
|
||||
),
|
||||
"todo_list" to
|
||||
ToolDisplayConfig(
|
||||
name = "todo_list",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Checklist,
|
||||
),
|
||||
"fact_store" to
|
||||
ToolDisplayConfig(
|
||||
name = "fact_store",
|
||||
@@ -150,6 +156,12 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Schedule,
|
||||
),
|
||||
"cronjob_manage" to
|
||||
ToolDisplayConfig(
|
||||
name = "cronjob_manage",
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Schedule,
|
||||
),
|
||||
"memory" to
|
||||
ToolDisplayConfig(
|
||||
name = "memory",
|
||||
@@ -168,6 +180,12 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Settings,
|
||||
),
|
||||
"process_manage" to
|
||||
ToolDisplayConfig(
|
||||
name = "process_manage",
|
||||
summaryArgKey = "action",
|
||||
icon = Icons.Filled.Settings,
|
||||
),
|
||||
"skill_manage" to
|
||||
ToolDisplayConfig(
|
||||
name = "skill_manage",
|
||||
@@ -187,6 +205,42 @@ object ToolSchemaRegistry {
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Keyboard,
|
||||
),
|
||||
"browser_scroll" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_scroll",
|
||||
summaryArgKey = "direction",
|
||||
icon = Icons.Filled.TouchApp,
|
||||
),
|
||||
"browser_back" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_back",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Language,
|
||||
),
|
||||
"browser_press" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_press",
|
||||
summaryArgKey = "key",
|
||||
icon = Icons.Filled.Keyboard,
|
||||
),
|
||||
"browser_get_images" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_get_images",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Image,
|
||||
),
|
||||
"browser_vision" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_vision",
|
||||
summaryArgKey = "question",
|
||||
icon = Icons.Filled.Visibility,
|
||||
),
|
||||
"browser_console" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_console",
|
||||
summaryArgKey = null,
|
||||
icon = Icons.Filled.Terminal,
|
||||
),
|
||||
"browser_cdp" to
|
||||
ToolDisplayConfig(
|
||||
name = "browser_cdp",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import com.m57.hermescontrol.ui.common.AppUpdateDialog
|
||||
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
|
||||
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
||||
|
||||
@Composable
|
||||
fun ChatAppUpdateSection() {
|
||||
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
||||
val appUpdateViewModel: AppUpdateViewModel =
|
||||
viewModel {
|
||||
val app =
|
||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
|
||||
?: error("Application not available")
|
||||
AppUpdateViewModel(app)
|
||||
}
|
||||
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val updateLifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(updateLifecycleOwner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
|
||||
appUpdateViewModel.resumeInstallAfterPermission()
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
updateLifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
||||
val noticeTag =
|
||||
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
||||
?: UpdateNoticeManager.noticeTag()
|
||||
if (noticeTag != null) {
|
||||
UpdateNoticeBanner(
|
||||
latestTag = noticeTag,
|
||||
onUpdate = { AppUpdateCache.showDialog() },
|
||||
onDismiss = { AppUpdateCache.dismiss() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (AppUpdateCache.isDialogVisible) {
|
||||
val context = LocalContext.current
|
||||
val dialogState =
|
||||
if (appUpdateState !is AppUpdateState.Idle) {
|
||||
appUpdateState
|
||||
} else {
|
||||
updateNotice
|
||||
}
|
||||
AppUpdateDialog(
|
||||
state = dialogState,
|
||||
onDismiss = { AppUpdateCache.hideDialog() },
|
||||
onStartUpdate = { appUpdateViewModel.startUpdate() },
|
||||
onCancelDownload = { appUpdateViewModel.cancelDownload() },
|
||||
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
||||
onOpenSettings = {
|
||||
launchUnknownAppSourcesSettings(context)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchUnknownAppSourcesSettings(context: Context) {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_SECURITY_SETTINGS).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,10 @@ fun ChatInputBar(
|
||||
onReasoningTap: (String?) -> Unit = {},
|
||||
canDisableReasoning: Boolean? = null,
|
||||
supportsReasoning: Boolean? = null,
|
||||
fastMode: Boolean = false,
|
||||
fastSupported: Boolean = false,
|
||||
isFastModeChanging: Boolean = false,
|
||||
onToggleFastMode: () -> Unit = {},
|
||||
) {
|
||||
// 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
|
||||
@@ -377,6 +381,10 @@ fun ChatInputBar(
|
||||
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||
canDisableReasoning = canDisableReasoning,
|
||||
supportsReasoning = supportsReasoning,
|
||||
fastMode = fastMode,
|
||||
fastSupported = fastSupported,
|
||||
isFastModeChanging = isFastModeChanging,
|
||||
onToggleFastMode = onToggleFastMode,
|
||||
)
|
||||
|
||||
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.speech.RecognizerIntent
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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 java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class ChatMediaLaunchers(
|
||||
val isListening: Boolean,
|
||||
val onMicTap: () -> Unit,
|
||||
val onCameraTap: () -> Unit,
|
||||
val onImageTap: () -> Unit,
|
||||
val onFileTap: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberChatMediaLaunchers(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||
onAddAttachments: (List<Attachment>) -> Unit,
|
||||
onShowMessage: (String) -> Unit,
|
||||
launchExternalActivity: (() -> Unit) -> Unit,
|
||||
context: Context = LocalContext.current,
|
||||
): ChatMediaLaunchers {
|
||||
var isListening by remember { mutableStateOf(false) }
|
||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
|
||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
|
||||
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
|
||||
|
||||
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
||||
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
||||
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
|
||||
|
||||
// Speech-to-text recognition launcher (issue #194)
|
||||
val speechLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
isListening = false
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val spokenText =
|
||||
result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
if (spokenText.isNotBlank()) {
|
||||
val currentText = currentInputFieldValue.text
|
||||
val merged =
|
||||
if (currentText.isBlank()) {
|
||||
spokenText
|
||||
} else {
|
||||
"$currentText $spokenText"
|
||||
}
|
||||
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mic permission launcher
|
||||
val micPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
if (granted) {
|
||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||
isListening = true
|
||||
currentLaunchExternalActivity {
|
||||
try {
|
||||
speechLauncher.launch(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
isListening = false
|
||||
currentOnShowMessage(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentOnShowMessage(sttNotAvailableMsg)
|
||||
}
|
||||
} else {
|
||||
currentOnShowMessage(sttPermissionDeniedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-file picker for attachments (issue #195)
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetMultipleContents(),
|
||||
) { uris: List<Uri> ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
val attachments =
|
||||
uris.mapNotNull { uri ->
|
||||
runCatching {
|
||||
var name = uri.lastPathSegment ?: "file"
|
||||
var size = 0L
|
||||
context.contentResolver
|
||||
.query(
|
||||
uri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx)
|
||||
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
|
||||
}
|
||||
}
|
||||
Attachment(
|
||||
uri = uri.toString(),
|
||||
name = name,
|
||||
mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream",
|
||||
size = size,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Log.w("ChatScreen", "Skipping unreadable picked attachment", error)
|
||||
}.getOrNull()
|
||||
}
|
||||
currentOnAddAttachments(attachments)
|
||||
}
|
||||
|
||||
// Camera photo launcher (issue #195)
|
||||
val cameraLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.TakePicture(),
|
||||
) { success ->
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
val uri = pendingCameraUri
|
||||
pendingCameraUri = null
|
||||
if (success && uri != null) {
|
||||
try {
|
||||
val fileName =
|
||||
"photo_${
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
|
||||
Date(),
|
||||
)
|
||||
}.jpg"
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val size = inputStream?.use { it.available().toLong() } ?: 0L
|
||||
currentOnAddAttachment(uri.toString(), fileName, "image/jpeg", size)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatScreen", "Camera capture failed", e)
|
||||
currentOnShowMessage(cameraErrorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onMicTap: () -> Unit = {
|
||||
if (isListening) {
|
||||
isListening = false
|
||||
} else if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||
isListening = true
|
||||
currentLaunchExternalActivity {
|
||||
try {
|
||||
speechLauncher.launch(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
isListening = false
|
||||
currentOnShowMessage(sttNotAvailableMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentOnShowMessage(sttNotAvailableMsg)
|
||||
}
|
||||
} else {
|
||||
currentLaunchExternalActivity {
|
||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onCameraTap: () -> Unit = {
|
||||
try {
|
||||
val timeStamp =
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val photoFile =
|
||||
File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir)
|
||||
val uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
photoFile,
|
||||
)
|
||||
pendingCameraUri = uri
|
||||
currentLaunchExternalActivity {
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatScreen", "Camera launch failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
val onImageTap: () -> Unit = {
|
||||
currentLaunchExternalActivity {
|
||||
filePickerLauncher.launch("image/*")
|
||||
}
|
||||
}
|
||||
|
||||
val onFileTap: () -> Unit = {
|
||||
currentLaunchExternalActivity {
|
||||
filePickerLauncher.launch("*/*")
|
||||
}
|
||||
}
|
||||
|
||||
return remember(isListening) {
|
||||
ChatMediaLaunchers(
|
||||
isListening = isListening,
|
||||
onMicTap = onMicTap,
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
onFileTap = onFileTap,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,20 @@ package com.m57.hermescontrol.ui.chat.components
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
@@ -21,6 +25,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -29,8 +35,12 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
@@ -60,6 +70,10 @@ fun ComposerToolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
canDisableReasoning: Boolean? = null,
|
||||
supportsReasoning: Boolean? = null,
|
||||
fastMode: Boolean = false,
|
||||
fastSupported: Boolean = false,
|
||||
isFastModeChanging: Boolean = false,
|
||||
onToggleFastMode: () -> Unit = {},
|
||||
) {
|
||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||
val reasoningDisabledForModel = supportsReasoning == false
|
||||
@@ -109,24 +123,44 @@ fun ComposerToolbar(
|
||||
.testTag("model_chip"),
|
||||
)
|
||||
|
||||
// Reasoning chip with dropdown menu (right side, next to mic)
|
||||
// Reasoning & Generation controls with dropdown menu (Option 3)
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = reasoningLevel != null,
|
||||
selected = reasoningLevel != null || fastMode,
|
||||
onClick = { showReasoningMenu = true },
|
||||
enabled = !reasoningDisabledForModel,
|
||||
enabled = isConnected,
|
||||
label = {
|
||||
Text(
|
||||
text =
|
||||
if (reasoningDisabledForModel) {
|
||||
"No reasoning"
|
||||
} else {
|
||||
buildReasoningLabel(reasoningLevel)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (fastMode) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bolt,
|
||||
contentDescription = stringResource(R.string.chat_fast_mode_label),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
if (reasoningDisabledForModel) {
|
||||
if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning"
|
||||
} else {
|
||||
val rLabel = buildReasoningLabel(reasoningLevel)
|
||||
if (fastMode) {
|
||||
"${stringResource(
|
||||
R.string.chat_fast_mode_label,
|
||||
)} · $rLabel"
|
||||
} else {
|
||||
rLabel
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -137,11 +171,74 @@ fun ComposerToolbar(
|
||||
DropdownMenu(
|
||||
expanded = showReasoningMenu,
|
||||
onDismissRequest = { showReasoningMenu = false },
|
||||
modifier = Modifier.widthIn(min = 220.dp),
|
||||
) {
|
||||
// ── Fast Mode Toggle Item ──
|
||||
val fastAvailable = isConnected && fastSupported
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bolt,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (fastMode) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_fast_mode_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
if (!fastSupported) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_fast_mode_unavailable),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (isFastModeChanging) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Switch(
|
||||
checked = fastMode,
|
||||
onCheckedChange = null, // MenuItem click owns the trigger
|
||||
enabled = fastAvailable && !isFastModeChanging,
|
||||
modifier =
|
||||
Modifier
|
||||
.scale(0.85f)
|
||||
.testTag("fast_mode_switch"),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
if (fastAvailable && !isFastModeChanging) {
|
||||
onToggleFastMode()
|
||||
}
|
||||
},
|
||||
enabled = fastAvailable && !isFastModeChanging,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = "Reasoning",
|
||||
text = "REASONING",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
if (canDisable == false) {
|
||||
@@ -160,7 +257,6 @@ fun ComposerToolbar(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
val allLevels =
|
||||
listOf(
|
||||
"none" to "None",
|
||||
|
||||
+752
@@ -0,0 +1,752 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorStatus
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState
|
||||
import com.m57.hermescontrol.ui.chat.ConnectorActionState
|
||||
import com.m57.hermescontrol.ui.chat.ConnectorUiItem
|
||||
import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase
|
||||
|
||||
/**
|
||||
* Visual elements for a connector status badge.
|
||||
*/
|
||||
data class ConnectorStatusVisuals(
|
||||
val icon: ImageVector,
|
||||
val color: Color,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun resolveConnectorStatusVisuals(
|
||||
status: ConnectorStatus,
|
||||
isConnected: Boolean,
|
||||
): ConnectorStatusVisuals {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
return when {
|
||||
isConnected || status == ConnectorStatus.CONNECTED -> {
|
||||
ConnectorStatusVisuals(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
color = statusColors.success,
|
||||
label = stringResource(R.string.session_integrations_status_connected),
|
||||
)
|
||||
}
|
||||
|
||||
status == ConnectorStatus.INITIATED -> {
|
||||
ConnectorStatusVisuals(
|
||||
icon = Icons.Filled.HourglassTop,
|
||||
color = statusColors.info,
|
||||
label = stringResource(R.string.session_integrations_awaiting),
|
||||
)
|
||||
}
|
||||
|
||||
status == ConnectorStatus.EXPIRED -> {
|
||||
ConnectorStatusVisuals(
|
||||
icon = Icons.Filled.Warning,
|
||||
color = statusColors.warning,
|
||||
label = stringResource(R.string.session_integrations_status_expired),
|
||||
)
|
||||
}
|
||||
|
||||
status == ConnectorStatus.REVOKED -> {
|
||||
ConnectorStatusVisuals(
|
||||
icon = Icons.Filled.Warning,
|
||||
color = statusColors.warning,
|
||||
label = stringResource(R.string.session_integrations_status_revoked),
|
||||
)
|
||||
}
|
||||
|
||||
status == ConnectorStatus.FAILED -> {
|
||||
ConnectorStatusVisuals(
|
||||
icon = Icons.Filled.ErrorOutline,
|
||||
color = statusColors.error,
|
||||
label = stringResource(R.string.session_integrations_status_failed),
|
||||
)
|
||||
}
|
||||
|
||||
status == ConnectorStatus.DISCONNECTED -> {
|
||||
ConnectorStatusVisuals(
|
||||
icon = Icons.Filled.LinkOff,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
label = stringResource(R.string.session_integrations_status_disconnected),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ConnectorStatusVisuals(
|
||||
icon = Icons.AutoMirrored.Filled.HelpOutline,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
label = stringResource(R.string.session_integrations_status_unknown),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun mapLoadPhaseErrorMessage(loadPhase: ConnectorsLoadPhase): String? =
|
||||
when (loadPhase) {
|
||||
is ConnectorsLoadPhase.Error -> {
|
||||
mapConnectorErrorMessage(loadPhase.error)
|
||||
}
|
||||
|
||||
is ConnectorsLoadPhase.Unavailable -> {
|
||||
stringResource(R.string.session_integrations_err_unavailable)
|
||||
}
|
||||
|
||||
is ConnectorsLoadPhase.UnsupportedRuntime -> {
|
||||
stringResource(
|
||||
R.string.session_integrations_err_unsupported_runtime,
|
||||
)
|
||||
}
|
||||
|
||||
is ConnectorsLoadPhase.UnsupportedBackend -> {
|
||||
stringResource(
|
||||
R.string.session_integrations_err_unsupported_backend,
|
||||
)
|
||||
}
|
||||
|
||||
is ConnectorsLoadPhase.NotOwner -> {
|
||||
stringResource(R.string.session_integrations_err_not_owner)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun mapConnectorErrorMessage(error: ConnectorError): String =
|
||||
when (error) {
|
||||
is ConnectorError.InvalidParams -> {
|
||||
stringResource(R.string.session_integrations_err_invalid_params)
|
||||
}
|
||||
|
||||
is ConnectorError.NotOwner -> {
|
||||
stringResource(R.string.session_integrations_err_not_owner)
|
||||
}
|
||||
|
||||
is ConnectorError.Unavailable -> {
|
||||
stringResource(R.string.session_integrations_err_unavailable)
|
||||
}
|
||||
|
||||
is ConnectorError.UnsupportedRuntime -> {
|
||||
stringResource(R.string.session_integrations_err_unsupported_runtime)
|
||||
}
|
||||
|
||||
is ConnectorError.UnsupportedBackend -> {
|
||||
stringResource(R.string.session_integrations_err_unsupported_backend)
|
||||
}
|
||||
|
||||
is ConnectorError.RequestFailed -> {
|
||||
stringResource(R.string.session_integrations_err_request_failed)
|
||||
}
|
||||
|
||||
is ConnectorError.InvalidResponse, is ConnectorError.MalformedEnvelope -> {
|
||||
stringResource(R.string.session_integrations_err_invalid_response)
|
||||
}
|
||||
|
||||
is ConnectorError.NetworkError -> {
|
||||
stringResource(R.string.session_integrations_err_network)
|
||||
}
|
||||
|
||||
is ConnectorError.Other -> {
|
||||
stringResource(R.string.session_integrations_err_generic)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun mapActionErrorMessage(rawError: String?): String =
|
||||
when {
|
||||
rawError.isNullOrBlank() -> {
|
||||
stringResource(R.string.session_integrations_err_generic)
|
||||
}
|
||||
|
||||
rawError.contains("not owned", ignoreCase = true) -> {
|
||||
stringResource(R.string.session_integrations_err_not_owner)
|
||||
}
|
||||
|
||||
rawError.contains(
|
||||
"No active session",
|
||||
ignoreCase = true,
|
||||
) -> {
|
||||
stringResource(R.string.session_integrations_no_session)
|
||||
}
|
||||
|
||||
rawError.contains("Invalid connector slug", ignoreCase = true) ||
|
||||
rawError.contains("Invalid params", ignoreCase = true) -> {
|
||||
stringResource(R.string.session_integrations_err_invalid_params)
|
||||
}
|
||||
|
||||
rawError.contains("authorization URL", ignoreCase = true) ||
|
||||
rawError.contains("unexpected status", ignoreCase = true) -> {
|
||||
stringResource(R.string.session_integrations_err_invalid_response)
|
||||
}
|
||||
|
||||
rawError.contains("authorization failed", ignoreCase = true) -> {
|
||||
stringResource(R.string.session_integrations_err_request_failed)
|
||||
}
|
||||
|
||||
rawError.contains("browser", ignoreCase = true) -> {
|
||||
stringResource(R.string.session_integrations_err_browser_launch)
|
||||
}
|
||||
|
||||
else -> {
|
||||
stringResource(R.string.session_integrations_err_generic)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native bottom sheet for session integrations / connectors (issue #1091).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SessionIntegrationsSheet(
|
||||
uiState: ChatConnectorsUiState,
|
||||
sessionId: String? = uiState.sessionId,
|
||||
onDismiss: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onConnect: (slug: String, reconnect: Boolean) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
if (!uiState.isVisible) return
|
||||
|
||||
val effectiveSessionId = sessionId ?: uiState.sessionId
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier.testTag("session_integrations_sheet"),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
SessionIntegrationsContent(
|
||||
uiState = uiState,
|
||||
sessionId = effectiveSessionId,
|
||||
onDismiss = onDismiss,
|
||||
onRefresh = onRefresh,
|
||||
onConnect = onConnect,
|
||||
onClearError = onClearError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless content composable for testing and decoupled layout.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionIntegrationsContent(
|
||||
uiState: ChatConnectorsUiState,
|
||||
sessionId: String? = uiState.sessionId,
|
||||
onDismiss: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onConnect: (slug: String, reconnect: Boolean) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val effectiveSessionId = sessionId ?: uiState.sessionId
|
||||
val isSessionBlank = effectiveSessionId.isNullOrBlank()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
// Top Header
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.session_integrations_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.testTag("session_integrations_title"),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
val subtitleText =
|
||||
if (!isSessionBlank) {
|
||||
val displayId =
|
||||
if (effectiveSessionId.length >
|
||||
12
|
||||
) {
|
||||
"${effectiveSessionId.take(12)}…"
|
||||
} else {
|
||||
effectiveSessionId
|
||||
}
|
||||
stringResource(R.string.session_integrations_subtitle, displayId)
|
||||
} else {
|
||||
stringResource(R.string.session_integrations_no_session)
|
||||
}
|
||||
Text(
|
||||
text = subtitleText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag("session_integrations_subtitle"),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
enabled = !uiState.isLoading && !isSessionBlank,
|
||||
modifier = Modifier.testTag("session_integrations_refresh_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.session_integrations_refresh),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("session_integrations_close_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.session_integrations_close),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
// Action Error Banner
|
||||
if (uiState.errorMessage != null) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("session_integrations_action_error_banner"),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = mapActionErrorMessage(uiState.errorMessage),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onClearError,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.session_integrations_close),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Awaiting Authorization Banner (manual Refresh status affordance)
|
||||
val awaitingSlug =
|
||||
(uiState.actionState as? ConnectorActionState.AwaitingAuthorization)?.slug
|
||||
if (awaitingSlug != null) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("session_integrations_awaiting_banner"),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.OpenInBrowser,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.session_integrations_awaiting),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.session_integrations_awaiting_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onRefresh,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.End)
|
||||
.testTag("session_integrations_refresh_status_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.session_integrations_refresh_status),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content States
|
||||
when {
|
||||
isSessionBlank || (uiState.loadPhase is ConnectorsLoadPhase.Initial && uiState.items.isEmpty()) -> {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 180.dp)
|
||||
.testTag("session_integrations_no_session_state"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.LinkOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.session_integrations_no_session),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.loadPhase is ConnectorsLoadPhase.Loading -> {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 180.dp)
|
||||
.testTag("session_integrations_loading"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.session_integrations_connecting),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.loadPhase !is ConnectorsLoadPhase.Loading &&
|
||||
uiState.loadPhase !is ConnectorsLoadPhase.Refreshing &&
|
||||
uiState.loadPhase !is ConnectorsLoadPhase.Loaded &&
|
||||
uiState.items.isEmpty() -> {
|
||||
// Non-loaded error / unavailable / unsupported / not owner terminal states
|
||||
val errorMessage = mapLoadPhaseErrorMessage(uiState.loadPhase) ?: ""
|
||||
val canRetry = (uiState.loadPhase as? ConnectorsLoadPhase.Error)?.canRetry ?: false
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 180.dp)
|
||||
.testTag("session_integrations_error"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.testTag("session_integrations_error_text"),
|
||||
)
|
||||
if (canRetry) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.testTag("session_integrations_retry_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.session_integrations_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.items.isEmpty() -> {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 180.dp)
|
||||
.testTag("session_integrations_empty"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Extension,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.session_integrations_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// List of items
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("session_integrations_list"),
|
||||
) {
|
||||
if (uiState.loadPhase is ConnectorsLoadPhase.Refreshing) {
|
||||
item {
|
||||
LinearProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("session_integrations_refreshing_indicator"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = uiState.items,
|
||||
key = { it.slug },
|
||||
) { item ->
|
||||
ConnectorRow(
|
||||
item = item,
|
||||
actionState = uiState.actionState,
|
||||
isLoading = uiState.isLoading,
|
||||
onConnect = onConnect,
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectorRow(
|
||||
item: ConnectorUiItem,
|
||||
actionState: ConnectorActionState,
|
||||
isLoading: Boolean,
|
||||
onConnect: (slug: String, reconnect: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val visuals = resolveConnectorStatusVisuals(item.status, item.isConnected)
|
||||
val isCurrentConnecting =
|
||||
actionState is ConnectorActionState.Connecting && actionState.slug == item.slug
|
||||
val isAnyActionPending =
|
||||
actionState is ConnectorActionState.Connecting || actionState is ConnectorActionState.AwaitingAuthorization
|
||||
val needsReconnect =
|
||||
item.isConnected ||
|
||||
item.status == ConnectorStatus.EXPIRED ||
|
||||
item.status == ConnectorStatus.REVOKED
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
.testTag("session_integrations_item_${item.slug}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Accessible Status Indicator Icon
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(visuals.color.copy(alpha = 0.15f))
|
||||
.testTag("session_integrations_status_${item.slug}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = visuals.icon,
|
||||
contentDescription = visuals.label,
|
||||
tint = visuals.color,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = visuals.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = visuals.color,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
if (isCurrentConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.testTag("session_integrations_spinner_${item.slug}"),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
val buttonText =
|
||||
if (needsReconnect) {
|
||||
stringResource(R.string.session_integrations_reconnect)
|
||||
} else {
|
||||
stringResource(R.string.session_integrations_connect)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onConnect(item.slug, needsReconnect) },
|
||||
enabled = item.isEnabled && !isAnyActionPending && !isLoading,
|
||||
modifier = Modifier.testTag("session_integrations_action_${item.slug}"),
|
||||
) {
|
||||
Text(
|
||||
text = buttonText,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+235
-1
@@ -4,20 +4,25 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.AltRoute
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
@@ -32,11 +37,14 @@ import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -45,6 +53,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
|
||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||
|
||||
/**
|
||||
@@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem
|
||||
fun SubagentInspectionSheet(
|
||||
indicators: List<SubagentIndicator> = emptyList(),
|
||||
todos: List<TodoItem> = emptyList(),
|
||||
inspectingSubagentId: String? = null,
|
||||
subagentTranscript: SubagentTranscriptUiState? = null,
|
||||
onToggleTranscript: ((String) -> Unit)? = null,
|
||||
onRetryTranscript: (() -> Unit)? = null,
|
||||
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
|
||||
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -197,7 +211,7 @@ fun SubagentInspectionSheet(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "SUBAGENTS",
|
||||
text = stringResource(R.string.subagent_subagents_header).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
@@ -211,8 +225,14 @@ fun SubagentInspectionSheet(
|
||||
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
||||
},
|
||||
) { _, indicator ->
|
||||
val isInspectingThis =
|
||||
indicator.subagentId != null && indicator.subagentId == inspectingSubagentId
|
||||
InspectionItemCard(
|
||||
indicator = indicator,
|
||||
isInspectingTranscript = isInspectingThis,
|
||||
transcriptState = if (isInspectingThis) subagentTranscript else null,
|
||||
onToggleTranscript = onToggleTranscript,
|
||||
onRetryTranscript = onRetryTranscript,
|
||||
onSteer =
|
||||
if (onSteerSubagent != null) {
|
||||
{ message -> onSteerSubagent(indicator, message) }
|
||||
@@ -448,6 +468,10 @@ private fun TodoInspectionCard(
|
||||
@Composable
|
||||
private fun InspectionItemCard(
|
||||
indicator: SubagentIndicator,
|
||||
isInspectingTranscript: Boolean = false,
|
||||
transcriptState: SubagentTranscriptUiState? = null,
|
||||
onToggleTranscript: ((String) -> Unit)? = null,
|
||||
onRetryTranscript: (() -> Unit)? = null,
|
||||
onSteer: ((String) -> Unit)? = null,
|
||||
onStop: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -619,6 +643,44 @@ private fun InspectionItemCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Full Rolling Transcript Disclosure (issue #1089)
|
||||
if (!indicator.subagentId.isNullOrBlank() && onToggleTranscript != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { onToggleTranscript(indicator.subagentId) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("subagent_transcript_toggle_${indicator.subagentId}"),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isInspectingTranscript) Icons.Filled.ExpandLess else Icons.Filled.Terminal,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text =
|
||||
if (isInspectingTranscript) {
|
||||
stringResource(R.string.subagent_hide_transcript)
|
||||
} else {
|
||||
stringResource(R.string.subagent_inspect_transcript)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isInspectingTranscript) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
SubagentTranscriptView(
|
||||
state = transcriptState,
|
||||
onRetry = onRetryTranscript,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive Controls for active subagent (issue #1030)
|
||||
if (indicator.isRunning || indicator.isSteered) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
@@ -721,3 +783,175 @@ private fun InspectionItemCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentTranscriptView(
|
||||
state: SubagentTranscriptUiState?,
|
||||
onRetry: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth().testTag("subagent_transcript_container"),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
when {
|
||||
state == null || (state.isLoading && state.text.isEmpty()) -> {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.error != null && state.text.isEmpty() -> {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = state.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
OutlinedButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.testTag("subagent_transcript_retry"),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_retry),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.isEmpty -> {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.testTag("subagent_transcript_empty"),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Header badges (truncation note & background loading indicator)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_live_transcript),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontSize = 9.sp,
|
||||
)
|
||||
|
||||
if (state.isTruncated) {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_truncated),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontSize = 8.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(state.text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 60.dp, max = 240.dp)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = state.text,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.testTag("subagent_transcript_text"),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.error != null && onRetry != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = state.error,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Autorenew,
|
||||
contentDescription = stringResource(R.string.subagent_transcript_retry),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Pin
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
|
||||
/**
|
||||
* Interactive prompt cards for mid-turn Credential Vault operations (issue #1090).
|
||||
* Rendered inline in chat (matching ClarifyBubble pattern) to avoid blocking dialog scrims.
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun VaultUnlockCard(
|
||||
prompt: VaultUnlockPromptUi,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var password by remember(prompt.requestId) { mutableStateOf("") }
|
||||
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
|
||||
|
||||
val displayName = prompt.displayName ?: prompt.backend ?: "Password Manager"
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("vault_unlock_card"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_unlock_title, displayName),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.vault_unlock_desc, displayName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.vault_unlock_password_label)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_unlock_password_input"),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("vault_unlock_dismiss"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_unlock_dismiss))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (password.isNotBlank()) {
|
||||
onConfirm(password)
|
||||
}
|
||||
},
|
||||
enabled = password.isNotBlank(),
|
||||
modifier = Modifier.testTag("vault_unlock_confirm"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_unlock_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VaultSaveLoginCard(
|
||||
prompt: VaultSaveLoginPromptUi,
|
||||
onConfirm: (identifier: String, password: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var identifier by remember(prompt.requestId) { mutableStateOf("") }
|
||||
var password by remember(prompt.requestId) { mutableStateOf("") }
|
||||
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
|
||||
|
||||
val siteLabel = prompt.site ?: prompt.origin ?: "this site"
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("vault_save_login_card"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Key,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_save_login_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.vault_save_login_desc, siteLabel),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = identifier,
|
||||
onValueChange = { identifier = it },
|
||||
label = { Text(stringResource(R.string.vault_save_login_identifier)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_save_login_identifier_input"),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Email,
|
||||
),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.vault_save_login_password)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_save_login_password_input"),
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("vault_save_login_dismiss"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_save_login_dismiss))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (identifier.isNotBlank() && password.isNotBlank()) {
|
||||
onConfirm(identifier.trim(), password)
|
||||
}
|
||||
},
|
||||
enabled = identifier.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.testTag("vault_save_login_confirm"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_save_login_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VaultCodeCard(
|
||||
prompt: VaultCodePromptUi,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var code by remember(prompt.requestId) { mutableStateOf("") }
|
||||
|
||||
val siteLabel = prompt.site ?: "site"
|
||||
val desc = prompt.hint?.takeIf { it.isNotBlank() } ?: stringResource(R.string.vault_code_desc, siteLabel)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.testTag("vault_code_card"),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Pin,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.vault_code_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
label = { Text(stringResource(R.string.vault_code_label)) },
|
||||
placeholder = { Text("123456") },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("vault_code_input"),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.testTag("vault_code_dismiss"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_code_dismiss))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (code.isNotBlank()) {
|
||||
onConfirm(code.trim())
|
||||
}
|
||||
},
|
||||
enabled = code.isNotBlank(),
|
||||
modifier = Modifier.testTag("vault_code_confirm"),
|
||||
) {
|
||||
Text(stringResource(R.string.vault_code_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -171,6 +172,44 @@ internal fun FullBleedAgentMessage(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.testTag("fullbleed_finish_time"),
|
||||
)
|
||||
if (message.tokenCount != null && message.tokenCount > 0) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tokens,
|
||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.testTag("fullbleed_token_count"),
|
||||
)
|
||||
}
|
||||
if (message.tps != null && message.tps > 0.0) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.chat_msg_tps,
|
||||
TokenEstimator.formatTps(message.tps),
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.testTag("fullbleed_tps"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,15 @@ 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.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import com.m57.hermescontrol.ui.chat.components.VaultCodeCard
|
||||
import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard
|
||||
import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard
|
||||
import com.m57.hermescontrol.ui.chat.toolCallMilestones
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
|
||||
@@ -69,6 +75,15 @@ fun FullBleedChatList(
|
||||
onRespondClarify: ((String) -> Unit)? = null,
|
||||
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
|
||||
onDismissClarify: (() -> Unit)? = null,
|
||||
vaultUnlockPrompt: VaultUnlockPromptUi? = null,
|
||||
onRespondVaultUnlock: ((String) -> Unit)? = null,
|
||||
onDismissVaultUnlock: (() -> Unit)? = null,
|
||||
vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null,
|
||||
onRespondVaultSaveLogin: ((String, String) -> Unit)? = null,
|
||||
onDismissVaultSaveLogin: (() -> Unit)? = null,
|
||||
vaultCodePrompt: VaultCodePromptUi? = null,
|
||||
onRespondVaultCode: ((String) -> Unit)? = null,
|
||||
onDismissVaultCode: (() -> Unit)? = null,
|
||||
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||
savingAttachmentPath: String? = null,
|
||||
openingAttachmentPath: String? = null,
|
||||
@@ -85,7 +100,7 @@ fun FullBleedChatList(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val toolMilestones = toolCallMilestones(messages)
|
||||
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
|
||||
val turns =
|
||||
remember(messages, streamingMessage) {
|
||||
groupIntoTurnsWithStreaming(messages, streamingMessage)
|
||||
@@ -148,14 +163,12 @@ fun FullBleedChatList(
|
||||
}
|
||||
}
|
||||
|
||||
var entryIndex = 0
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
// Eager captures: item lambda reads these at
|
||||
// composition time (lazy), so capture now.
|
||||
val userMessage = turn.message
|
||||
val milestone = toolMilestones[entryIndex]
|
||||
item(key = "user-${userMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
renderChatBubble(
|
||||
@@ -170,12 +183,8 @@ fun FullBleedChatList(
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
milestone?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
entryIndex++
|
||||
}
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
@@ -210,7 +219,6 @@ fun FullBleedChatList(
|
||||
val hoistedReasoning =
|
||||
turnReasoning != null &&
|
||||
proseMessage.id == turnReasoning.message.id
|
||||
val milestone = toolMilestones[entryIndex]
|
||||
item(key = "prose-${proseMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||
@@ -248,18 +256,14 @@ fun FullBleedChatList(
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
milestone?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
firstProseSeen = true
|
||||
entryIndex++
|
||||
}
|
||||
|
||||
is AgentEntry.ToolRow -> {
|
||||
val toolMessage = entry.message
|
||||
val milestone = toolMilestones[entryIndex]
|
||||
val milestone = toolMilestones[toolMessage.id]
|
||||
item(key = "tool-${toolMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
FullBleedToolRow(toolMessage)
|
||||
@@ -268,7 +272,6 @@ fun FullBleedChatList(
|
||||
}
|
||||
}
|
||||
}
|
||||
entryIndex++
|
||||
}
|
||||
|
||||
is AgentEntry.SystemEvent -> {
|
||||
@@ -288,7 +291,6 @@ fun FullBleedChatList(
|
||||
}
|
||||
}
|
||||
}
|
||||
entryIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +309,38 @@ fun FullBleedChatList(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultUnlockPrompt != null) {
|
||||
item(key = "vault_unlock_card") {
|
||||
VaultUnlockCard(
|
||||
prompt = vaultUnlockPrompt,
|
||||
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
|
||||
onDismiss = { onDismissVaultUnlock?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultSaveLoginPrompt != null) {
|
||||
item(key = "vault_save_login_card") {
|
||||
VaultSaveLoginCard(
|
||||
prompt = vaultSaveLoginPrompt,
|
||||
onConfirm = { identifier, password ->
|
||||
onRespondVaultSaveLogin?.invoke(identifier, password)
|
||||
},
|
||||
onDismiss = { onDismissVaultSaveLogin?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (vaultCodePrompt != null) {
|
||||
item(key = "vault_code_card") {
|
||||
VaultCodeCard(
|
||||
prompt = vaultCodePrompt,
|
||||
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
|
||||
onDismiss = { onDismissVaultCode?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
object MarkdownInlineStyler {
|
||||
private val URL_PATTERN =
|
||||
Regex(
|
||||
"""https?://[^\s<>'"\)\],;]+(?<![.\),;])""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun parseInline(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||
): AnnotatedString =
|
||||
parseInlineSource(
|
||||
text =
|
||||
splitInlineMath(text).joinToString(separator = "") { segment ->
|
||||
when (segment) {
|
||||
is InlineMathSegment.Math -> segment.latex
|
||||
is InlineMathSegment.Text -> segment.value
|
||||
}
|
||||
},
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
)
|
||||
|
||||
fun parseInlineSource(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||
): AnnotatedString {
|
||||
val searchHighlightColor =
|
||||
if (isCurrentMatch) {
|
||||
highlights.currentSearchBackground to highlights.currentSearchForeground
|
||||
} else {
|
||||
highlights.searchBackground to highlights.searchForeground
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
var i = 0
|
||||
val src = text
|
||||
while (i < src.length) {
|
||||
when {
|
||||
// ***bold italic***
|
||||
src.startsWith("***", i) -> {
|
||||
val end = src.indexOf("***", i + 3)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 3, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 3
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// **bold**
|
||||
src.startsWith("**", i) -> {
|
||||
val end = src.indexOf("**", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ~~strike~~
|
||||
src.startsWith("~~", i) -> {
|
||||
val end = src.indexOf("~~", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// *italic*
|
||||
src.startsWith("*", i) -> {
|
||||
val end = src.indexOf('*', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
val raw = src.substring(i + 1, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ==highlight==
|
||||
src.startsWith("==", i) -> {
|
||||
val end = src.indexOf("==", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(background = highlights.markupBackground)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ^superscript^
|
||||
src.startsWith("^", i) -> {
|
||||
val end = src.indexOf('^', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
|
||||
append(src.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ~subscript~ (single tilde; ~~ handled above)
|
||||
src.startsWith("~", i) -> {
|
||||
val end = src.indexOf('~', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
|
||||
append(src.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// <kbd>key</kbd>
|
||||
src.startsWith("<kbd>", i) -> {
|
||||
val end = src.indexOf("</kbd>", i)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 5, end)
|
||||
val toAppend = if (isRtr(isRtl)) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = textColor.copy(alpha = 0.12f),
|
||||
),
|
||||
) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 6
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// [^ref] footnote marker -> superscript
|
||||
src.startsWith("[^", i) -> {
|
||||
val close = src.indexOf(']', i)
|
||||
if (close != -1) {
|
||||
val id = src.substring(i + 2, close)
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
baselineShift = BaselineShift.Superscript,
|
||||
color = linkColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
) {
|
||||
append("[$id]")
|
||||
}
|
||||
i = close + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// [text](url)
|
||||
src.startsWith("[", i) -> {
|
||||
val close = src.indexOf(']', i)
|
||||
if (close != -1 && close + 1 < src.length && src[close + 1] == '(') {
|
||||
val urlEnd = src.indexOf(')', close + 2)
|
||||
if (urlEnd != -1) {
|
||||
val label = src.substring(i + 1, close)
|
||||
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
|
||||
val url = src.substring(close + 2, urlEnd)
|
||||
pushLink(LinkAnnotation.Url(url))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(labelToAppend)
|
||||
}
|
||||
pop()
|
||||
i = urlEnd + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// `inline code`
|
||||
src.startsWith("`", i) -> {
|
||||
val end = src.indexOf('`', i + 1)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 1, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
background = textColor.copy(alpha = 0.08f),
|
||||
),
|
||||
) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// bare URL
|
||||
URL_PATTERN.matchAt(src, i) != null -> {
|
||||
val match = URL_PATTERN.matchAt(src, i)!!
|
||||
val url = match.value
|
||||
val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url
|
||||
pushLink(LinkAnnotation.Url(url))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(urlToAppend)
|
||||
}
|
||||
pop()
|
||||
i = match.range.last + 1
|
||||
}
|
||||
|
||||
// Plain text / words in RTL
|
||||
isRtl && Character.isLetterOrDigit(src.codePointAt(i)) &&
|
||||
Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
|
||||
var end = i + Character.charCount(src.codePointAt(i))
|
||||
while (end < src.length) {
|
||||
val cp = src.codePointAt(end)
|
||||
val dir = Character.getDirectionality(cp)
|
||||
if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR ||
|
||||
(
|
||||
dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length &&
|
||||
Character.getDirectionality(src.codePointAt(end + 1)) ==
|
||||
Character.DIRECTIONALITY_LEFT_TO_RIGHT
|
||||
)
|
||||
) {
|
||||
end += Character.charCount(cp)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
val ltrSnippet = src.substring(i, end)
|
||||
append(BidiUtils.wrapLtrIsolate(ltrSnippet))
|
||||
i = end
|
||||
}
|
||||
|
||||
// search highlight
|
||||
searchQuery.isNotEmpty() &&
|
||||
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = searchHighlightColor.first,
|
||||
color = searchHighlightColor.second,
|
||||
),
|
||||
) {
|
||||
append(src.substring(i, i + searchQuery.length))
|
||||
}
|
||||
i += searchQuery.length
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRtr(isRtl: Boolean): Boolean = isRtl
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.hrm.latex.renderer.measure.LatexMeasurerState
|
||||
import com.hrm.latex.renderer.model.LatexConfig
|
||||
import com.hrm.latex.renderer.model.LatexTheme
|
||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
@Composable
|
||||
fun MarkdownInlineText(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
latexMeasurer: LatexMeasurerState,
|
||||
style: TextStyle,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
val resolvedStyle =
|
||||
style.copy(
|
||||
textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
)
|
||||
val processedText =
|
||||
remember(text, isRtl) {
|
||||
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
val segments = remember(processedText) { splitInlineMath(processedText) }
|
||||
if (segments.none { it is InlineMathSegment.Math }) {
|
||||
Text(
|
||||
text =
|
||||
remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
|
||||
MarkdownInlineStyler.parseInlineSource(
|
||||
text = processedText,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
)
|
||||
},
|
||||
color = textColor,
|
||||
style = resolvedStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
return@CompositionLocalProvider
|
||||
}
|
||||
|
||||
val config =
|
||||
LatexConfig(
|
||||
fontSize = style.fontSize,
|
||||
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
|
||||
accessibilityEnabled = true,
|
||||
)
|
||||
val inlineContent = mutableMapOf<String, InlineTextContent>()
|
||||
segments.forEachIndexed { index, segment ->
|
||||
if (segment is InlineMathSegment.Math) {
|
||||
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
|
||||
}
|
||||
}
|
||||
val annotated =
|
||||
buildAnnotatedString {
|
||||
segments.forEachIndexed { index, segment ->
|
||||
when (segment) {
|
||||
is InlineMathSegment.Math -> {
|
||||
val id = "latex-$index"
|
||||
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
|
||||
}
|
||||
|
||||
is InlineMathSegment.Text -> {
|
||||
append(
|
||||
MarkdownInlineStyler.parseInlineSource(
|
||||
text = segment.value,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotated,
|
||||
inlineContent = inlineContent,
|
||||
color = textColor,
|
||||
style = resolvedStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
|
||||
object MarkdownMediaResolver {
|
||||
fun resolveImageUrl(uri: String): String {
|
||||
val trimmed = uri.trim()
|
||||
if (trimmed.isBlank()) return uri
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) {
|
||||
return trimmed
|
||||
}
|
||||
val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("")
|
||||
val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty()
|
||||
val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed)
|
||||
if (mediaUrl != null) return mediaUrl
|
||||
|
||||
if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) {
|
||||
val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
|
||||
val sep = if (cleanPath.contains("?")) "&" else "?"
|
||||
return if (token.isNotBlank() && !cleanPath.contains("token=")) {
|
||||
"${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token"
|
||||
} else {
|
||||
"${baseUrl.trimEnd('/')}$cleanPath"
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
sealed interface MdBlock {
|
||||
data class Code(
|
||||
val code: String,
|
||||
val language: String? = null,
|
||||
) : MdBlock
|
||||
|
||||
data class Math(
|
||||
val latex: String,
|
||||
) : MdBlock
|
||||
|
||||
data class Heading(
|
||||
val level: Int,
|
||||
val text: String,
|
||||
) : MdBlock
|
||||
|
||||
data class Bullet(
|
||||
val text: String,
|
||||
val level: Int = 0,
|
||||
) : MdBlock
|
||||
|
||||
data class Task(
|
||||
val checked: Boolean,
|
||||
val text: String,
|
||||
val level: Int = 0,
|
||||
) : MdBlock
|
||||
|
||||
data class Ordered(
|
||||
val index: Int,
|
||||
val text: String,
|
||||
val level: Int = 0,
|
||||
) : MdBlock
|
||||
|
||||
data class Image(
|
||||
val uri: String,
|
||||
val alt: String = "",
|
||||
) : MdBlock
|
||||
|
||||
data class Video(
|
||||
val uri: String,
|
||||
val alt: String = "",
|
||||
) : MdBlock
|
||||
|
||||
data class Quote(
|
||||
val text: String,
|
||||
) : MdBlock
|
||||
|
||||
data class Paragraph(
|
||||
val text: String,
|
||||
) : MdBlock
|
||||
|
||||
data class Table(
|
||||
val header: List<String>,
|
||||
val alignments: List<TableAlign>,
|
||||
val rows: List<List<String>>,
|
||||
) : MdBlock
|
||||
|
||||
object Hr : MdBlock
|
||||
|
||||
data class DefList(
|
||||
val items: List<DefItem>,
|
||||
) : MdBlock
|
||||
|
||||
data class Footnotes(
|
||||
val notes: List<FnNote>,
|
||||
) : MdBlock
|
||||
}
|
||||
|
||||
data class DefItem(
|
||||
val term: String,
|
||||
val definitions: List<String>,
|
||||
)
|
||||
|
||||
data class Footnote(
|
||||
val id: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
data class FnNote(
|
||||
val id: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
enum class TableAlign {
|
||||
LEFT,
|
||||
CENTER,
|
||||
RIGHT,
|
||||
}
|
||||
|
||||
sealed interface InlineMathSegment {
|
||||
data class Text(
|
||||
val value: String,
|
||||
) : InlineMathSegment
|
||||
|
||||
data class Math(
|
||||
val latex: String,
|
||||
) : InlineMathSegment
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
|
||||
private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""")
|
||||
private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
|
||||
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
|
||||
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
|
||||
private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
|
||||
|
||||
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
|
||||
fun splitInlineMath(text: String): List<InlineMathSegment> {
|
||||
val segments = mutableListOf<InlineMathSegment>()
|
||||
var plainStart = 0
|
||||
var i = 0
|
||||
|
||||
while (i < text.length) {
|
||||
if (text[i] == '`' && !text.isEscaped(i)) {
|
||||
val codeEnd = text.indexOf('`', i + 1)
|
||||
i = if (codeEnd == -1) text.length else codeEnd + 1
|
||||
continue
|
||||
}
|
||||
if (text.startsWith("\\(", i)) {
|
||||
val end = text.indexOf("\\)", i + 2)
|
||||
if (end != -1 && end > i + 2) {
|
||||
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
|
||||
segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
|
||||
i = end + 2
|
||||
plainStart = i
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
var end = i + 1
|
||||
while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
|
||||
if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
|
||||
segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
|
||||
i = end + 1
|
||||
plainStart = i
|
||||
}
|
||||
|
||||
if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
|
||||
return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
|
||||
}
|
||||
|
||||
private fun String.isEscaped(index: Int): Boolean {
|
||||
var slashes = 0
|
||||
var i = index - 1
|
||||
while (i >= 0 && this[i] == '\\') {
|
||||
slashes++
|
||||
i--
|
||||
}
|
||||
return slashes % 2 == 1
|
||||
}
|
||||
|
||||
private fun tryParseDisplayMath(
|
||||
lines: List<String>,
|
||||
start: Int,
|
||||
): Pair<MdBlock.Math, Int>? {
|
||||
val first = lines[start].trim()
|
||||
if (first.startsWith("\\[")) {
|
||||
if (first.length > 4 && first.endsWith("\\]")) {
|
||||
val latex = first.substring(2, first.length - 2).trim()
|
||||
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
|
||||
}
|
||||
if (first != "\\[") return null
|
||||
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
|
||||
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
|
||||
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
|
||||
}
|
||||
if (!first.startsWith("$$")) return null
|
||||
if (first.length > 4 && first.endsWith("$$")) {
|
||||
val latex = first.substring(2, first.length - 2).trim()
|
||||
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
|
||||
}
|
||||
if (first != "$$") return null
|
||||
|
||||
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
|
||||
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
|
||||
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
|
||||
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
|
||||
*/
|
||||
fun parseBlocks(src: String): List<MdBlock> {
|
||||
val lines = src.lines()
|
||||
val blocks = mutableListOf<MdBlock>()
|
||||
val footnotes = mutableListOf<Footnote>()
|
||||
var i = 0
|
||||
|
||||
while (i < lines.size) {
|
||||
val line = lines[i]
|
||||
|
||||
// Footnote definition: [^id]: text (collected, not rendered inline)
|
||||
val fnMatch = FN_DEF_RE.matchAt(line, 0)
|
||||
if (fnMatch != null) {
|
||||
footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
val displayMath = tryParseDisplayMath(lines, i)
|
||||
if (displayMath != null) {
|
||||
blocks.add(displayMath.first)
|
||||
i = displayMath.second
|
||||
continue
|
||||
}
|
||||
|
||||
val codeFence = parseCodeFenceStart(line)
|
||||
when {
|
||||
codeFence != null -> {
|
||||
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
|
||||
if (end != null) {
|
||||
blocks.add(
|
||||
MdBlock.Code(
|
||||
code = lines.subList(i + 1, end).joinToString("\n"),
|
||||
language = codeFence.language,
|
||||
),
|
||||
)
|
||||
i = end + 1
|
||||
} else {
|
||||
blocks.add(
|
||||
MdBlock.Code(
|
||||
code = lines.subList(i + 1, lines.size).joinToString("\n"),
|
||||
language = codeFence.language,
|
||||
),
|
||||
)
|
||||
i = lines.size
|
||||
}
|
||||
}
|
||||
|
||||
line.isBlank() -> {
|
||||
i++
|
||||
}
|
||||
|
||||
isHorizontalRule(line) -> {
|
||||
blocks.add(MdBlock.Hr)
|
||||
i++
|
||||
}
|
||||
|
||||
// Heading: requires a space after the '#' run so "#572" stays a paragraph
|
||||
isValidHeading(line) -> {
|
||||
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
|
||||
blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
|
||||
i++
|
||||
}
|
||||
|
||||
isTableStart(lines, i) -> {
|
||||
val (header, alignments, body) = parseTable(lines, i)
|
||||
blocks.add(MdBlock.Table(header, alignments, body))
|
||||
i += body.size + 2
|
||||
}
|
||||
|
||||
line.startsWith(">") -> {
|
||||
val quote = mutableListOf<String>()
|
||||
while (i < lines.size && lines[i].startsWith(">")) {
|
||||
quote.add(lines[i].removePrefix(">").trim())
|
||||
i++
|
||||
}
|
||||
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
|
||||
}
|
||||
|
||||
// Definition list: term line followed by one+ ": definition" lines
|
||||
isDefListStart(lines, i) -> {
|
||||
val term = line.trim()
|
||||
val defs = mutableListOf<String>()
|
||||
i++
|
||||
while (i < lines.size && lines[i].trim().startsWith(":")) {
|
||||
defs.add(lines[i].trim().removePrefix(":").trim())
|
||||
i++
|
||||
}
|
||||
blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
|
||||
}
|
||||
|
||||
LIST_ITEM_LINE_RE.matches(line) -> {
|
||||
val (listBlocks, nextIndex) = parseList(lines, i)
|
||||
blocks.addAll(listBlocks)
|
||||
i = nextIndex
|
||||
}
|
||||
|
||||
// Standalone Markdown image:  on its own line.
|
||||
line.isNotBlank() && tryParseVideo(line) != null -> {
|
||||
blocks.add(tryParseVideo(line)!!)
|
||||
i++
|
||||
}
|
||||
|
||||
line.isNotBlank() && tryParseImage(line) != null -> {
|
||||
blocks.add(tryParseImage(line)!!)
|
||||
i++
|
||||
}
|
||||
|
||||
else -> {
|
||||
i = fallthroughToParagraph(lines, i, blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (footnotes.isNotEmpty()) {
|
||||
blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
private sealed interface ParsedItem {
|
||||
val indent: Int
|
||||
val continuationLines: MutableList<String>
|
||||
|
||||
data class Bullet(
|
||||
override val indent: Int,
|
||||
val text: String,
|
||||
override val continuationLines: MutableList<String> = mutableListOf(),
|
||||
) : ParsedItem
|
||||
|
||||
data class Task(
|
||||
override val indent: Int,
|
||||
val checked: Boolean,
|
||||
val text: String,
|
||||
override val continuationLines: MutableList<String> = mutableListOf(),
|
||||
) : ParsedItem
|
||||
|
||||
data class Ordered(
|
||||
override val indent: Int,
|
||||
val rawNumber: Int,
|
||||
val text: String,
|
||||
override val continuationLines: MutableList<String> = mutableListOf(),
|
||||
) : ParsedItem
|
||||
}
|
||||
|
||||
private fun computeIndent(prefix: String): Int {
|
||||
var count = 0
|
||||
for (ch in prefix) {
|
||||
if (ch == '\t') {
|
||||
count += 4 - (count % 4)
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun parseList(
|
||||
lines: List<String>,
|
||||
startIndex: Int,
|
||||
): Pair<List<MdBlock>, Int> {
|
||||
val items = mutableListOf<ParsedItem>()
|
||||
var j = startIndex
|
||||
|
||||
while (j < lines.size) {
|
||||
val line = lines[j]
|
||||
|
||||
val taskMatch = TASK_LINE_RE.matchEntire(line)
|
||||
val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null
|
||||
val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null
|
||||
|
||||
if (taskMatch != null) {
|
||||
val indent = computeIndent(taskMatch.groupValues[1])
|
||||
val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true)
|
||||
val text = taskMatch.groupValues[3]
|
||||
items.add(ParsedItem.Task(indent, checked, text))
|
||||
j++
|
||||
} else if (bulletMatch != null) {
|
||||
val indent = computeIndent(bulletMatch.groupValues[1])
|
||||
val text = bulletMatch.groupValues[2]
|
||||
items.add(ParsedItem.Bullet(indent, text))
|
||||
j++
|
||||
} else if (orderedMatch != null) {
|
||||
val indent = computeIndent(orderedMatch.groupValues[1])
|
||||
val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1
|
||||
val text = orderedMatch.groupValues[3]
|
||||
items.add(ParsedItem.Ordered(indent, num, text))
|
||||
j++
|
||||
} else if (line.isBlank()) {
|
||||
// Check if there is a following list item
|
||||
var lookahead = j + 1
|
||||
while (lookahead < lines.size && lines[lookahead].isBlank()) {
|
||||
lookahead++
|
||||
}
|
||||
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
|
||||
j = lookahead
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Check if this is a continuation line for the previous list item
|
||||
val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' })
|
||||
if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) {
|
||||
items.last().continuationLines.add(line.trim())
|
||||
j++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
return emptyList<MdBlock>() to j
|
||||
}
|
||||
|
||||
val blocks = mutableListOf<MdBlock>()
|
||||
|
||||
// Track state of list items and their relative nesting depth using an indent stack
|
||||
val indentStack = mutableListOf<Int>() // maps level (index) -> base indent
|
||||
var lastItemWasOrdered = false
|
||||
var currentSequenceNumber = 1
|
||||
var lastLevel = -1
|
||||
|
||||
for (item in items) {
|
||||
// Resolve level using the indent stack
|
||||
val level: Int
|
||||
if (indentStack.isEmpty() || item.indent == 0) {
|
||||
indentStack.clear()
|
||||
indentStack.add(item.indent)
|
||||
level = 0
|
||||
} else if (item.indent > indentStack.last()) {
|
||||
indentStack.add(item.indent)
|
||||
level = indentStack.size - 1
|
||||
} else if (item.indent == indentStack.last()) {
|
||||
level = indentStack.size - 1
|
||||
} else {
|
||||
// Unwind stack to the matching or nearest smaller indent
|
||||
while (indentStack.size > 1 && indentStack.last() > item.indent) {
|
||||
indentStack.removeAt(indentStack.size - 1)
|
||||
}
|
||||
level = indentStack.size - 1
|
||||
}
|
||||
|
||||
val fullText =
|
||||
if (item.continuationLines.isEmpty()) {
|
||||
when (item) {
|
||||
is ParsedItem.Bullet -> item.text
|
||||
is ParsedItem.Task -> item.text
|
||||
is ParsedItem.Ordered -> item.text
|
||||
}
|
||||
} else {
|
||||
val base =
|
||||
when (item) {
|
||||
is ParsedItem.Bullet -> item.text
|
||||
is ParsedItem.Task -> item.text
|
||||
is ParsedItem.Ordered -> item.text
|
||||
}
|
||||
base + " " + item.continuationLines.joinToString(" ")
|
||||
}
|
||||
|
||||
when (item) {
|
||||
is ParsedItem.Bullet -> {
|
||||
blocks.add(MdBlock.Bullet(fullText, level = level))
|
||||
lastItemWasOrdered = false
|
||||
lastLevel = level
|
||||
}
|
||||
|
||||
is ParsedItem.Task -> {
|
||||
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
|
||||
lastItemWasOrdered = false
|
||||
lastLevel = level
|
||||
}
|
||||
|
||||
is ParsedItem.Ordered -> {
|
||||
val indexToUse =
|
||||
if (level == lastLevel && lastItemWasOrdered) {
|
||||
if (item.rawNumber > currentSequenceNumber) {
|
||||
currentSequenceNumber = item.rawNumber
|
||||
} else {
|
||||
currentSequenceNumber++
|
||||
}
|
||||
currentSequenceNumber
|
||||
} else {
|
||||
currentSequenceNumber = item.rawNumber
|
||||
currentSequenceNumber
|
||||
}
|
||||
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
|
||||
lastItemWasOrdered = true
|
||||
lastLevel = level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks to j
|
||||
}
|
||||
|
||||
private fun isHorizontalRule(line: String): Boolean {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.length < 3) return false
|
||||
val c = trimmed[0]
|
||||
return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
|
||||
}
|
||||
|
||||
private fun isTableStart(
|
||||
lines: List<String>,
|
||||
i: Int,
|
||||
): Boolean {
|
||||
val l = lines[i]
|
||||
if (!l.contains('|')) return false
|
||||
if (i + 1 >= lines.size) return false
|
||||
return isTableSeparator(lines[i + 1])
|
||||
}
|
||||
|
||||
private fun isTableSeparator(line: String): Boolean {
|
||||
val t = line.trim().trim('|')
|
||||
if (t.isEmpty()) return false
|
||||
// every cell must be only dashes/colons/spaces, with at least one dash
|
||||
return t.split('|').all { cell ->
|
||||
val c = cell.trim()
|
||||
c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTable(
|
||||
lines: List<String>,
|
||||
i: Int,
|
||||
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
|
||||
val header = splitRow(lines[i])
|
||||
val alignments =
|
||||
splitRow(lines[i + 1]).map { cell ->
|
||||
val c = cell.trim()
|
||||
when {
|
||||
c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
|
||||
c.endsWith(":") -> TableAlign.RIGHT
|
||||
c.startsWith(":") -> TableAlign.LEFT
|
||||
else -> TableAlign.LEFT
|
||||
}
|
||||
}
|
||||
val body = mutableListOf<List<String>>()
|
||||
var j = i + 2
|
||||
while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
|
||||
body.add(splitRow(lines[j]))
|
||||
j++
|
||||
}
|
||||
return Triple(header, alignments, body)
|
||||
}
|
||||
|
||||
private fun splitRow(line: String): List<String> {
|
||||
val trimmed = line.trim().trim('|')
|
||||
return trimmed.split('|').map { it.trim() }
|
||||
}
|
||||
|
||||
private data class CodeFenceInfo(
|
||||
val fenceChar: Char,
|
||||
val fenceLength: Int,
|
||||
val language: String?,
|
||||
)
|
||||
|
||||
private fun parseCodeFenceStart(line: String): CodeFenceInfo? {
|
||||
val trimmed = line.trimStart()
|
||||
if (trimmed.length < 3) return null
|
||||
val firstChar = trimmed[0]
|
||||
if (firstChar != '`' && firstChar != '~') return null
|
||||
val count = trimmed.takeWhile { it == firstChar }.length
|
||||
if (count < 3) return null
|
||||
val rest = trimmed.substring(count).trim()
|
||||
if (firstChar == '`' && rest.contains('`')) return null
|
||||
return CodeFenceInfo(
|
||||
fenceChar = firstChar,
|
||||
fenceLength = count,
|
||||
language = rest.ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun isCodeFenceEnd(
|
||||
line: String,
|
||||
fence: CodeFenceInfo,
|
||||
): Boolean {
|
||||
val trimmed = line.trimStart()
|
||||
val count = trimmed.takeWhile { it == fence.fenceChar }.length
|
||||
if (count < fence.fenceLength) return false
|
||||
val rest = trimmed.substring(count).trim()
|
||||
return rest.isEmpty()
|
||||
}
|
||||
|
||||
private fun isDefListStart(
|
||||
lines: List<String>,
|
||||
i: Int,
|
||||
): Boolean {
|
||||
val l = lines[i].trim()
|
||||
if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false
|
||||
if (LIST_ITEM_LINE_RE.matches(lines[i])) return false
|
||||
if (i + 1 >= lines.size) return false
|
||||
return lines[i + 1].trim().startsWith(":")
|
||||
}
|
||||
|
||||
private fun isValidHeading(line: String): Boolean {
|
||||
if (!line.startsWith("#")) return false
|
||||
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
|
||||
return level < line.length && line[level] == ' '
|
||||
}
|
||||
|
||||
private fun tryParseImage(line: String): MdBlock.Image? {
|
||||
val m = IMAGE_RE.matchAt(line, 0) ?: return null
|
||||
val uri = m.groupValues[2].trim()
|
||||
if (uri.isEmpty()) return null
|
||||
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
|
||||
}
|
||||
|
||||
private fun tryParseVideo(line: String): MdBlock.Video? {
|
||||
val m = IMAGE_RE.matchAt(line, 0) ?: return null
|
||||
val uri = m.groupValues[2].trim()
|
||||
if (uri.isEmpty()) return null
|
||||
val ext = uri.substringAfterLast('.', "").lowercase()
|
||||
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
|
||||
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fallthroughToParagraph(
|
||||
lines: List<String>,
|
||||
start: Int,
|
||||
blocks: MutableList<MdBlock>,
|
||||
): Int {
|
||||
var i = start
|
||||
val para = mutableListOf<String>()
|
||||
while (
|
||||
i < lines.size &&
|
||||
lines[i].isNotBlank() &&
|
||||
parseCodeFenceStart(lines[i]) == null &&
|
||||
!isValidHeading(lines[i]) &&
|
||||
!lines[i].startsWith(">") &&
|
||||
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
|
||||
!isHorizontalRule(lines[i]) &&
|
||||
!isTableStart(lines, i) &&
|
||||
!isDefListStart(lines, i) &&
|
||||
tryParseDisplayMath(lines, i) == null &&
|
||||
FN_DEF_RE.matchAt(lines[i], 0) == null
|
||||
) {
|
||||
para.add(lines[i])
|
||||
i++
|
||||
}
|
||||
if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
|
||||
return i
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
private val TABLE_COL_WIDTH = 140.dp
|
||||
|
||||
@Composable
|
||||
fun MarkdownTable(
|
||||
block: MdBlock.Table,
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isRtl =
|
||||
remember(block) {
|
||||
block.header.any { BidiUtils.isRtlText(it) } ||
|
||||
block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } }
|
||||
}
|
||||
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
val headerBg = textColor.copy(alpha = 0.08f)
|
||||
val alignments = block.alignments
|
||||
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
// Header row
|
||||
Row(modifier = Modifier.background(headerBg)) {
|
||||
block.header.forEachIndexed { idx, cell ->
|
||||
val cellRtl = BidiUtils.isRtlText(cell)
|
||||
Text(
|
||||
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||
color = textColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
|
||||
// Body rows
|
||||
block.rows.forEach { row ->
|
||||
Row {
|
||||
row.forEachIndexed { idx, cell ->
|
||||
val cellRtl = BidiUtils.isRtlText(cell)
|
||||
Text(
|
||||
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||
color = textColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableTextAlign(align: TableAlign?): TextAlign? =
|
||||
when (align) {
|
||||
TableAlign.CENTER -> TextAlign.Center
|
||||
TableAlign.RIGHT -> TextAlign.End
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* One normalized tool invocation, as seen by the display engine.
|
||||
*
|
||||
* [args] and [result] are the payloads coerced to records (stringified JSON
|
||||
* re-parsed, non-objects dropped); [rawArgs]/[rawResult] keep the original
|
||||
* elements for renderers that need arrays, scalars, or wrapper unwrapping.
|
||||
*/
|
||||
internal data class ToolCall(
|
||||
val name: String,
|
||||
val args: JsonObject?,
|
||||
val result: JsonObject?,
|
||||
val rawArgs: JsonElement?,
|
||||
val rawResult: JsonElement?,
|
||||
) {
|
||||
companion object {
|
||||
fun of(
|
||||
name: String,
|
||||
rawArgs: JsonElement?,
|
||||
rawResult: JsonElement?,
|
||||
): ToolCall =
|
||||
ToolCall(
|
||||
name = name,
|
||||
args = ToolJson.parseMaybeObject(rawArgs),
|
||||
result = ToolJson.parseMaybeObject(rawResult),
|
||||
rawArgs = rawArgs,
|
||||
rawResult = rawResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Count-label extraction ("3 results", "2 files") for the collapsed row.
|
||||
*
|
||||
* Hunts for a count in this order: known count fields, known list arrays,
|
||||
* `*_count`/`*_total` field names, then plain-text "Found N matches"
|
||||
* phrasing in summary/message fields. web_search always counts its hits and
|
||||
* always reports "results".
|
||||
*/
|
||||
internal object ToolCounts {
|
||||
private val COUNT_FIELD_KEYS =
|
||||
listOf(
|
||||
"count",
|
||||
"total",
|
||||
"result_count",
|
||||
"results_count",
|
||||
"num_results",
|
||||
"match_count",
|
||||
"matches_count",
|
||||
"file_count",
|
||||
"files_count",
|
||||
"item_count",
|
||||
"items_count",
|
||||
"search_count",
|
||||
"searches_count",
|
||||
"source_count",
|
||||
"sources_count",
|
||||
"document_count",
|
||||
"documents_count",
|
||||
"updated",
|
||||
"added",
|
||||
"removed",
|
||||
"deleted",
|
||||
"created",
|
||||
"changed",
|
||||
"processed",
|
||||
"steps",
|
||||
)
|
||||
|
||||
private val COUNT_ARRAY_KEYS = listOf("results", "items", "matches", "files", "documents", "sources", "rows")
|
||||
|
||||
private val COUNT_EXCLUDED_KEYS = setOf("duration_s", "exit_code", "status_code")
|
||||
|
||||
private val NOUN_BY_FIELD =
|
||||
mapOf(
|
||||
"result_count" to "result",
|
||||
"results_count" to "result",
|
||||
"num_results" to "result",
|
||||
"match_count" to "match",
|
||||
"matches_count" to "match",
|
||||
"file_count" to "file",
|
||||
"files_count" to "file",
|
||||
"item_count" to "item",
|
||||
"items_count" to "item",
|
||||
"search_count" to "search",
|
||||
"searches_count" to "search",
|
||||
"source_count" to "source",
|
||||
"sources_count" to "source",
|
||||
"document_count" to "document",
|
||||
"documents_count" to "document",
|
||||
"updated" to "item",
|
||||
"added" to "item",
|
||||
"removed" to "item",
|
||||
"deleted" to "item",
|
||||
"created" to "item",
|
||||
"changed" to "item",
|
||||
"processed" to "item",
|
||||
"steps" to "step",
|
||||
)
|
||||
|
||||
private val NOUN_BY_ARRAY =
|
||||
mapOf(
|
||||
"documents" to "document",
|
||||
"files" to "file",
|
||||
"items" to "item",
|
||||
"matches" to "match",
|
||||
"results" to "result",
|
||||
"rows" to "row",
|
||||
"sources" to "source",
|
||||
)
|
||||
|
||||
private fun countFromUnknown(value: JsonElement?): Int? {
|
||||
if (value is JsonArray) {
|
||||
return if (value.isNotEmpty()) value.size else null
|
||||
}
|
||||
|
||||
val n = ToolJson.numberValue(value) ?: return null
|
||||
|
||||
if (n <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Math.round(n).toInt()
|
||||
}
|
||||
|
||||
private fun singularizeNoun(noun: String): String {
|
||||
val normalized = noun.lowercase()
|
||||
|
||||
if (normalized.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (normalized.endsWith("ies") && normalized.length > 3) {
|
||||
return "${normalized.dropLast(3)}y"
|
||||
}
|
||||
|
||||
if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) {
|
||||
return normalized.dropLast(2)
|
||||
}
|
||||
|
||||
if (normalized.endsWith("s") && normalized.length > 2 && !normalized.endsWith("ss")) {
|
||||
return normalized.dropLast(1)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun pluralizeNoun(
|
||||
noun: String,
|
||||
count: Int,
|
||||
): String {
|
||||
if (count == 1) {
|
||||
return noun
|
||||
}
|
||||
|
||||
if (noun == "search") {
|
||||
return "searches"
|
||||
}
|
||||
|
||||
if (noun.endsWith(
|
||||
"y",
|
||||
) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun)
|
||||
) {
|
||||
return "${noun.dropLast(1)}ies"
|
||||
}
|
||||
|
||||
if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) {
|
||||
return "${noun}es"
|
||||
}
|
||||
|
||||
return "${noun}s"
|
||||
}
|
||||
|
||||
private fun countMetric(
|
||||
count: Int,
|
||||
noun: String,
|
||||
): Pair<Int, String> = count to singularizeNoun(noun).ifEmpty { "item" }
|
||||
|
||||
private fun countFromRecord(
|
||||
record: JsonObject,
|
||||
fallbackNoun: String,
|
||||
): Pair<Int, String>? {
|
||||
for (key in COUNT_FIELD_KEYS) {
|
||||
val count = countFromUnknown(record[key]) ?: continue
|
||||
|
||||
return countMetric(count, NOUN_BY_FIELD[key] ?: fallbackNoun)
|
||||
}
|
||||
|
||||
for (key in COUNT_ARRAY_KEYS) {
|
||||
val count = countFromUnknown(record[key]) ?: continue
|
||||
|
||||
return countMetric(count, NOUN_BY_ARRAY[key] ?: fallbackNoun)
|
||||
}
|
||||
|
||||
for ((key, value) in record) {
|
||||
if (key in COUNT_EXCLUDED_KEYS) {
|
||||
continue
|
||||
}
|
||||
if (!Regex("_count$|_total$").containsMatchIn(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
val count = countFromUnknown(value) ?: continue
|
||||
val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "")
|
||||
|
||||
return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun countFromText(
|
||||
text: String,
|
||||
fallbackNoun: String,
|
||||
): Pair<Int, String>? {
|
||||
val t = text.trim()
|
||||
if (t.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val unitMatch =
|
||||
Regex(
|
||||
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(t)
|
||||
?: Regex(
|
||||
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(t)
|
||||
|
||||
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
||||
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
|
||||
|
||||
return if (n > 0) countMetric(n, noun) else null
|
||||
}
|
||||
|
||||
fun countLabelFor(call: ToolCall): Pair<Int, String>? {
|
||||
val rawResult = call.rawResult ?: return null
|
||||
val toolName = call.name
|
||||
val result = call.result
|
||||
|
||||
val fallbackNoun =
|
||||
when (toolName) {
|
||||
"browser_snapshot", "todo" -> "item"
|
||||
"list_files" -> "file"
|
||||
"search_files", "session_search_recall", "web_search" -> "result"
|
||||
"skills_list" -> "skill"
|
||||
else -> "item"
|
||||
}
|
||||
|
||||
if (toolName == "web_search") {
|
||||
val hits = ToolJson.collectResultItems(rawResult)
|
||||
|
||||
if (hits.isNotEmpty()) {
|
||||
return countMetric(hits.size, "result")
|
||||
}
|
||||
}
|
||||
|
||||
val direct = countFromRecord(result ?: JsonObject(emptyMap()), fallbackNoun)
|
||||
if (direct != null) {
|
||||
return if (toolName == "web_search") countMetric(direct.first, "result") else direct
|
||||
}
|
||||
|
||||
val payload = ToolJson.unwrapToolPayload(rawResult)
|
||||
if (payload !== rawResult && payload is JsonObject) {
|
||||
val payloadCount = countFromRecord(payload, fallbackNoun)
|
||||
|
||||
if (payloadCount != null) {
|
||||
return if (toolName == "web_search") countMetric(payloadCount.first, "result") else payloadCount
|
||||
}
|
||||
}
|
||||
|
||||
val summaryText =
|
||||
ToolJson
|
||||
.firstString(result, listOf("summary", "message", "detail"))
|
||||
.ifEmpty { ToolJson.fallbackDetailText(call.args, rawResult) }
|
||||
|
||||
return countFromText(summaryText, fallbackNoun)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Shared parsing/normalization primitives for the tool-display engine.
|
||||
*
|
||||
* Everything in here is payload-shape plumbing — no per-tool knowledge.
|
||||
* Per-tool formatting lives in the renderers under [render][com.m57.hermescontrol.ui.chat.tool.render].
|
||||
*/
|
||||
internal object ToolJson {
|
||||
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE)
|
||||
|
||||
const val MAX_TOOL_RENDER_CHARS = 20_000
|
||||
|
||||
fun clampForDisplay(
|
||||
value: String,
|
||||
max: Int = MAX_TOOL_RENDER_CHARS,
|
||||
): String {
|
||||
if (value.length <= max) return value
|
||||
val omitted = value.length - max
|
||||
return value.take(max) +
|
||||
"\n… $omitted more characters truncated — use Copy for the full output."
|
||||
}
|
||||
|
||||
/** First non-blank string value among [keys], trimmed. */
|
||||
fun firstString(
|
||||
record: JsonObject?,
|
||||
keys: List<String>,
|
||||
): String {
|
||||
val r = record ?: return ""
|
||||
|
||||
for (k in keys) {
|
||||
val v = r[k] ?: continue
|
||||
if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) {
|
||||
return v.content.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun numberValue(value: JsonElement?): Double? {
|
||||
val v = value ?: return null
|
||||
if (v is JsonNull) {
|
||||
return null
|
||||
}
|
||||
if (v !is JsonPrimitive) {
|
||||
return null
|
||||
}
|
||||
|
||||
// TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are
|
||||
// accepted, matching the desktop engine and the gateway's habit of
|
||||
// shipping exit_code as a string or float.
|
||||
return v.content.toDoubleOrNull()
|
||||
}
|
||||
|
||||
fun intValue(value: JsonElement?): Int? {
|
||||
val n = numberValue(value) ?: return null
|
||||
|
||||
return n.toInt()
|
||||
}
|
||||
|
||||
/** True only for a literal JSON `true` (not the string "true"). */
|
||||
fun boolTrue(value: JsonElement?): Boolean =
|
||||
(value as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
|
||||
|
||||
/** True only for a literal JSON `false` (not the string "false"). */
|
||||
fun boolFalse(value: JsonElement?): Boolean =
|
||||
(value as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true
|
||||
|
||||
fun compactPreview(
|
||||
value: String,
|
||||
max: Int = 72,
|
||||
): String {
|
||||
val line = value.replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
return if (line.length > max) "${line.take(max - 1)}…" else line
|
||||
}
|
||||
|
||||
fun compactPreview(
|
||||
value: JsonElement?,
|
||||
max: Int = 72,
|
||||
): String {
|
||||
val raw: String =
|
||||
if (value is JsonPrimitive && value.isString) {
|
||||
value.content
|
||||
} else {
|
||||
firstString(parseMaybeObject(value), listOf("context"))
|
||||
}
|
||||
|
||||
return compactPreview(raw, max)
|
||||
}
|
||||
|
||||
fun contextValue(value: JsonElement?): String {
|
||||
val row = parseMaybeObject(value)
|
||||
val context = firstString(row, listOf("context"))
|
||||
val preview = firstString(row, listOf("preview"))
|
||||
|
||||
if (context.isNotEmpty()) {
|
||||
return context
|
||||
}
|
||||
if (preview.isNotEmpty()) {
|
||||
return preview
|
||||
}
|
||||
|
||||
return (value as? JsonPrimitive)?.takeIf { it.isString }?.content ?: ""
|
||||
}
|
||||
|
||||
/** Objects pass through; stringified JSON objects are re-parsed. */
|
||||
fun parseMaybeObject(value: JsonElement?): JsonObject? =
|
||||
when (value) {
|
||||
is JsonObject -> {
|
||||
value
|
||||
}
|
||||
|
||||
is JsonPrimitive -> {
|
||||
if (!value.isString || value.content.trim().isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
Json.parseToJsonElement(value.content) as? JsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Peel one `data`/`result`/`output`/`response`/`payload` wrapper layer. */
|
||||
fun unwrapToolPayload(value: JsonElement?): JsonElement? {
|
||||
val record = parseMaybeObject(value) ?: return value
|
||||
|
||||
for (key in listOf("data", "result", "output", "response", "payload")) {
|
||||
val payload = record[key] ?: continue
|
||||
if (payload is JsonNull) {
|
||||
continue
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
fun findFirstUrl(vararg sources: JsonElement?): String {
|
||||
for (src in sources) {
|
||||
if (src is JsonPrimitive && src.isString) {
|
||||
val m = URL_PATTERN.find(src.content)
|
||||
|
||||
if (m != null) {
|
||||
return m.value
|
||||
}
|
||||
} else if (src is JsonObject) {
|
||||
for (v in src.values) {
|
||||
val found = findFirstUrl(v)
|
||||
|
||||
if (found.isNotEmpty()) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun hostnameOf(value: String): String =
|
||||
try {
|
||||
val url = java.net.URI(value)
|
||||
val path = url.path?.takeIf { it.isNotEmpty() && it != "/" } ?: ""
|
||||
"${url.host ?: value}$path"
|
||||
} catch (_: Exception) {
|
||||
value
|
||||
}
|
||||
|
||||
fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "")
|
||||
|
||||
fun stripDividerLines(value: String): String =
|
||||
value
|
||||
.split("\n")
|
||||
.filter { !Regex("^[-=]{3,}\\s*$").matches(it.trim()) }
|
||||
.joinToString("\n")
|
||||
.trim()
|
||||
|
||||
fun cleanVisibleText(text: String): String =
|
||||
text
|
||||
.replace(Regex("`{3,}"), "")
|
||||
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
|
||||
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
|
||||
|
||||
/** Find the result-item array behind common list keys or wrappers. */
|
||||
fun collectResultItems(value: JsonElement?): List<JsonElement> {
|
||||
if (value is JsonArray) {
|
||||
return value.toList()
|
||||
}
|
||||
|
||||
val record = parseMaybeObject(value) ?: return emptyList()
|
||||
|
||||
val keys =
|
||||
listOf(
|
||||
"web",
|
||||
"results",
|
||||
"search_results",
|
||||
"sources",
|
||||
"web_sources",
|
||||
"items",
|
||||
"organic_results",
|
||||
"organic",
|
||||
"matches",
|
||||
"documents",
|
||||
)
|
||||
|
||||
for (key in keys) {
|
||||
val candidate = record[key] ?: continue
|
||||
|
||||
if (candidate is JsonArray) {
|
||||
return candidate.toList()
|
||||
}
|
||||
|
||||
if (candidate is JsonObject) {
|
||||
val nested = collectResultItems(candidate)
|
||||
|
||||
if (nested.isNotEmpty()) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val payload = unwrapToolPayload(record)
|
||||
|
||||
return if (payload === record) emptyList() else collectResultItems(payload)
|
||||
}
|
||||
|
||||
/** Humanized generic title: "fact_store" → "Fact Store", sans browser_/web_ prefix. */
|
||||
fun titleForTool(name: String): String {
|
||||
val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "")
|
||||
|
||||
return normalized
|
||||
.split("_")
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(
|
||||
" ",
|
||||
) { part -> part.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } }
|
||||
.ifEmpty { name }
|
||||
}
|
||||
|
||||
/** Generic detail: prefer a fresh result context, then args, then the heuristic summary. */
|
||||
fun fallbackDetailText(
|
||||
args: JsonElement?,
|
||||
result: JsonElement?,
|
||||
): String {
|
||||
val argContext = contextValue(args)
|
||||
val resultContext = contextValue(result)
|
||||
|
||||
if (resultContext.isNotEmpty() && resultContext != argContext) {
|
||||
return resultContext
|
||||
}
|
||||
|
||||
if (argContext.isNotEmpty()) {
|
||||
return argContext
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
return ToolResultSummary.formatToolResultSummary(result)
|
||||
}
|
||||
|
||||
return ToolResultSummary.formatToolResultSummary(args)
|
||||
}
|
||||
|
||||
fun formatDurationSeconds(seconds: Double): String {
|
||||
if (!seconds.isFinite() || seconds < 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (seconds < 1) {
|
||||
val ms = maxOf(1, Math.round(seconds * 1000))
|
||||
return "${ms}ms"
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return if (seconds >= 10) "${seconds.toInt()}s" else "${"%.1f".format(seconds)}s"
|
||||
}
|
||||
|
||||
val wholeSeconds = Math.round(seconds)
|
||||
val minutes = wholeSeconds / 60
|
||||
val remSeconds = wholeSeconds % 60
|
||||
|
||||
if (minutes < 60) {
|
||||
return if (remSeconds != 0L) "${minutes}m ${remSeconds}s" else "${minutes}m"
|
||||
}
|
||||
|
||||
val hours = minutes / 60
|
||||
val remMinutes = minutes % 60
|
||||
|
||||
return if (remMinutes != 0L) "${hours}h ${remMinutes}m" else "${hours}h"
|
||||
}
|
||||
|
||||
fun durationLabel(result: JsonObject?): String? {
|
||||
val seconds =
|
||||
numberValue(result?.get("duration_s"))
|
||||
?: numberValue(result?.get("duration_seconds"))
|
||||
?: numberValue(result?.get("total_duration_seconds"))
|
||||
?: return null
|
||||
|
||||
return formatDurationSeconds(seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
/**
|
||||
* Per-tool (or per-family) display strategy.
|
||||
*
|
||||
* Every hook is a *refinement*: return null to fall back to the generic
|
||||
* treatment ([ToolViewBuilder] humanizes the tool name for titles and uses
|
||||
* [ToolResultSummary]-based text for subtitle/detail). Returning an empty
|
||||
* string is a real answer — "this tool intentionally shows nothing here" —
|
||||
* and suppresses the generic fallback.
|
||||
*/
|
||||
internal interface ToolRenderer {
|
||||
/** Title while the call is still running ("Running ls", "Searching …"). */
|
||||
fun pendingTitle(call: ToolCall): String? = null
|
||||
|
||||
/** Title after successful completion ("Ran ls", "Opened example.com"). */
|
||||
fun doneTitle(call: ToolCall): String? = null
|
||||
|
||||
/** Title when the call errored. */
|
||||
fun errorTitle(call: ToolCall): String? = null
|
||||
|
||||
/** Secondary line naming what was acted on. Not used when an error is shown. */
|
||||
fun subtitle(call: ToolCall): String? = null
|
||||
|
||||
/** Expanded body text. */
|
||||
fun detail(call: ToolCall): String? = null
|
||||
|
||||
/** Structured extras (streams, diffs, search hits) for special row layouts. */
|
||||
fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras = ToolViewExtras.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured, tool-specific fields a renderer can contribute to [ToolView]
|
||||
* beyond the title/subtitle/detail text.
|
||||
*/
|
||||
internal data class ToolViewExtras(
|
||||
val detailLabel: String? = null,
|
||||
val stdout: String? = null,
|
||||
val stderr: String? = null,
|
||||
val exitCode: Int? = null,
|
||||
val terminalCommand: String? = null,
|
||||
val inlineDiff: String? = null,
|
||||
val diffPath: String? = null,
|
||||
val diffStats: DiffStats? = null,
|
||||
val searchHits: List<SearchHit>? = null,
|
||||
val searchQuery: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
val NONE = ToolViewExtras()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer
|
||||
|
||||
/**
|
||||
* Maps tool names to their display strategy.
|
||||
*
|
||||
* Unknown tools get [GenericToolRenderer]: every hook returns null, so
|
||||
* [ToolViewBuilder] falls back to the humanized tool name and the
|
||||
* [ToolResultSummary] heuristics — never a raw JSON dump.
|
||||
*/
|
||||
internal object ToolRendererRegistry {
|
||||
/** All-default renderer: defers everything to the generic fallbacks. */
|
||||
private object GenericToolRenderer : ToolRenderer
|
||||
|
||||
private val renderers: Map<String, ToolRenderer> =
|
||||
mapOf(
|
||||
// Shell
|
||||
"terminal" to TerminalRenderer,
|
||||
"execute_code" to TerminalRenderer,
|
||||
"read_terminal" to ReadTerminalRenderer,
|
||||
// Delegation
|
||||
"delegate_task" to DelegateTaskRenderer,
|
||||
// Files
|
||||
"read_file" to ReadFileRenderer,
|
||||
"edit_file" to FileEditRenderer,
|
||||
"patch" to FileEditRenderer,
|
||||
"write_file" to FileEditRenderer,
|
||||
"search_files" to SearchFilesRenderer,
|
||||
// Web
|
||||
"web_search" to WebSearchRenderer,
|
||||
"web_extract" to WebExtractRenderer,
|
||||
"x_search" to XSearchRenderer,
|
||||
// Browser
|
||||
"browser_navigate" to BrowserNavigateRenderer,
|
||||
"browser_snapshot" to BrowserSnapshotRenderer,
|
||||
"browser_click" to BrowserClickRenderer,
|
||||
"browser_fill" to BrowserTypeRenderer,
|
||||
"browser_type" to BrowserTypeRenderer,
|
||||
"browser_scroll" to BrowserActionRenderer,
|
||||
"browser_back" to BrowserActionRenderer,
|
||||
"browser_press" to BrowserActionRenderer,
|
||||
"browser_get_images" to BrowserImagesRenderer,
|
||||
"browser_vision" to BrowserVisionRenderer,
|
||||
"browser_console" to BrowserConsoleRenderer,
|
||||
// Memory / facts
|
||||
"memory" to MemoryRenderer,
|
||||
"fact_store" to FactStoreRenderer,
|
||||
// Task management
|
||||
"cronjob" to CronjobRenderer,
|
||||
"cronjob_manage" to CronjobRenderer,
|
||||
"todo" to TodoRenderer,
|
||||
"todo_list" to TodoRenderer,
|
||||
"session_search" to SessionSearchRenderer,
|
||||
"process" to ProcessRenderer,
|
||||
"process_manage" to ProcessRenderer,
|
||||
// Skills / tool discovery
|
||||
"skills_list" to SkillsListRenderer,
|
||||
"skill_view" to SkillViewRenderer,
|
||||
"skill_manage" to SkillManageRenderer,
|
||||
"tool_search" to ToolSearchRenderer,
|
||||
// Media
|
||||
"image_generate" to ImageGenerateRenderer,
|
||||
"vision_analyze" to VisionAnalyzeRenderer,
|
||||
"text_to_speech" to TextToSpeechRenderer,
|
||||
// Projects
|
||||
"project_list" to ProjectListRenderer,
|
||||
"project_create" to ProjectMutateRenderer,
|
||||
"project_switch" to ProjectMutateRenderer,
|
||||
// Desktop automation
|
||||
"computer_use" to ComputerUseRenderer,
|
||||
)
|
||||
|
||||
fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Status + error-text heuristics for a completed tool call, ported from the
|
||||
* desktop engine. Key rules, in order:
|
||||
*
|
||||
* - running (or a missing result) is RUNNING
|
||||
* - an explicit `success: true` / `ok: true` beats every error heuristic,
|
||||
* including a stale isError envelope
|
||||
* - a non-zero exit code alone is NOT an error: grep returns 1 on no-match,
|
||||
* diff returns 1 on differences, piped commands surface the last stage's
|
||||
* code — all routinely produce useful output. It only becomes an error
|
||||
* when the command produced no real output to show.
|
||||
* - a rejected memory write is a budget negotiation → WARNING, not ERROR
|
||||
*/
|
||||
internal object ToolStatusResolver {
|
||||
fun errorText(
|
||||
call: ToolCall,
|
||||
isError: Boolean,
|
||||
): String {
|
||||
val result = call.rawResult
|
||||
val resultRecord = call.result
|
||||
|
||||
if (isError) {
|
||||
val extracted = ToolResultSummary.extractToolErrorMessage(result)
|
||||
if (extracted.isNotEmpty()) {
|
||||
return extracted
|
||||
}
|
||||
if (result is JsonPrimitive && result.isString && result.content.trim().isNotEmpty()) {
|
||||
return result.content.trim()
|
||||
}
|
||||
|
||||
return "Tool returned an error."
|
||||
}
|
||||
|
||||
val error = ToolJson.firstString(resultRecord, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return error
|
||||
}
|
||||
|
||||
val extracted = ToolResultSummary.extractToolErrorMessage(result)
|
||||
if (extracted.isNotEmpty()) {
|
||||
return extracted
|
||||
}
|
||||
|
||||
if (resultRecord != null) {
|
||||
if (ToolJson.boolFalse(resultRecord["success"]) || ToolJson.boolFalse(resultRecord["ok"])) {
|
||||
return ToolJson
|
||||
.firstString(
|
||||
resultRecord,
|
||||
listOf("message", "reason", "detail"),
|
||||
).ifEmpty { "Tool returned success=false." }
|
||||
}
|
||||
|
||||
val status = ToolJson.firstString(resultRecord, listOf("status"))
|
||||
if (Regex("\\b(error|failed|failure)\\b", RegexOption.IGNORE_CASE).containsMatchIn(status)) {
|
||||
return ToolJson
|
||||
.firstString(
|
||||
resultRecord,
|
||||
listOf("message", "reason", "detail"),
|
||||
).ifEmpty { "Tool returned status \"$status\"." }
|
||||
}
|
||||
|
||||
val exit = ToolJson.numberValue(resultRecord["exit_code"])
|
||||
if (exit != null && exit != 0.0) {
|
||||
val hasOutput =
|
||||
listOf("output", "stdout", "stderr", "output_preview")
|
||||
.any { k -> ToolJson.firstString(resultRecord, listOf(k)).isNotEmpty() }
|
||||
|
||||
if (!hasOutput) {
|
||||
return "Command failed with exit code ${exit.toInt()}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun status(
|
||||
call: ToolCall,
|
||||
isError: Boolean,
|
||||
running: Boolean,
|
||||
): ToolViewStatus {
|
||||
if (running || call.rawResult == null) {
|
||||
return ToolViewStatus.RUNNING
|
||||
}
|
||||
|
||||
// Explicit success wins over isError / nested-error heuristics.
|
||||
val resultRecord = call.result
|
||||
if (resultRecord != null &&
|
||||
(ToolJson.boolTrue(resultRecord["success"]) || ToolJson.boolTrue(resultRecord["ok"]))
|
||||
) {
|
||||
return ToolViewStatus.SUCCESS
|
||||
}
|
||||
|
||||
if (errorText(call, isError).isEmpty()) {
|
||||
return ToolViewStatus.SUCCESS
|
||||
}
|
||||
|
||||
// A rejected memory write is a budget negotiation, not a failure.
|
||||
return if (call.name == "memory") ToolViewStatus.WARNING else ToolViewStatus.ERROR
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,202 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
||||
/** `browser_navigate`: hostname titles, including the failed-to-open form. */
|
||||
internal object BrowserNavigateRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Opening ${targetHostname(call.args, null)}"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Opened ${targetHostname(call.args, call.result)}"
|
||||
|
||||
override fun errorTitle(call: ToolCall): String? {
|
||||
val url =
|
||||
ToolJson
|
||||
.findFirstUrl(call.result)
|
||||
.ifEmpty { call.result?.get("url")?.toString() ?: "" }
|
||||
|
||||
return if (url.isNotEmpty()) "Failed to open ${ToolJson.hostnameOf(url)}" else null
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val url =
|
||||
ToolJson
|
||||
.firstString(call.args, listOf("url", "target"))
|
||||
.ifEmpty { ToolJson.firstString(call.result, listOf("url")) }
|
||||
.ifEmpty { ToolJson.findFirstUrl(call.args, call.result) }
|
||||
|
||||
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Navigated in browser"
|
||||
}
|
||||
}
|
||||
|
||||
/** `browser_snapshot`: control-count summary of the accessibility snapshot. */
|
||||
internal object BrowserSnapshotRenderer : ToolRenderer {
|
||||
private fun summarizeSnapshot(snapshot: String): String {
|
||||
fun count(re: Regex): Int = re.findAll(snapshot).count()
|
||||
|
||||
val stats =
|
||||
listOf(
|
||||
"${count(Regex("""button\s+"[^"]+"""))} buttons",
|
||||
"${count(Regex("""link\s+"[^"]+"""))} links",
|
||||
"${count(Regex("""(?:textbox|combobox|searchbox)\s+"[^"]+"""))} inputs",
|
||||
).joinToString(" · ")
|
||||
|
||||
val labels =
|
||||
Regex("""(?:button|link|combobox|textbox)\s+"([^"]+)"""")
|
||||
.findAll(snapshot)
|
||||
.map { it.groupValues[1].trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(4)
|
||||
.toList()
|
||||
|
||||
return if (labels.isNotEmpty()) "$stats\nTop controls: ${labels.joinToString(", ")}" else stats
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val snapshot = ToolJson.firstString(call.result, listOf("snapshot"))
|
||||
|
||||
return if (snapshot.isNotEmpty()) {
|
||||
summarizeSnapshot(snapshot)
|
||||
} else {
|
||||
"Captured a browser accessibility snapshot"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val snapshot = ToolJson.firstString(call.result, listOf("snapshot"))
|
||||
|
||||
return if (snapshot.isNotEmpty()) {
|
||||
summarizeSnapshot(snapshot)
|
||||
} else {
|
||||
ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `browser_click`: what got clicked, with internal refs called out. */
|
||||
internal object BrowserClickRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val clicked =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("clicked"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("ref", "target")) }
|
||||
|
||||
return when {
|
||||
clicked.isEmpty() -> "Clicked on page"
|
||||
clicked.startsWith("@") -> "Clicked page element (internal ref $clicked)"
|
||||
else -> "Clicked $clicked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `browser_fill` / `browser_type`: which field got which value. */
|
||||
internal object BrowserTypeRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val field = ToolJson.firstString(call.args, listOf("label", "field", "ref", "target"))
|
||||
val value = ToolJson.firstString(call.args, listOf("value", "text"))
|
||||
|
||||
return listOf(
|
||||
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
|
||||
value.takeIf { it.isNotEmpty() }?.let { "Value: ${ToolJson.compactPreview(it, 42)}" },
|
||||
).filterNotNull()
|
||||
.joinToString(" · ")
|
||||
.ifEmpty { "Filled page input" }
|
||||
}
|
||||
}
|
||||
|
||||
/** browser_scroll / browser_back / browser_press: one-line acks. */
|
||||
internal object BrowserActionRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String? =
|
||||
when (call.name) {
|
||||
"browser_scroll" -> {
|
||||
"Scrolled ${ToolJson.firstString(call.result, listOf("scrolled"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("direction")) }.ifEmpty { "page" }}"
|
||||
}
|
||||
|
||||
"browser_back" -> {
|
||||
val url = ToolJson.firstString(call.result, listOf("url"))
|
||||
if (url.isNotEmpty()) "Went back to ${ToolJson.hostnameOf(url)}" else "Went back"
|
||||
}
|
||||
|
||||
"browser_press" -> {
|
||||
"Pressed ${ToolJson.firstString(call.result, listOf("pressed"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("key")) }}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String = ""
|
||||
|
||||
override fun detail(call: ToolCall): String = ""
|
||||
}
|
||||
|
||||
/** browser_get_images: image inventory of the current page. */
|
||||
internal object BrowserImagesRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String {
|
||||
val n = ToolJson.intValue(call.result?.get("count")) ?: 0
|
||||
return "Found $n image${if (n == 1) "" else "s"}"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
(call.result?.get("images") as? JsonArray)
|
||||
?.take(12)
|
||||
?.mapNotNull { el ->
|
||||
val img = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val src = ToolJson.firstString(img, listOf("src"))
|
||||
val alt = ToolJson.firstString(img, listOf("alt"))
|
||||
listOf(alt, src)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" — ")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { "- $it" }
|
||||
}?.joinToString("\n") ?: ""
|
||||
}
|
||||
|
||||
/** browser_vision: screenshot Q&A — the analysis text is the payload. */
|
||||
internal object BrowserVisionRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Looking at page"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Analyzed page"
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("question")), 120)
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("analysis"))
|
||||
.ifEmpty { ToolJson.firstString(call.result, listOf("note")) }
|
||||
}
|
||||
|
||||
/** browser_console: message/error counts, or a JS eval result. */
|
||||
internal object BrowserConsoleRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String? {
|
||||
val result = call.result ?: return null
|
||||
if (result.containsKey("result")) return "Evaluated JS"
|
||||
val msgs = ToolJson.intValue(result["total_messages"]) ?: 0
|
||||
val errs = ToolJson.intValue(result["total_errors"]) ?: 0
|
||||
return "Console: $msgs message${if (msgs == 1) "" else "s"}" +
|
||||
if (errs > 0) ", $errs error${if (errs == 1) "" else "s"}" else ""
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result ?: return ""
|
||||
result["result"]?.let { return it.toString() }
|
||||
val lines =
|
||||
(result["console_messages"] as? JsonArray)?.take(30)?.mapNotNull { el ->
|
||||
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
"[${ToolJson.firstString(m, listOf("type")).ifEmpty { "log" }}] " +
|
||||
ToolJson.compactPreview(ToolJson.firstString(m, listOf("text")), 200)
|
||||
} ?: emptyList()
|
||||
val errors =
|
||||
(result["js_errors"] as? JsonArray)?.take(10)?.mapNotNull { el ->
|
||||
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
"[error] ${ToolJson.compactPreview(ToolJson.firstString(e, listOf("message")), 200)}"
|
||||
} ?: emptyList()
|
||||
return (errors + lines).joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
||||
/**
|
||||
* `computer_use`: desktop-automation results — app rosters, interactable
|
||||
* element inventories, multimodal text blocks, and plain action acks.
|
||||
*/
|
||||
internal object ComputerUseRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val args = call.args
|
||||
val result = call.result
|
||||
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val apps = result?.get("apps") as? JsonArray
|
||||
if (apps != null) {
|
||||
return "${apps.size} apps"
|
||||
}
|
||||
|
||||
val elements = result?.get("elements") as? JsonArray
|
||||
if (elements != null) {
|
||||
val mode = ToolJson.firstString(result, listOf("mode"))
|
||||
val width = ToolJson.intValue(result["width"])
|
||||
val height = ToolJson.intValue(result["height"])
|
||||
val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""
|
||||
val sizePart = if (width != null && height != null) " ${width}x$height" else ""
|
||||
return "${elements.size} elements$modePart$sizePart"
|
||||
}
|
||||
|
||||
val action = ToolJson.firstString(args, listOf("action"))
|
||||
if (action.isNotEmpty()) {
|
||||
val ok = ToolJson.boolTrue(result?.get("ok"))
|
||||
val fail = ToolJson.boolFalse(result?.get("ok"))
|
||||
return "$action${when {
|
||||
ok -> " ✓"
|
||||
fail -> " ✗"
|
||||
else -> ""
|
||||
}}"
|
||||
}
|
||||
|
||||
return "Computer use"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val args = call.args
|
||||
val result = call.result
|
||||
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val isMultimodal = ToolJson.boolTrue(result?.get("_multimodal"))
|
||||
val content = result?.get("content") as? JsonArray
|
||||
val multimodalText =
|
||||
if (isMultimodal && content != null) {
|
||||
content
|
||||
.mapNotNull { el ->
|
||||
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
if (ToolJson.firstString(e, listOf("type")) == "text") {
|
||||
ToolJson.firstString(e, listOf("text"))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.joinToString("\n")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val textSummary = ToolJson.firstString(result, listOf("summary"))
|
||||
val visionAnalysis = ToolJson.firstString(result, listOf("vision_analysis"))
|
||||
val apps = result?.get("apps") as? JsonArray
|
||||
val elements = result?.get("elements") as? JsonArray
|
||||
val mode = ToolJson.firstString(result, listOf("mode"))
|
||||
val width = ToolJson.intValue(result?.get("width"))
|
||||
val height = ToolJson.intValue(result?.get("height"))
|
||||
val action = ToolJson.firstString(args, listOf("action"))
|
||||
val ok = ToolJson.boolTrue(result?.get("ok"))
|
||||
val fail = ToolJson.boolFalse(result?.get("ok"))
|
||||
val message = ToolJson.firstString(result, listOf("message"))
|
||||
|
||||
val body =
|
||||
buildString {
|
||||
val textPart =
|
||||
multimodalText
|
||||
.ifEmpty {
|
||||
ToolJson.firstString(
|
||||
result,
|
||||
listOf("text_summary"),
|
||||
)
|
||||
}.ifEmpty { textSummary }
|
||||
if (textPart.isNotEmpty()) {
|
||||
append(textPart)
|
||||
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
|
||||
append("\n\n$textSummary")
|
||||
}
|
||||
}
|
||||
|
||||
if (apps != null) {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
apps.forEachIndexed { idx, el ->
|
||||
val obj = ToolJson.parseMaybeObject(el)
|
||||
val name = ToolJson.firstString(obj, listOf("name")).ifEmpty { "?" }
|
||||
val pid = ToolJson.intValue(obj?.get("pid"))
|
||||
append("${idx + 1}. $name")
|
||||
if (pid != null) append(" (PID: $pid)")
|
||||
append("\n")
|
||||
}
|
||||
} else if (elements != null) {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append("🖥️ ${elements.size} interactable elements")
|
||||
if (mode.isNotEmpty()) append(" (mode: $mode)")
|
||||
if (width != null && height != null) append(" • ${width}x$height")
|
||||
if (textSummary.isNotEmpty()) append("\n\n$textSummary")
|
||||
val previewCount = minOf(elements.size, 5)
|
||||
if (previewCount > 0) {
|
||||
append("\n\n")
|
||||
for (i in 0 until previewCount) {
|
||||
val obj = ToolJson.parseMaybeObject(elements[i])
|
||||
val idx = ToolJson.intValue(obj?.get("index"))
|
||||
val role = ToolJson.firstString(obj, listOf("role"))
|
||||
val label = ToolJson.firstString(obj, listOf("label"))
|
||||
append(" #$idx ${role}${label.takeIf { it.isNotEmpty() }?.let { " '$it'" } ?: ""}\n")
|
||||
}
|
||||
if (elements.size > previewCount) {
|
||||
append(" ... and ${elements.size - previewCount} more")
|
||||
}
|
||||
}
|
||||
} else if (action.isNotEmpty() && multimodalText.isEmpty() &&
|
||||
ToolJson
|
||||
.firstString(
|
||||
result,
|
||||
listOf("text_summary"),
|
||||
).isEmpty() && textSummary.isEmpty()
|
||||
) {
|
||||
append("$action:")
|
||||
when {
|
||||
ok -> append(" ✅ ok")
|
||||
fail -> append(" ❌ failed")
|
||||
}
|
||||
if (message.isNotEmpty()) append("\n$message")
|
||||
} else if (isEmpty()) {
|
||||
append("Computer use action")
|
||||
}
|
||||
}
|
||||
|
||||
return if (visionAnalysis.isNotEmpty()) "$body\n\n━━━ Vision Analysis ━━━\n$visionAnalysis" else body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* `delegate_task`: fan-out of subagent tasks.
|
||||
* Covers joined results, background dispatch handles, and list/steer/stop acks.
|
||||
*/
|
||||
internal object DelegateTaskRenderer : ToolRenderer {
|
||||
private fun goals(call: ToolCall): List<String> {
|
||||
val tasks = call.args?.get("tasks") as? JsonArray
|
||||
val fromTasks =
|
||||
tasks?.mapIndexedNotNull { i, el ->
|
||||
ToolJson
|
||||
.parseMaybeObject(el)
|
||||
?.let { ToolJson.firstString(it, listOf("goal")) }
|
||||
?.ifEmpty { "Task ${i + 1}" }
|
||||
} ?: emptyList()
|
||||
if (fromTasks.isNotEmpty()) return fromTasks
|
||||
return ToolJson
|
||||
.firstString(call.args, listOf("goal"))
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
private fun action(call: ToolCall) = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "spawn" }
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String =
|
||||
when (action(call)) {
|
||||
"list" -> {
|
||||
"Listing subagents"
|
||||
}
|
||||
|
||||
"steer" -> {
|
||||
"Steering subagent"
|
||||
}
|
||||
|
||||
"stop" -> {
|
||||
"Stopping subagent"
|
||||
}
|
||||
|
||||
else -> {
|
||||
val n = goals(call).size.coerceAtLeast(1)
|
||||
"Delegating $n task${if (n == 1) "" else "s"}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun doneTitle(call: ToolCall): String {
|
||||
val result = call.result
|
||||
return when {
|
||||
action(call) == "list" -> {
|
||||
val n = ToolJson.intValue(result?.get("count")) ?: 0
|
||||
"$n subagent${if (n == 1) "" else "s"}"
|
||||
}
|
||||
|
||||
action(call) == "steer" -> {
|
||||
"Steer queued"
|
||||
}
|
||||
|
||||
action(call) == "stop" -> {
|
||||
"Stop requested"
|
||||
}
|
||||
|
||||
ToolJson.firstString(result, listOf("status")) == "dispatched" -> {
|
||||
val n = ToolJson.intValue(result?.get("count")) ?: goals(call).size
|
||||
"Dispatched $n background task${if (n == 1) "" else "s"}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
val results = result?.get("results") as? JsonArray
|
||||
val n = results?.size ?: goals(call).size.coerceAtLeast(1)
|
||||
val failed =
|
||||
results?.count {
|
||||
ToolJson.firstString(ToolJson.parseMaybeObject(it), listOf("status")) == "failed"
|
||||
} ?: 0
|
||||
if (failed > 0) {
|
||||
"Delegated $n tasks · $failed failed"
|
||||
} else {
|
||||
"Delegated $n task${if (n == 1) "" else "s"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.compactPreview(goals(call).firstOrNull() ?: "", 120)
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
// list action: one row per live subagent
|
||||
(result?.get("subagents") as? JsonArray)?.let { subs ->
|
||||
return subs
|
||||
.mapNotNull { el ->
|
||||
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val goal = ToolJson.compactPreview(ToolJson.firstString(s, listOf("goal")), 80)
|
||||
val status = ToolJson.firstString(s, listOf("status"))
|
||||
val secs = ToolJson.numberValue(s["running_seconds"])
|
||||
val time = secs?.let { " · ${ToolJson.formatDurationSeconds(it)}" } ?: ""
|
||||
"- [$status] $goal$time"
|
||||
}.joinToString("\n")
|
||||
}
|
||||
// joined spawn: per-task result rows
|
||||
val rows =
|
||||
(result?.get("results") as? JsonArray)?.mapNotNull { el ->
|
||||
val r = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val idx = ToolJson.intValue(r["task_index"]) ?: 0
|
||||
val ok = ToolJson.firstString(r, listOf("status")) != "failed"
|
||||
val marker = if (ok) "[x]" else "[!]"
|
||||
val goal = goals(call).getOrNull(idx) ?: "Task ${idx + 1}"
|
||||
val model = ToolJson.firstString(r, listOf("model"))
|
||||
val dur =
|
||||
ToolJson
|
||||
.numberValue(r["duration_seconds"])
|
||||
?.let { ToolJson.formatDurationSeconds(it) } ?: ""
|
||||
val meta = listOf(model, dur).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
val summary =
|
||||
ToolJson.compactPreview(
|
||||
ToolJson.firstString(r, listOf("summary", "error")),
|
||||
160,
|
||||
)
|
||||
buildString {
|
||||
append("$marker ${ToolJson.compactPreview(goal, 80)}")
|
||||
if (meta.isNotEmpty()) append(" ($meta)")
|
||||
if (summary.isNotEmpty()) append("\n $summary")
|
||||
}
|
||||
} ?: emptyList()
|
||||
if (rows.isNotEmpty()) return rows.joinToString("\n")
|
||||
// dispatched handle / steer / stop acks
|
||||
val note = ToolJson.firstString(result, listOf("note"))
|
||||
val goalsArr =
|
||||
(result?.get("goals") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||
?.joinToString("\n") { "- ${ToolJson.compactPreview(it, 100)}" } ?: ""
|
||||
return listOf(goalsArr, note).filter { it.isNotEmpty() }.joinToString("\n\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.DiffStats
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** Path/diff plumbing shared by the file-oriented renderers. */
|
||||
internal object FileEditSupport {
|
||||
private val HTML_PATH_REGEX =
|
||||
Regex(
|
||||
"(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun basename(path: String): String {
|
||||
val normalized = path.replace('\\', '/').trim()
|
||||
|
||||
return normalized.split("/").filter { it.isNotEmpty() }.lastOrNull() ?: normalized
|
||||
}
|
||||
|
||||
fun editPath(
|
||||
args: JsonObject?,
|
||||
result: JsonObject?,
|
||||
): String {
|
||||
val fromArgs = ToolJson.firstString(args, listOf("path", "file", "filepath"))
|
||||
if (fromArgs.isNotEmpty()) {
|
||||
return fromArgs
|
||||
}
|
||||
|
||||
val fromResult = ToolJson.firstString(result, listOf("path", "file", "filepath", "resolved_path"))
|
||||
if (fromResult.isNotEmpty()) {
|
||||
return fromResult
|
||||
}
|
||||
|
||||
return htmlPathFromInlineDiff(ToolJson.firstString(result, listOf("inline_diff", "diff")))
|
||||
}
|
||||
|
||||
private fun stripInlineDiffChrome(value: String): String =
|
||||
ToolJson
|
||||
.stripAnsi(value)
|
||||
.replace(Regex("^\\s*┊\\s*review diff\\s*\\n", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
|
||||
fun inlineDiffFromResult(result: JsonElement?): String {
|
||||
val record = ToolJson.parseMaybeObject(result) ?: return ""
|
||||
|
||||
for (key in listOf("inline_diff", "diff")) {
|
||||
val value = record[key]
|
||||
|
||||
if (value is JsonPrimitive && value.isString && value.content.trim().isNotEmpty()) {
|
||||
return stripInlineDiffChrome(value.content)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun htmlPathFromInlineDiff(value: String): String {
|
||||
val cleaned = stripInlineDiffChrome(value)
|
||||
|
||||
for (match in HTML_PATH_REGEX.findAll(cleaned)) {
|
||||
val candidate = match.groupValues[1].trim()
|
||||
|
||||
if (candidate.isNotEmpty()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
fun countDiffLineStats(diff: String): DiffStats {
|
||||
var added = 0
|
||||
var removed = 0
|
||||
|
||||
for (line in diff.split("\n")) {
|
||||
when {
|
||||
line.startsWith("+") && !line.startsWith("+++") -> added += 1
|
||||
line.startsWith("-") && !line.startsWith("---") -> removed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return DiffStats(added = added, removed = removed)
|
||||
}
|
||||
|
||||
/** Subtitle shared by read_file and the edit family: the path, or a summary. */
|
||||
fun pathSubtitle(
|
||||
call: ToolCall,
|
||||
path: String,
|
||||
): String = path.ifEmpty { ToolJson.fallbackDetailText(call.args, call.rawResult) }
|
||||
}
|
||||
|
||||
/** `read_file`: "Read name.kt L10-14" title with the file content as detail. */
|
||||
internal object ReadFileRenderer : ToolRenderer {
|
||||
private fun displayTarget(
|
||||
args: JsonObject?,
|
||||
result: JsonObject?,
|
||||
): String {
|
||||
val inherited = ToolJson.firstString(args, listOf("context", "preview"))
|
||||
if (inherited.isNotEmpty()) {
|
||||
return inherited
|
||||
}
|
||||
|
||||
val path = ToolJson.firstString(args, listOf("path", "file", "filepath"))
|
||||
if (path.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val offset = ToolJson.intValue(args?.get("offset"))
|
||||
val limit = ToolJson.intValue(args?.get("limit"))
|
||||
var lineLabel = ""
|
||||
|
||||
if (offset != null) {
|
||||
lineLabel = if (limit == null || limit <= 1) "L$offset" else "L$offset-${offset + limit - 1}"
|
||||
} else if (limit != null) {
|
||||
val content = ToolJson.firstString(result, listOf("content"))
|
||||
val lines =
|
||||
content
|
||||
.split("\n")
|
||||
.mapNotNull { line ->
|
||||
Regex("^(\\d+)\\|")
|
||||
.find(line)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toIntOrNull()
|
||||
}
|
||||
|
||||
if (lines.isNotEmpty()) {
|
||||
lineLabel =
|
||||
if (lines.first() == lines.last()) {
|
||||
"L${lines.first()}"
|
||||
} else {
|
||||
"L${lines.first()}-${lines.last()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return listOf(FileEditSupport.basename(path), lineLabel).filter { it.isNotEmpty() }.joinToString(" ")
|
||||
}
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String = "Reading ${displayTarget(call.args, null)}"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Read ${displayTarget(call.args, call.result)}"
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
FileEditSupport.pathSubtitle(call, ToolJson.firstString(call.args, listOf("path", "file", "filepath")))
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
if (call.rawResult == null) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ToolJson.firstString(call.result, listOf("content", "text", "data", "body"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `edit_file` / `patch` / `write_file`: basename title, path subtitle, and
|
||||
* the inline diff (with +/− stats) as the primary payload. Text detail only
|
||||
* appears when there is no diff to render.
|
||||
*/
|
||||
internal object FileEditRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String? {
|
||||
val path = FileEditSupport.editPath(call.args, call.result)
|
||||
|
||||
return if (path.isNotEmpty()) FileEditSupport.basename(path) else null
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
FileEditSupport.pathSubtitle(call, FileEditSupport.editPath(call.args, call.result))
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
if (FileEditSupport.inlineDiffFromResult(call.rawResult).isNotEmpty()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ToolJson.firstString(call.result, listOf("message", "summary")).ifEmpty {
|
||||
if (FileEditSupport.editPath(call.args, call.result).isNotEmpty()) {
|
||||
""
|
||||
} else {
|
||||
ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras {
|
||||
val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult)
|
||||
|
||||
if (inlineDiff.isEmpty()) {
|
||||
return ToolViewExtras.NONE
|
||||
}
|
||||
|
||||
return ToolViewExtras(
|
||||
inlineDiff = inlineDiff,
|
||||
diffPath = FileEditSupport.editPath(call.args, call.result),
|
||||
diffStats = FileEditSupport.countDiffLineStats(inlineDiff),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
|
||||
/** `image_generate`: generated-image URL with the prompt in the detail. */
|
||||
internal object ImageGenerateRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val imageUrl =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("image"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("image_url")) }
|
||||
val modality = ToolJson.firstString(call.result, listOf("modality"))
|
||||
|
||||
return listOf(imageUrl.take(60), modality.takeIf { it.isNotEmpty() }?.let { "($it)" })
|
||||
.filterNotNull()
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
val imageUrl = ToolJson.firstString(call.result, listOf("image"))
|
||||
val prompt = ToolJson.firstString(call.args, listOf("prompt"))
|
||||
|
||||
return buildString {
|
||||
if (error.isNotEmpty()) {
|
||||
append("❌ $error")
|
||||
if (imageUrl.isNotEmpty()) append("\n🔗 $imageUrl")
|
||||
} else if (imageUrl.isNotEmpty()) {
|
||||
append("🖼️ ")
|
||||
if (prompt.isNotEmpty()) append("$prompt\n")
|
||||
append("\n🔗 $imageUrl")
|
||||
} else {
|
||||
append("✅ Generated")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `vision_analyze`: the analyzed image URL and the model's description. */
|
||||
internal object VisionAnalyzeRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("image_url")).take(60)
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
val description = ToolJson.firstString(call.result, listOf("description"))
|
||||
val content = ToolJson.firstString(call.result, listOf("content"))
|
||||
|
||||
return when {
|
||||
error.isNotEmpty() -> "❌ $error"
|
||||
description.isNotEmpty() -> description
|
||||
content.isNotEmpty() -> content
|
||||
else -> "No description available"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `text_to_speech`: audio synthesis. Success payload carries file_path(s), provider, chunk_count. */
|
||||
internal object TextToSpeechRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Generating speech"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Generated speech"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val provider = ToolJson.firstString(call.result, listOf("provider"))
|
||||
val chunks = ToolJson.intValue(call.result?.get("chunk_count"))
|
||||
val parts =
|
||||
listOfNotNull(
|
||||
provider.takeIf { it.isNotEmpty() },
|
||||
chunks?.takeIf { it > 1 }?.let { "$it chunks" },
|
||||
)
|
||||
return parts
|
||||
.joinToString(" · ")
|
||||
.ifEmpty { ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 120) }
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val text = ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 400)
|
||||
val path = ToolJson.firstString(call.result, listOf("file_path", "output_path"))
|
||||
return listOfNotNull(
|
||||
text.takeIf { it.isNotEmpty() }?.let { "“$it”" },
|
||||
path.takeIf { it.isNotEmpty() }?.let { "Saved to $it" },
|
||||
).joinToString("\n\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
||||
/** `memory`: action-verb titles that keep the write target visible. */
|
||||
internal object MemoryRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Saving"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String {
|
||||
val action = ToolJson.firstString(call.args, listOf("action")).lowercase()
|
||||
val target = ToolJson.firstString(call.args, listOf("target"))
|
||||
val base =
|
||||
when (action) {
|
||||
"replace", "update" -> "Memory Updated"
|
||||
"remove", "delete" -> "Memory Removed"
|
||||
"add" -> "Memory Saved"
|
||||
else -> "Memory ${ToolJson.firstString(call.args, listOf("action"))}"
|
||||
}
|
||||
|
||||
// Keep the target visible — "Memory Saved (user)" — like the
|
||||
// pre-engine mobile summary did.
|
||||
return if (target.isNotEmpty()) "$base ($target)" else base
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error"))
|
||||
|
||||
override fun detail(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error"))
|
||||
}
|
||||
|
||||
/** `fact_store`: add/remove/update acks and fact listings with trust scores. */
|
||||
internal object FactStoreRenderer : ToolRenderer {
|
||||
private fun facts(call: ToolCall): JsonArray? =
|
||||
(call.result?.get("results") as? JsonArray)
|
||||
?: (call.result?.get("facts") as? JsonArray)
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val args = call.args
|
||||
val result = call.result
|
||||
val action =
|
||||
ToolJson
|
||||
.firstString(
|
||||
args,
|
||||
listOf("action"),
|
||||
).ifEmpty { ToolJson.firstString(result, listOf("action")) }
|
||||
val entity = ToolJson.firstString(args, listOf("entity"))
|
||||
val query = ToolJson.firstString(args, listOf("query"))
|
||||
val facts = facts(call)
|
||||
val count = ToolJson.intValue(result?.get("count")) ?: facts?.size ?: 0
|
||||
val status = ToolJson.firstString(result, listOf("status"))
|
||||
val factId = ToolJson.intValue(result?.get("fact_id"))
|
||||
val removed = ToolJson.boolTrue(result?.get("removed"))
|
||||
val updated = ToolJson.boolTrue(result?.get("updated"))
|
||||
|
||||
val actionContext =
|
||||
when {
|
||||
entity.isNotEmpty() -> " ($action: $entity)"
|
||||
query.isNotEmpty() -> " ($action: $query)"
|
||||
else -> " ($action)"
|
||||
}
|
||||
|
||||
return when {
|
||||
status == "added" && factId != null -> "Fact added (ID: $factId)"
|
||||
status == "added" -> "Fact added"
|
||||
removed -> "Fact removed"
|
||||
updated -> "Fact updated"
|
||||
facts != null -> "$count facts$actionContext"
|
||||
else -> action
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
facts(call)
|
||||
?.mapNotNull { el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val fid = ToolJson.intValue(item["fact_id"]) ?: 0
|
||||
val content = ToolJson.firstString(item, listOf("content"))
|
||||
val category = ToolJson.firstString(item, listOf("category"))
|
||||
val trust = ToolJson.numberValue(item["trust_score"])
|
||||
val tags = ToolJson.firstString(item, listOf("tags"))
|
||||
|
||||
val metaParts = mutableListOf<String>()
|
||||
if (category.isNotEmpty()) metaParts += "[$category]"
|
||||
if (trust != null) metaParts += "trust: ${"%.2f".format(trust)}"
|
||||
if (tags.isNotEmpty()) metaParts += "🏷️ $tags"
|
||||
|
||||
if (metaParts.isNotEmpty()) {
|
||||
"#$fid $content\n ${metaParts.joinToString(" ")}"
|
||||
} else {
|
||||
"#$fid $content"
|
||||
}
|
||||
}?.joinToString("\n")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: ""
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** `project_list`: project roster with the active project starred. */
|
||||
internal object ProjectListRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val projects = (call.result?.get("projects") as? JsonArray)?.size
|
||||
val active = ToolJson.firstString(call.result, listOf("active_id"))
|
||||
|
||||
return if (projects != null) {
|
||||
"$projects projects${active.takeIf { it.isNotEmpty() }?.let { " (1 active)" } ?: ""}"
|
||||
} else {
|
||||
"Projects"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
(call.result?.get("projects") as? JsonArray)
|
||||
?.mapNotNull { el ->
|
||||
val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(p, listOf("name"))
|
||||
val slug = ToolJson.firstString(p, listOf("slug"))
|
||||
val path = ToolJson.firstString(p, listOf("primary_path"))
|
||||
val isActive =
|
||||
(p["active"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
|
||||
val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name")
|
||||
if (slug.isNotEmpty()) lines += " 🏷️ $slug"
|
||||
if (path.isNotEmpty()) lines += " 📍 $path"
|
||||
if (isActive) lines += " ✅ ACTIVE PROJECT"
|
||||
lines.joinToString("\n")
|
||||
}?.joinToString("\n\n")
|
||||
?: ""
|
||||
}
|
||||
|
||||
/** `project_create` / `project_switch`: mutation ack with name/slug/path. */
|
||||
internal object ProjectMutateRenderer : ToolRenderer {
|
||||
private fun actionLabel(call: ToolCall): String = if (call.name == "project_create") "Created" else "Switched to"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val name = ToolJson.firstString(call.result, listOf("name"))
|
||||
val label = actionLabel(call)
|
||||
|
||||
return if (name.isNotEmpty()) "$label $name" else label
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
val name = ToolJson.firstString(call.result, listOf("name"))
|
||||
val slug = ToolJson.firstString(call.result, listOf("slug"))
|
||||
val path = ToolJson.firstString(call.result, listOf("primary_path"))
|
||||
|
||||
return buildString {
|
||||
if (error.isNotEmpty()) {
|
||||
append("❌ $error")
|
||||
} else {
|
||||
append("✅ ${actionLabel(call)}")
|
||||
if (name.isNotEmpty()) append(": $name")
|
||||
if (slug.isNotEmpty()) append("\n 🏷️ $slug")
|
||||
if (path.isNotEmpty()) append("\n 📍 $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* `search_files`: ripgrep-backed content/file search.
|
||||
* Handles both result shapes: dense path-grouped `matches_text` string (≥5 hits)
|
||||
* and `matches:[{path,line,content}]` (<5 hits), plus `files:[path]`.
|
||||
*/
|
||||
internal object SearchFilesRenderer : ToolRenderer {
|
||||
private fun pattern(call: ToolCall) = ToolJson.firstString(call.args, listOf("pattern"))
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String? =
|
||||
pattern(call).takeIf { it.isNotEmpty() }?.let { "Searching “${ToolJson.compactPreview(it, 48)}”" }
|
||||
|
||||
override fun doneTitle(call: ToolCall): String? {
|
||||
val p = pattern(call).takeIf { it.isNotEmpty() } ?: return null
|
||||
val total = ToolJson.intValue(call.result?.get("total_count"))
|
||||
val suffix = total?.let { " · $it match${if (it == 1) "" else "es"}" } ?: ""
|
||||
return "Searched “${ToolJson.compactPreview(p, 48)}”$suffix"
|
||||
}
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val path = ToolJson.firstString(call.args, listOf("path")).takeIf { it != "." && it.isNotEmpty() } ?: ""
|
||||
val glob = ToolJson.firstString(call.args, listOf("file_glob"))
|
||||
return listOf(path, glob).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result ?: return ""
|
||||
// Dense ≥5-match block: already ripgrep-grouped, use verbatim.
|
||||
val dense = ToolJson.firstString(result, listOf("matches_text"))
|
||||
val body =
|
||||
if (dense.isNotEmpty()) {
|
||||
dense
|
||||
} else {
|
||||
(result["matches"] as? JsonArray)
|
||||
?.mapNotNull { el ->
|
||||
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val path = ToolJson.firstString(m, listOf("path"))
|
||||
val line = ToolJson.intValue(m["line"])
|
||||
val content = ToolJson.firstString(m, listOf("content"))
|
||||
"$path:${line ?: "?"}: ${content.trim()}"
|
||||
}?.joinToString("\n")
|
||||
?: (result["files"] as? JsonArray)
|
||||
?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
|
||||
}?.joinToString("\n") ?: ""
|
||||
}
|
||||
val hint =
|
||||
if (ToolJson.boolTrue(result["truncated"])) {
|
||||
ToolJson.firstString(result, listOf("_hint")).ifEmpty { "Results truncated" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return listOf(body, hint).filter { it.isNotEmpty() }.joinToString("\n\n")
|
||||
}
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras = ToolViewExtras(detailLabel = "Matches")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** `skills_list`: count-with-category subtitle and a name/description list. */
|
||||
internal object SkillsListRenderer : ToolRenderer {
|
||||
private fun skills(call: ToolCall): JsonArray? =
|
||||
(call.result?.get("skills") as? JsonArray)
|
||||
?: (call.result?.get("results") as? JsonArray)
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val category = ToolJson.firstString(call.args, listOf("category"))
|
||||
val count = skills(call)?.size ?: 0
|
||||
|
||||
return if (count > 0) {
|
||||
"$count skills${category.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""}"
|
||||
} else {
|
||||
"No skills found"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
skills(call)
|
||||
?.mapNotNull { el ->
|
||||
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(s, listOf("name"))
|
||||
val desc = ToolJson.firstString(s, listOf("description"))
|
||||
val cat = ToolJson.firstString(s, listOf("category"))
|
||||
val lines = mutableListOf("📌 $name")
|
||||
if (desc.isNotEmpty()) lines += " $desc"
|
||||
if (cat.isNotEmpty()) lines += " [$cat]"
|
||||
lines.joinToString("\n")
|
||||
}?.joinToString("\n\n")
|
||||
?: "No skills found"
|
||||
}
|
||||
|
||||
/** `skill_view`: content preview capped at 500 chars plus linked files. */
|
||||
internal object SkillViewRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("name"))
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val content = ToolJson.firstString(call.result, listOf("content"))
|
||||
val linkedFiles = call.result?.get("linked_files") as? JsonObject
|
||||
val contentPreview =
|
||||
if (content.length > 500) {
|
||||
"${content.take(
|
||||
500,
|
||||
)}\n... [${content.length - 500} more chars]"
|
||||
} else {
|
||||
content
|
||||
}
|
||||
val filesInfo =
|
||||
linkedFiles
|
||||
?.entries
|
||||
?.joinToString("\n") { (k, v) ->
|
||||
val paths =
|
||||
when (v) {
|
||||
is JsonArray -> {
|
||||
v.joinToString(
|
||||
", ",
|
||||
) { (it as? JsonPrimitive)?.content ?: it.toString() }
|
||||
}
|
||||
|
||||
is JsonPrimitive -> {
|
||||
v.content
|
||||
}
|
||||
|
||||
else -> {
|
||||
v.toString()
|
||||
}
|
||||
}
|
||||
" 📎 $k: $paths"
|
||||
}
|
||||
|
||||
return buildString {
|
||||
if (contentPreview.isNotEmpty()) append(contentPreview)
|
||||
if (filesInfo != null) {
|
||||
if (contentPreview.isNotEmpty()) append("\n\n")
|
||||
append("━━━ Linked Files ━━━\n$filesInfo")
|
||||
}
|
||||
if (content.isEmpty() && linkedFiles == null) append("No content available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `skill_manage`: action ack with the skill name. */
|
||||
internal object SkillManageRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||
val name = ToolJson.firstString(call.args, listOf("name"))
|
||||
|
||||
return if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val error = ToolJson.firstString(call.result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val msg = ToolJson.firstString(call.result, listOf("message"))
|
||||
val failed = ToolJson.boolFalse(call.result?.get("success"))
|
||||
|
||||
// Pinned quirk from the original engine: the branch order is inverted
|
||||
// (a non-false `success` renders the failure line). Preserved verbatim
|
||||
// by the characterization suite; fix upstream deliberately if desired.
|
||||
return when {
|
||||
!failed -> "❌ Skill operation failed"
|
||||
msg.isNotEmpty() -> "✅ $msg"
|
||||
else -> "✅ Skill operation done"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `tool_search`: on-demand tool discovery matches. */
|
||||
internal object ToolSearchRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val query = ToolJson.firstString(call.args, listOf("query"))
|
||||
val matches = (call.result?.get("matches") as? JsonArray)?.size
|
||||
|
||||
return if (matches != null) "$query ($matches matches)" else query
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String =
|
||||
(call.result?.get("matches") as? JsonArray)
|
||||
?.mapNotNull { el ->
|
||||
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(m, listOf("name"))
|
||||
val desc = ToolJson.firstString(m, listOf("description"))
|
||||
val lines = mutableListOf("🔧 $name")
|
||||
if (desc.isNotEmpty()) lines += " $desc"
|
||||
lines.joinToString("\n")
|
||||
}?.joinToString("\n\n")
|
||||
?: "No matching tools"
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** `cronjob`: job listings with schedules, or the mutated job's key fields. */
|
||||
internal object CronjobRenderer : ToolRenderer {
|
||||
override fun doneTitle(call: ToolCall): String =
|
||||
"Cron ${ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }}"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val jobs = call.result?.get("jobs") as? JsonArray
|
||||
|
||||
if (jobs != null) {
|
||||
return if (jobs.isNotEmpty()) {
|
||||
"${jobs.size} cron ${if (jobs.size == 1) "job" else "jobs"}"
|
||||
} else {
|
||||
"No cron jobs"
|
||||
}
|
||||
}
|
||||
|
||||
val message = ToolJson.firstString(call.result, listOf("message"))
|
||||
if (message.isNotEmpty()) {
|
||||
return message
|
||||
}
|
||||
|
||||
val action = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }
|
||||
val name =
|
||||
ToolJson
|
||||
.firstString(call.result, listOf("name"))
|
||||
.ifEmpty { ToolJson.firstString(call.args, listOf("name", "job_id")) }
|
||||
val label = action.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
|
||||
return if (name.isNotEmpty()) "$label $name" else "Cron $action"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val jobs = result?.get("jobs") as? JsonArray
|
||||
|
||||
if (jobs != null) {
|
||||
if (jobs.isEmpty()) {
|
||||
return "No cron jobs scheduled"
|
||||
}
|
||||
|
||||
return jobs
|
||||
.take(20)
|
||||
.mapNotNull { job ->
|
||||
val row = ToolJson.parseMaybeObject(job) ?: return@mapNotNull null
|
||||
val name = ToolJson.firstString(row, listOf("name", "id")).ifEmpty { "job" }
|
||||
val sched = ToolJson.firstString(row, listOf("schedule_display", "schedule"))
|
||||
if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
val rows =
|
||||
listOf(
|
||||
"Schedule" to ToolJson.firstString(result, listOf("schedule")),
|
||||
"Repeat" to ToolJson.firstString(result, listOf("repeat")),
|
||||
"Delivery" to ToolJson.firstString(result, listOf("deliver")),
|
||||
"Next run" to ToolJson.firstString(result, listOf("next_run_at")),
|
||||
).filter { it.second.isNotEmpty() }
|
||||
|
||||
return if (rows.isNotEmpty()) {
|
||||
rows.joinToString(
|
||||
"\n",
|
||||
) { (k, v) -> "$k: $v" }
|
||||
} else {
|
||||
ToolJson.fallbackDetailText(call.args, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `todo`: status-count subtitle plus a checklist with [x]/[>]/[~] markers. */
|
||||
internal object TodoRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val summary = call.result?.get("summary") as? JsonObject ?: return ""
|
||||
val total = ToolJson.intValue(summary["total"]) ?: 0
|
||||
val pending = ToolJson.intValue(summary["pending"]) ?: 0
|
||||
val inProgress = ToolJson.intValue(summary["in_progress"]) ?: 0
|
||||
val completed = ToolJson.intValue(summary["completed"]) ?: 0
|
||||
val cancelled = ToolJson.intValue(summary["cancelled"]) ?: 0
|
||||
|
||||
val parts =
|
||||
listOf(
|
||||
pending.takeIf { it > 0 }?.let { "$it pending" },
|
||||
inProgress.takeIf { it > 0 }?.let { "$it in_progress" },
|
||||
completed.takeIf { it > 0 }?.let { "$it completed" },
|
||||
cancelled.takeIf { it > 0 }?.let { "$it cancelled" },
|
||||
).filterNotNull()
|
||||
|
||||
val itemWord = if (total == 1) "item" else "items"
|
||||
|
||||
return if (parts.isEmpty()) "0 items" else "$total $itemWord (${parts.joinToString(", ")})"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val todos = call.result?.get("todos") as? JsonArray ?: return ""
|
||||
|
||||
return todos
|
||||
.mapNotNull { el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val id = ToolJson.firstString(item, listOf("id"))
|
||||
val content = ToolJson.firstString(item, listOf("content"))
|
||||
val status = ToolJson.firstString(item, listOf("status")).ifEmpty { "pending" }
|
||||
val parent = ToolJson.firstString(item, listOf("parent"))
|
||||
val marker =
|
||||
when (status) {
|
||||
"completed" -> "[x]"
|
||||
"in_progress" -> "[>]"
|
||||
"cancelled" -> "[~]"
|
||||
else -> "[ ]"
|
||||
}
|
||||
val indent = if (parent.isNotEmpty()) " ↳ " else ""
|
||||
"$indent$marker $id. $content"
|
||||
}.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/** `session_search`: mode-specific subtitles and rich session/message lists. */
|
||||
internal object SessionSearchRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val mode = ToolJson.firstString(result, listOf("mode"))
|
||||
val query = ToolJson.firstString(result, listOf("query"))
|
||||
val count = ToolJson.intValue(result?.get("count"))
|
||||
val msgCount = ToolJson.intValue(result?.get("message_count"))
|
||||
val messages = result?.get("messages") as? JsonArray
|
||||
val truncated = ToolJson.boolTrue(result?.get("truncated"))
|
||||
|
||||
return when (mode) {
|
||||
"discover" -> "${count ?: 0} session${if (count == 1) "" else "s"}${query.takeIf { it.isNotEmpty() }?.let {
|
||||
": ${it.take(
|
||||
60,
|
||||
)}"
|
||||
} ?: ""}"
|
||||
|
||||
"scroll" -> "${messages?.size ?: 0} messages (scroll)"
|
||||
|
||||
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
|
||||
|
||||
"browse" -> "${count ?: 0} recent sessions"
|
||||
|
||||
else -> "session_search"
|
||||
}
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val results = result?.get("results") as? JsonArray
|
||||
val messages = result?.get("messages") as? JsonArray
|
||||
|
||||
val formattedResults =
|
||||
results
|
||||
?.mapIndexedNotNull { idx, el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapIndexedNotNull null
|
||||
val whenField = ToolJson.firstString(item, listOf("when", "started_at"))
|
||||
val source = ToolJson.firstString(item, listOf("source"))
|
||||
val title = ToolJson.firstString(item, listOf("title"))
|
||||
val snippet =
|
||||
ToolJson
|
||||
.firstString(item, listOf("snippet"))
|
||||
.ifEmpty { ToolJson.firstString(item, listOf("preview")) }
|
||||
val model = ToolJson.firstString(item, listOf("model"))
|
||||
val matchedRole = ToolJson.firstString(item, listOf("matched_role"))
|
||||
val sessionMsgCount = ToolJson.intValue(item["message_count"])
|
||||
|
||||
val header = whenField.takeIf { it.isNotEmpty() }?.let { "📅 $it" } ?: ""
|
||||
val sourceTag = source.takeIf { it.isNotEmpty() }?.let { "[$it]" } ?: ""
|
||||
val titleLine = title.takeIf { it.isNotEmpty() }?.let { "\n 📄 $it" } ?: ""
|
||||
val modelLine = model.takeIf { it.isNotEmpty() }?.let { "\n 🤖 $it" } ?: ""
|
||||
val matchedLine = matchedRole.takeIf { it.isNotEmpty() }?.let { "\n 🎯 matched: $it" } ?: ""
|
||||
val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: ""
|
||||
val snippetLine =
|
||||
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
|
||||
|
||||
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
|
||||
}?.joinToString("\n")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
val anchorId = ToolJson.intValue(result?.get("around_message_id"))
|
||||
|
||||
val formattedMessages =
|
||||
messages
|
||||
?.mapNotNull { el ->
|
||||
val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val msgId = ToolJson.intValue(item["id"])
|
||||
val role = ToolJson.firstString(item, listOf("role")).ifEmpty { "?" }
|
||||
val content = ToolJson.firstString(item, listOf("content"))
|
||||
val toolName = ToolJson.firstString(item, listOf("tool_name"))
|
||||
|
||||
val roleEmoji =
|
||||
when (role) {
|
||||
"user" -> "👤"
|
||||
"assistant" -> "🤖"
|
||||
"tool" -> "🔧"
|
||||
else -> "❓"
|
||||
}
|
||||
val namePart = if (toolName.isNotEmpty()) " ($toolName)" else ""
|
||||
val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else ""
|
||||
val cleanContent = content.take(300).replace("\n", " ")
|
||||
|
||||
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
|
||||
}?.joinToString("\n")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
return formattedResults ?: formattedMessages ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
/** `process`: background session listings and lifecycle acks. */
|
||||
internal object ProcessRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||
val procId = ToolJson.firstString(call.args, listOf("session_id"))
|
||||
|
||||
return if (procId.isNotEmpty()) "$action: $procId" else action
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val processes = result?.get("processes") as? JsonArray
|
||||
if (processes != null) {
|
||||
return processes
|
||||
.mapNotNull { el ->
|
||||
val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||
val pid =
|
||||
ToolJson
|
||||
.firstString(p, listOf("session_id"))
|
||||
.ifEmpty { ToolJson.firstString(p, listOf("id")) }
|
||||
val pStatus = ToolJson.firstString(p, listOf("status"))
|
||||
val cmd = ToolJson.firstString(p, listOf("command"))
|
||||
val running =
|
||||
(p["running"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" }
|
||||
val parts = mutableListOf("📌 $pid${cmd.takeIf { it.isNotEmpty() }?.let { ": $it" } ?: ""}")
|
||||
if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
|
||||
if (running != null) parts += " Running: $running"
|
||||
parts.joinToString("\n")
|
||||
}.joinToString("\n\n")
|
||||
}
|
||||
|
||||
val output = ToolJson.firstString(result, listOf("output"))
|
||||
if (output.isNotEmpty()) {
|
||||
val status = ToolJson.firstString(result, listOf("status"))
|
||||
return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output"
|
||||
}
|
||||
|
||||
val status = ToolJson.firstString(result, listOf("status"))
|
||||
if (status.isNotEmpty()) {
|
||||
return "✅ $status"
|
||||
}
|
||||
|
||||
val action = ToolJson.firstString(call.args, listOf("action"))
|
||||
return "✅ ${action.ifEmpty { "done" }} done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.CommandSummarizer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* `terminal` and `execute_code`: shell-styled rows with a summarized command
|
||||
* title, first output line as subtitle, and split (or merged) streams for
|
||||
* the ANSI-aware expanded view. Exit code and the `$` command line are only
|
||||
* meaningful for `terminal`.
|
||||
*/
|
||||
internal object TerminalRenderer : ToolRenderer {
|
||||
private fun shellCommand(args: JsonObject?): String =
|
||||
ToolJson
|
||||
.firstString(args, listOf("command", "code"))
|
||||
.ifEmpty { ToolJson.firstString(args, listOf("context", "preview")) }
|
||||
.ifEmpty { ToolJson.contextValue(args) }
|
||||
|
||||
private fun shellOutput(result: JsonObject?): String {
|
||||
val output = ToolJson.firstString(result, listOf("output", "stdout", "stderr"))
|
||||
val lines =
|
||||
(result?.get("lines") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||
?.joinToString("\n")
|
||||
?: ""
|
||||
|
||||
return listOf(output, lines).filter { it.isNotEmpty() }.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun commandTitle(
|
||||
call: ToolCall,
|
||||
runningVerb: Boolean,
|
||||
): String? {
|
||||
val command = shellCommand(call.args)
|
||||
val verb =
|
||||
when {
|
||||
call.name == "execute_code" && runningVerb -> "Running code"
|
||||
call.name == "execute_code" -> "Ran code"
|
||||
runningVerb -> "Running"
|
||||
else -> "Ran"
|
||||
}
|
||||
|
||||
if (command.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return "$verb ${ToolJson.compactPreview(CommandSummarizer.summarizeShellCommand(command), 160)}"
|
||||
}
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String? = commandTitle(call, runningVerb = true)
|
||||
|
||||
override fun doneTitle(call: ToolCall): String? = commandTitle(call, runningVerb = false)
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val firstMeaningfulLine =
|
||||
shellOutput(call.result)
|
||||
.split("\n")
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotEmpty() }
|
||||
|
||||
// A terminal row with no output shows its command in the title;
|
||||
// nothing more to add.
|
||||
return firstMeaningfulLine?.let { ToolJson.compactPreview(it, 160) } ?: ""
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val output = shellOutput(call.result)
|
||||
|
||||
return when {
|
||||
output.isNotEmpty() -> output
|
||||
|
||||
call.name == "execute_code" -> ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
|
||||
// A terminal row with no output already shows its command in the
|
||||
// title; the generic fallback would repeat it.
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras {
|
||||
// The gateway's terminal_tool emits a single merged `output` string
|
||||
// (stdout+stderr, ANSI-stripped); older payloads carried separate
|
||||
// `stdout`/`stderr`. Prefer a real stream split, fall back to the
|
||||
// merged output as the single renderable stream.
|
||||
val stdout = ToolJson.firstString(call.result, listOf("stdout", "output"))
|
||||
val stderr = ToolJson.firstString(call.result, listOf("stderr"))
|
||||
val hasSplitStreams = stdout.isNotEmpty() || stderr.isNotEmpty()
|
||||
|
||||
return ToolViewExtras(
|
||||
stdout = if (hasSplitStreams) stdout else null,
|
||||
stderr = if (hasSplitStreams) stderr else null,
|
||||
exitCode = if (call.name == "terminal") ToolJson.intValue(call.result?.get("exit_code")) else null,
|
||||
terminalCommand = if (call.name == "terminal") shellCommand(call.args) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `read_terminal`: a line-window over a live terminal session. */
|
||||
internal object ReadTerminalRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
if (error.isNotEmpty()) {
|
||||
return "❌ $error"
|
||||
}
|
||||
|
||||
val total = ToolJson.intValue(result?.get("total_lines")) ?: 0
|
||||
val start = ToolJson.intValue(result?.get("start")) ?: 0
|
||||
val end = ToolJson.intValue(result?.get("end")) ?: 0
|
||||
val text = ToolJson.firstString(result, listOf("text"))
|
||||
|
||||
if (text.isEmpty()) {
|
||||
return "Terminal output"
|
||||
}
|
||||
|
||||
val cursor = ToolJson.intValue(result?.get("cursor_row"))
|
||||
|
||||
return "Lines $start-$end of $total${cursor?.let { ", cursor at row $it" } ?: ""}"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
val text = ToolJson.firstString(result, listOf("text"))
|
||||
val total = ToolJson.intValue(result?.get("total_lines")) ?: 0
|
||||
val start = ToolJson.intValue(result?.get("start")) ?: 0
|
||||
val end = ToolJson.intValue(result?.get("end")) ?: 0
|
||||
val cursor = ToolJson.intValue(result?.get("cursor_row"))
|
||||
|
||||
return buildString {
|
||||
if (error.isNotEmpty()) {
|
||||
append("❌ $error")
|
||||
} else if (text.isNotEmpty()) {
|
||||
append("━━━ Terminal ━━━ ($start:$end / $total)")
|
||||
if (cursor != null) append(" │ cursor row $cursor")
|
||||
append("\n$text")
|
||||
} else {
|
||||
append("No terminal output")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool.render
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.tool.SearchHit
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/** Resolve the URL a web/browser tool targeted, as a display hostname+path. */
|
||||
internal fun targetHostname(
|
||||
args: JsonObject?,
|
||||
result: JsonObject?,
|
||||
): String {
|
||||
val url =
|
||||
ToolJson
|
||||
.firstString(args, listOf("url", "target"))
|
||||
.ifEmpty { ToolJson.firstString(result, listOf("url")) }
|
||||
.ifEmpty { ToolJson.findFirstUrl(args, result) }
|
||||
|
||||
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "page"
|
||||
}
|
||||
|
||||
/**
|
||||
* `web_search`: quoted-query title, parsed [SearchHit] rows for the
|
||||
* structured result list, and a "Search results" section label.
|
||||
*/
|
||||
internal object WebSearchRenderer : ToolRenderer {
|
||||
private fun searchQueryFor(args: JsonObject?): String =
|
||||
ToolJson.firstString(args, listOf("search_term", "query")).ifEmpty { ToolJson.contextValue(args) }
|
||||
|
||||
private fun extractSearchResults(
|
||||
result: JsonElement?,
|
||||
limit: Int = 6,
|
||||
): List<SearchHit> =
|
||||
ToolJson
|
||||
.collectResultItems(result)
|
||||
.mapNotNull { item ->
|
||||
val r = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null
|
||||
|
||||
SearchHit(
|
||||
title = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("title", "name"))),
|
||||
url = ToolJson.firstString(r, listOf("url", "href", "link")),
|
||||
snippet =
|
||||
ToolJson.cleanVisibleText(
|
||||
ToolJson.firstString(r, listOf("snippet", "description", "body")),
|
||||
),
|
||||
)
|
||||
}.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
|
||||
.take(limit)
|
||||
|
||||
override fun pendingTitle(call: ToolCall): String =
|
||||
"Searching \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\""
|
||||
|
||||
override fun doneTitle(call: ToolCall): String =
|
||||
"Searched \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\""
|
||||
|
||||
override fun subtitle(call: ToolCall): String =
|
||||
searchQueryFor(call.args).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
|
||||
|
||||
override fun extras(
|
||||
call: ToolCall,
|
||||
status: ToolViewStatus,
|
||||
): ToolViewExtras {
|
||||
val hits = if (status != ToolViewStatus.ERROR) extractSearchResults(call.rawResult) else emptyList()
|
||||
|
||||
return ToolViewExtras(
|
||||
detailLabel = if (hits.isNotEmpty()) "Search results" else null,
|
||||
searchHits = hits.ifEmpty { null },
|
||||
searchQuery = searchQueryFor(call.args).ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `web_extract`: hostname title/subtitle with the extracted text as detail. */
|
||||
internal object WebExtractRenderer : ToolRenderer {
|
||||
override fun pendingTitle(call: ToolCall): String = "Reading ${targetHostname(call.args, null)}"
|
||||
|
||||
override fun doneTitle(call: ToolCall): String = "Read ${targetHostname(call.args, call.result)}"
|
||||
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val url =
|
||||
ToolJson
|
||||
.firstString(call.args, listOf("url"))
|
||||
.ifEmpty { ToolJson.firstString(call.result, listOf("url")) }
|
||||
.ifEmpty { ToolJson.findFirstUrl(call.args, call.result) }
|
||||
|
||||
return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Fetched webpage"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val direct =
|
||||
ToolJson.firstString(call.result, listOf("content", "text", "markdown", "body", "summary", "message"))
|
||||
if (direct.isNotEmpty()) {
|
||||
return direct.replace(Regex("\\s*in\\s+\\d+(?:\\.\\d+)?s\\s*$"), "").trim()
|
||||
}
|
||||
|
||||
val results = call.result?.get("results") as? JsonArray
|
||||
if (results != null) {
|
||||
return results
|
||||
.mapNotNull { item ->
|
||||
val row = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null
|
||||
ToolJson.firstString(row, listOf("content", "text", "markdown", "body"))
|
||||
}.filter { it.isNotEmpty() }
|
||||
.joinToString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
return ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
|
||||
}
|
||||
}
|
||||
|
||||
/** `x_search`: query subtitle plus answer text with numbered citations. */
|
||||
internal object XSearchRenderer : ToolRenderer {
|
||||
override fun subtitle(call: ToolCall): String {
|
||||
val query = ToolJson.firstString(call.args, listOf("query"))
|
||||
val degraded = ToolJson.boolTrue(call.result?.get("degraded"))
|
||||
|
||||
return if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X"
|
||||
}
|
||||
|
||||
override fun detail(call: ToolCall): String {
|
||||
val result = call.result
|
||||
val error = ToolJson.firstString(result, listOf("error"))
|
||||
val answer = ToolJson.firstString(result, listOf("answer"))
|
||||
val citations = result?.get("citations") as? JsonArray
|
||||
val degraded = ToolJson.boolTrue(result?.get("degraded"))
|
||||
|
||||
return when {
|
||||
error.isNotEmpty() -> {
|
||||
"❌ $error"
|
||||
}
|
||||
|
||||
answer.isNotEmpty() -> {
|
||||
val lines = mutableListOf(answer)
|
||||
if (citations != null && citations.isNotEmpty()) {
|
||||
lines += "\n━━━ Citations ━━━"
|
||||
citations.forEachIndexed { idx, cit ->
|
||||
val c = ToolJson.parseMaybeObject(cit) ?: return@forEachIndexed
|
||||
val url = ToolJson.firstString(c, listOf("url"))
|
||||
val title = ToolJson.firstString(c, listOf("title"))
|
||||
lines += "${idx + 1}. ${title.ifEmpty { url.ifEmpty { "source" } }}"
|
||||
if (title.isNotEmpty() && url.isNotEmpty()) lines += " $url"
|
||||
}
|
||||
}
|
||||
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
|
||||
lines.joinToString("\n")
|
||||
}
|
||||
|
||||
else -> {
|
||||
"No results"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
|
||||
/**
|
||||
* Standard confirmation dialog across the app.
|
||||
* Supports destructive actions with error styling, optional title icons, and optional extra body content.
|
||||
*/
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
confirmText: String = stringResource(R.string.action_confirm),
|
||||
dismissText: String = stringResource(R.string.action_cancel),
|
||||
isDestructive: Boolean = false,
|
||||
icon: ImageVector? = null,
|
||||
extraContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = modifier,
|
||||
title = {
|
||||
if (icon != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (isDestructive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(title)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
if (extraContent != null) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(message)
|
||||
extraContent()
|
||||
}
|
||||
} else {
|
||||
Text(message)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (isDestructive) {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text(confirmText)
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onConfirm) {
|
||||
Text(confirmText)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(dismissText)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.HubSkill
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
@@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List<DetailRow> =
|
||||
DetailRow(stringResource(R.string.detail_dialog_description), description),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PluginCatalogEntry.toDetailRows(): List<DetailRow> =
|
||||
listOfNotNull(
|
||||
tier?.let { DetailRow(stringResource(R.string.detail_dialog_tier), it) },
|
||||
maintainer?.let { DetailRow(stringResource(R.string.detail_dialog_maintainer), it) },
|
||||
category?.let { DetailRow(stringResource(R.string.detail_dialog_category), it) },
|
||||
repo?.let { DetailRow(stringResource(R.string.detail_dialog_repository), it) },
|
||||
shaShort?.let { DetailRow(stringResource(R.string.detail_dialog_commit), it) },
|
||||
requiresHermes?.takeIf { it.isNotBlank() }?.let {
|
||||
DetailRow(stringResource(R.string.detail_dialog_requires_hermes), it)
|
||||
},
|
||||
docsUrl?.takeIf { it.isNotBlank() }?.let {
|
||||
DetailRow(stringResource(R.string.detail_dialog_docs), it)
|
||||
},
|
||||
capabilitySummary?.takeIf { it.isNotBlank() }?.let {
|
||||
DetailRow(stringResource(R.string.detail_dialog_capabilities), it)
|
||||
},
|
||||
description?.let { DetailRow(stringResource(R.string.detail_dialog_description), it) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Toolset.toDetailRows(): List<DetailRow> =
|
||||
listOf(
|
||||
|
||||
@@ -34,6 +34,33 @@ inline fun <T> ViewModel.safeLaunchLoad(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks.
|
||||
* Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning.
|
||||
*/
|
||||
inline fun <T> ViewModel.safeLaunchAction(
|
||||
currentJob: Job? = null,
|
||||
crossinline onStart: () -> Unit = {},
|
||||
crossinline apiCall: suspend () -> NetworkResult<T>,
|
||||
crossinline onSuccess: (T) -> Unit = {},
|
||||
crossinline onError: (String) -> Unit = {},
|
||||
crossinline onComplete: () -> Unit = {},
|
||||
): Job {
|
||||
if (currentJob?.isActive == true) return currentJob
|
||||
onStart()
|
||||
return viewModelScope.launch {
|
||||
try {
|
||||
val result = apiCall()
|
||||
when (result) {
|
||||
is NetworkResult.Success -> onSuccess(result.data)
|
||||
is NetworkResult.Failure -> onError(result.error.message)
|
||||
}
|
||||
} finally {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
|
||||
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||
|
||||
object AddServerRequestBuilder {
|
||||
/**
|
||||
* Constructs a validated AddMcpServerRequest from the add-server form parameters.
|
||||
*/
|
||||
fun build(
|
||||
name: String,
|
||||
mode: AddServerMode,
|
||||
url: String,
|
||||
command: String,
|
||||
args: String,
|
||||
auth: String,
|
||||
bearerToken: String,
|
||||
): AddMcpServerRequest {
|
||||
val isHttp = mode == AddServerMode.HTTP
|
||||
val isStdio = mode == AddServerMode.Stdio
|
||||
|
||||
val resolvedUrl = if (isHttp) url.trim().ifBlank { null } else null
|
||||
val resolvedCommand = if (isStdio) command.trim().ifBlank { null } else null
|
||||
val resolvedArgs =
|
||||
if (isStdio && args.isNotBlank()) {
|
||||
args.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val resolvedAuth = if (isHttp && auth != "none") auth else null
|
||||
val resolvedBearerToken =
|
||||
if (isHttp && auth == "header") bearerToken.trim().ifBlank { null } else null
|
||||
|
||||
return AddMcpServerRequest(
|
||||
name = name.trim(),
|
||||
url = resolvedUrl,
|
||||
command = resolvedCommand,
|
||||
args = resolvedArgs,
|
||||
auth = resolvedAuth,
|
||||
bearerToken = resolvedBearerToken,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overload reading form parameters directly from McpServersUiState.
|
||||
*/
|
||||
fun build(state: McpServersUiState): AddMcpServerRequest =
|
||||
build(
|
||||
name = state.addServerName,
|
||||
mode = state.addMode,
|
||||
url = state.addServerUrl,
|
||||
command = state.addServerCommand,
|
||||
args = state.addServerArgs,
|
||||
auth = state.addServerAuth,
|
||||
bearerToken = state.addServerBearerToken,
|
||||
)
|
||||
}
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user