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
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<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.SettingsChatPage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsLanguagePage
|
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.settings.SettingsViewModel
|
||||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -137,6 +138,11 @@ private fun appEntryProvider(
|
|||||||
viewModel = viewModel { SettingsViewModel() },
|
viewModel = viewModel { SettingsViewModel() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
entry<SettingsVault> {
|
||||||
|
SettingsVaultPage(
|
||||||
|
onBack = { NavigationController.goBack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
entry<SettingsAbout> {
|
entry<SettingsAbout> {
|
||||||
SettingsAboutPage(
|
SettingsAboutPage(
|
||||||
onBack = { NavigationController.goBack() },
|
onBack = { NavigationController.goBack() },
|
||||||
|
|||||||
@@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object SettingsBehavior : NavKey
|
@Serializable data object SettingsBehavior : NavKey
|
||||||
|
|
||||||
|
@Serializable data object SettingsVault : NavKey
|
||||||
|
|
||||||
@Serializable data object SettingsAbout : NavKey
|
@Serializable data object SettingsAbout : NavKey
|
||||||
@@ -12,6 +12,8 @@ data class ServerStoreState(
|
|||||||
val port: Int = 9119,
|
val port: Int = 9119,
|
||||||
val baseUrl: String? = null,
|
val baseUrl: String? = null,
|
||||||
val autoReconnect: Boolean = true,
|
val autoReconnect: Boolean = true,
|
||||||
|
val restoreLastSession: Boolean = false,
|
||||||
|
val lastOpenedSessionIds: Map<String, String> = emptyMap(),
|
||||||
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
||||||
val useDynamicColors: Boolean = false,
|
val useDynamicColors: Boolean = false,
|
||||||
val themePreset: ThemePreset = ThemePreset.DEFAULT,
|
val themePreset: ThemePreset = ThemePreset.DEFAULT,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKeys
|
import androidx.security.crypto.MasterKeys
|
||||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||||
@@ -500,6 +501,11 @@ object AuthManager {
|
|||||||
_baseUrlFlow.value = ""
|
_baseUrlFlow.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun setBaseUrlForTest(baseUrl: String) {
|
||||||
|
_baseUrlFlow.value = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
fun getToken(): String? {
|
fun getToken(): String? {
|
||||||
if (tokenInitialized) return cachedToken
|
if (tokenInitialized) return cachedToken
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
@@ -610,6 +616,47 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(autoReconnect = enabled) }
|
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 ──────────────────────────────────────────────────
|
// ── Theme preference ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference
|
fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference
|
||||||
|
|||||||
@@ -42,4 +42,8 @@ data class ChatMessageEntity(
|
|||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
@ColumnInfo(name = "display_kind")
|
@ColumnInfo(name = "display_kind")
|
||||||
val displayKind: String? = null,
|
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
|
else -> null
|
||||||
},
|
},
|
||||||
displayKind = displayKind,
|
displayKind = displayKind,
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
@@ -50,4 +52,6 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
|||||||
toolStatus = toolStatus?.name,
|
toolStatus = toolStatus?.name,
|
||||||
isStreaming = isStreaming,
|
isStreaming = isStreaming,
|
||||||
displayKind = displayKind,
|
displayKind = displayKind,
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
)
|
)
|
||||||
@@ -12,7 +12,7 @@ import java.io.File
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ChatMessageEntity::class],
|
entities = [ChatMessageEntity::class],
|
||||||
version = 6,
|
version = 7,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class HermesDatabase : RoomDatabase() {
|
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 =
|
fun get(context: Context): HermesDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
// SQLCipher can't open plaintext SQLite databases — if an old
|
// SQLCipher can't open plaintext SQLite databases — if an old
|
||||||
@@ -97,6 +109,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
|||||||
MIGRATION_3_4,
|
MIGRATION_3_4,
|
||||||
MIGRATION_4_5,
|
MIGRATION_4_5,
|
||||||
MIGRATION_5_6,
|
MIGRATION_5_6,
|
||||||
|
MIGRATION_6_7,
|
||||||
).fallbackToDestructiveMigration(false)
|
).fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
.also { instance = it }
|
.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
|
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 —
|
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
|
||||||
* the same RPC the Hermes desktop app's status-bar meter uses.
|
* 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`.
|
* Parse the decoded JSON-RPC result of `session.context_breakdown`.
|
||||||
*
|
*
|
||||||
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
|
* 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
|
* not a map (error/malformed response) — callers treat that as unknown and
|
||||||
* keep the last known meter values.
|
* keep the last known meter values.
|
||||||
*/
|
*/
|
||||||
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? {
|
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(
|
return ContextBreakdownResponse(
|
||||||
contextUsed = num("context_used"),
|
contextUsed = num("context_used"),
|
||||||
contextMax = num("context_max"),
|
contextMax = num("context_max"),
|
||||||
contextPercent = (map["context_percent"] as? Number)?.toInt(),
|
contextPercent = intNum("context_percent"),
|
||||||
estimatedTotal = num("estimated_total"),
|
estimatedTotal = num("estimated_total"),
|
||||||
model = map["model"] as? String,
|
model = str("model"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,9 +80,63 @@ data class PluginsHubResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AgentPluginInstallBody(
|
data class AgentPluginInstallBody(
|
||||||
val identifier: String,
|
val identifier: String = "",
|
||||||
val force: Boolean = false,
|
val force: Boolean = false,
|
||||||
val enable: Boolean = true,
|
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
|
@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.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
@@ -78,6 +79,14 @@ data class SessionInfo(
|
|||||||
val display_name: String? = null,
|
val display_name: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val terminal_backend: 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
|
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
|
||||||
// sweep and are surfaced first in the history list (sorted client-side).
|
// 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).
|
// 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.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
import kotlinx.serialization.json.longOrNull
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -50,10 +51,18 @@ data class SessionMessage(
|
|||||||
val display_kind: String? = null,
|
val display_kind: String? = null,
|
||||||
/** Display-only metadata for the marker (e.g. delegation result counts). */
|
/** Display-only metadata for the marker (e.g. delegation result counts). */
|
||||||
val display_metadata: JsonElement? = null,
|
val display_metadata: JsonElement? = null,
|
||||||
|
/** Token count recorded by the backend. */
|
||||||
|
val token_count: JsonElement? = null,
|
||||||
) {
|
) {
|
||||||
val timestampText: String?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
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?
|
val timestampEpochMs: Long?
|
||||||
get() {
|
get() {
|
||||||
val prim = timestamp as? JsonPrimitive ?: return null
|
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
|
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 —
|
* 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
|
* 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,
|
val contextMax: Long? = null,
|
||||||
/** Cumulative session total tokens. */
|
/** Cumulative session total tokens. */
|
||||||
val totalTokens: Long? = null,
|
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": { ... } }`
|
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
|
||||||
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
|
* (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.
|
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
|
||||||
*/
|
*/
|
||||||
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
||||||
val map = result as? Map<*, *> ?: return null
|
val rawMap =
|
||||||
val usageMap = (map["usage"] as? Map<*, *>) ?: map
|
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(
|
return UsageSnapshotResponse(
|
||||||
compressions = (usageMap["compressions"] as? Number)?.toInt(),
|
compressions = intNum("compressions"),
|
||||||
contextUsed = (usageMap["context_used"] as? Number)?.toLong(),
|
contextUsed = num("context_used"),
|
||||||
contextMax = (usageMap["context_max"] as? Number)?.toLong(),
|
contextMax = num("context_max"),
|
||||||
totalTokens = (usageMap["total"] as? Number)?.toLong(),
|
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.PairingApproveRequest
|
||||||
import com.m57.hermescontrol.data.model.PairingResponse
|
import com.m57.hermescontrol.data.model.PairingResponse
|
||||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
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.PluginProvidersPutRequest
|
||||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||||
import com.m57.hermescontrol.data.model.PortalResponse
|
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.SaveSkillContentRequest
|
||||||
import com.m57.hermescontrol.data.model.ScanStatus
|
import com.m57.hermescontrol.data.model.ScanStatus
|
||||||
import com.m57.hermescontrol.data.model.SessionDetailResponse
|
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.SessionListResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||||
@@ -267,6 +269,12 @@ interface HermesApiService {
|
|||||||
@Query("profile") profile: String? = null,
|
@Query("profile") profile: String? = null,
|
||||||
): Response<SessionDetailResponse>
|
): 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")
|
@GET("api/model/info")
|
||||||
suspend fun getModelInfo(): Response<ModelInfoResponse>
|
suspend fun getModelInfo(): Response<ModelInfoResponse>
|
||||||
|
|
||||||
@@ -618,6 +626,9 @@ interface HermesApiService {
|
|||||||
@GET("api/dashboard/plugins/hub")
|
@GET("api/dashboard/plugins/hub")
|
||||||
suspend fun getPlugins(): Response<PluginsHubResponse>
|
suspend fun getPlugins(): Response<PluginsHubResponse>
|
||||||
|
|
||||||
|
@GET("api/dashboard/plugins/catalog")
|
||||||
|
suspend fun getPluginCatalog(): Response<PluginCatalogResponse>
|
||||||
|
|
||||||
@POST("api/dashboard/agent-plugins/install")
|
@POST("api/dashboard/agent-plugins/install")
|
||||||
suspend fun installPlugin(
|
suspend fun installPlugin(
|
||||||
@Body body: AgentPluginInstallBody,
|
@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" -> {
|
"message.complete" -> {
|
||||||
val text = payload?.get("text") as? String ?: ""
|
val text = payload?.get("text") as? String ?: ""
|
||||||
val reasoning = payload?.get("reasoning") as? String
|
val reasoning = payload?.get("reasoning") as? String
|
||||||
WsEvent.MessageComplete(text, sessionId, reasoning)
|
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
"message.done" -> {
|
"message.done" -> {
|
||||||
@@ -341,6 +341,43 @@ object EventParser {
|
|||||||
WsEvent.SecretExpire(requestId, sessionId)
|
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 -> {
|
else -> {
|
||||||
Log.w(TAG, "Unknown event type: $eventType")
|
Log.w(TAG, "Unknown event type: $eventType")
|
||||||
WsEvent.Unknown(rawJson)
|
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.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
@@ -25,9 +27,12 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -62,34 +67,48 @@ enum class ConnectionStatus {
|
|||||||
object HermesWsClient {
|
object HermesWsClient {
|
||||||
private const val TAG = "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 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 MAX_BACKOFF_MS = 30_000L
|
||||||
private const val BACKOFF_MULTIPLIER = 2.0
|
private const val BACKOFF_MULTIPLIER = 2.0
|
||||||
|
private var testBackoffOverrideMs: Long? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test hook: the initial reconnect backoff, overridable so reconnect
|
* Test hook: the initial reconnect backoff, overridable so reconnect
|
||||||
* tests are deterministic instead of racing real time (CI runners
|
* 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
|
@VisibleForTesting
|
||||||
internal fun setReconnectBackoffForTest(initialMillis: Long) {
|
internal fun setReconnectBackoffForTest(initialMillis: Long) {
|
||||||
initialBackoffMs = initialMillis
|
initialBackoffMs = initialMillis
|
||||||
currentBackoff = 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 MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
|
||||||
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No inbound frame for this long means the link is dead even though the
|
* Liveness thresholds aligned with reference clients (desktop/web):
|
||||||
* TCP session may still look open (silent NAT/VPN drop). OkHttp pings
|
* Ping every 15s using gateway.ping; 45s inbound silence deadline.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
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) ────────
|
// ── Internal state (all access through synchronized / atomic) ────────
|
||||||
|
|
||||||
@@ -150,7 +169,7 @@ object HermesWsClient {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
val isHealthy: Boolean
|
val isHealthy: Boolean
|
||||||
get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
|
get() = isConnected && (monotonicTimeMs() - lastPongTimestamp < STALE_THRESHOLD_MS)
|
||||||
|
|
||||||
// ── Observable latency tracking (issue #1017) ─────────────────────────
|
// ── Observable latency tracking (issue #1017) ─────────────────────────
|
||||||
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
|
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
|
||||||
@@ -162,14 +181,14 @@ object HermesWsClient {
|
|||||||
|
|
||||||
private fun startHealthTracking() {
|
private fun startHealthTracking() {
|
||||||
healthJob?.cancel()
|
healthJob?.cancel()
|
||||||
lastPongTimestamp = System.currentTimeMillis()
|
lastPongTimestamp = monotonicTimeMs()
|
||||||
healthJob =
|
healthJob =
|
||||||
wsScope.launch {
|
wsScope.launch {
|
||||||
while (connected.get()) {
|
while (connected.get()) {
|
||||||
delay(30_000L)
|
delay(HEARTBEAT_INTERVAL_MS)
|
||||||
if (connected.get()) {
|
if (connected.get()) {
|
||||||
try {
|
try {
|
||||||
ping(timeoutMs = 10_000L)
|
ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Health check ping failed: ${e.message}")
|
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).
|
* 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].
|
* Updates [lastLatencyMs] and [lastPongTimestamp].
|
||||||
*/
|
*/
|
||||||
suspend fun ping(timeoutMs: Long = 5_000L): Long {
|
suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long {
|
||||||
val start = System.currentTimeMillis()
|
val start = monotonicTimeMs()
|
||||||
request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await()
|
request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await()
|
||||||
val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L)
|
val latency = (monotonicTimeMs() - start).coerceAtLeast(0L)
|
||||||
lastPongTimestamp = System.currentTimeMillis()
|
lastPongTimestamp = monotonicTimeMs()
|
||||||
_lastLatencyMs.value = latency
|
_lastLatencyMs.value = latency
|
||||||
return latency
|
return latency
|
||||||
}
|
}
|
||||||
@@ -207,7 +227,7 @@ object HermesWsClient {
|
|||||||
*/
|
*/
|
||||||
private fun runHealthCheckPass(): Boolean {
|
private fun runHealthCheckPass(): Boolean {
|
||||||
if (!connected.get()) return false
|
if (!connected.get()) return false
|
||||||
val staleMs = System.currentTimeMillis() - lastPongTimestamp
|
val staleMs = monotonicTimeMs() - lastPongTimestamp
|
||||||
if (staleMs <= STALE_THRESHOLD_MS) return true
|
if (staleMs <= STALE_THRESHOLD_MS) return true
|
||||||
Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
|
Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
|
||||||
synchronized(outboundLock) {
|
synchronized(outboundLock) {
|
||||||
@@ -220,11 +240,11 @@ object HermesWsClient {
|
|||||||
/**
|
/**
|
||||||
* Test hook: backdate liveness past the staleness threshold and force one
|
* Test hook: backdate liveness past the staleness threshold and force one
|
||||||
* synchronous watchdog pass, so reconnect tests are deterministic instead
|
* synchronous watchdog pass, so reconnect tests are deterministic instead
|
||||||
* of racing the real 30s/90s cadence.
|
* of racing the real 15s/45s cadence.
|
||||||
*/
|
*/
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
internal fun forceHealthCheckForTest(staleMillis: Long) {
|
internal fun forceHealthCheckForTest(staleMillis: Long) {
|
||||||
lastPongTimestamp = System.currentTimeMillis() - staleMillis
|
lastPongTimestamp = monotonicTimeMs() - staleMillis
|
||||||
runHealthCheckPass()
|
runHealthCheckPass()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +258,7 @@ object HermesWsClient {
|
|||||||
|
|
||||||
private val parsedEvents =
|
private val parsedEvents =
|
||||||
MutableSharedFlow<WsEvent>(
|
MutableSharedFlow<WsEvent>(
|
||||||
extraBufferCapacity = 512,
|
extraBufferCapacity = 2048,
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,9 +366,34 @@ object HermesWsClient {
|
|||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
val isConnected: Boolean get() = connected.get()
|
val isConnected: Boolean get() = connected.get()
|
||||||
|
|
||||||
|
private var foregroundProbeJob: Job? = null
|
||||||
|
|
||||||
fun setAppForeground(foreground: Boolean) {
|
fun setAppForeground(foreground: Boolean) {
|
||||||
appInForeground.set(foreground)
|
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() {
|
fun acquireExternalActivityConnectionLease() {
|
||||||
@@ -629,6 +674,8 @@ object HermesWsClient {
|
|||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
outboundDrainJob?.cancel()
|
outboundDrainJob?.cancel()
|
||||||
outboundDrainJob = null
|
outboundDrainJob = null
|
||||||
|
foregroundProbeJob?.cancel()
|
||||||
|
foregroundProbeJob = null
|
||||||
stopHealthTracking()
|
stopHealthTracking()
|
||||||
webSocket?.close(1000, "Client closed")
|
webSocket?.close(1000, "Client closed")
|
||||||
webSocket = null
|
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. */
|
/** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */
|
||||||
class HermesRpcException(
|
class HermesRpcException(
|
||||||
message: String,
|
message: String,
|
||||||
|
val code: Int = 0,
|
||||||
|
val data: JsonElement? = null,
|
||||||
) : Exception(message)
|
) : Exception(message)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -697,6 +746,15 @@ object HermesWsClient {
|
|||||||
send(method, params) { reqId ->
|
send(method, params) { reqId ->
|
||||||
pendingCalls[reqId] = PendingCall(method, deferred)
|
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).
|
// Arm the per-request timeout (fires if the server never answers).
|
||||||
pendingCalls[id]?.timeoutJob =
|
pendingCalls[id]?.timeoutJob =
|
||||||
wsScope.launch {
|
wsScope.launch {
|
||||||
@@ -716,7 +774,13 @@ object HermesWsClient {
|
|||||||
removeQueuedMessage(id)
|
removeQueuedMessage(id)
|
||||||
call.timeoutJob?.cancel()
|
call.timeoutJob?.cancel()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
call.deferred.completeExceptionally(HermesRpcException(error.message))
|
call.deferred.completeExceptionally(
|
||||||
|
HermesRpcException(
|
||||||
|
message = error.message,
|
||||||
|
code = error.code,
|
||||||
|
data = error.data,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
call.deferred.complete(result)
|
call.deferred.complete(result)
|
||||||
}
|
}
|
||||||
@@ -764,7 +828,9 @@ object HermesWsClient {
|
|||||||
params = decoratedParams.mapValues { it.value.toJsonElement() },
|
params = decoratedParams.mapValues { it.value.toJsonElement() },
|
||||||
)
|
)
|
||||||
val json = OkHttpProvider.json.encodeToString(request)
|
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
|
var reconnect = false
|
||||||
synchronized(outboundLock) {
|
synchronized(outboundLock) {
|
||||||
if (method == WsMethods.PROMPT_SUBMIT) {
|
if (method == WsMethods.PROMPT_SUBMIT) {
|
||||||
@@ -890,21 +956,23 @@ object HermesWsClient {
|
|||||||
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
|
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
|
||||||
|
|
||||||
private fun triggerReplay() {
|
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
|
if (lastSeenSeq.isEmpty() || replayInFlight) return
|
||||||
replayJob?.cancel()
|
|
||||||
replayJob =
|
|
||||||
wsScope.launch {
|
|
||||||
fetchReplay()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchReplay() {
|
|
||||||
if (replayInFlight || lastSeenSeq.isEmpty()) return
|
|
||||||
replayInFlight = true
|
replayInFlight = true
|
||||||
val sessionsToReplay = lastSeenSeq.keys().toList()
|
val sessionsToReplay = lastSeenSeq.keys().toList()
|
||||||
for (sid in sessionsToReplay) {
|
for (sid in sessionsToReplay) {
|
||||||
replayHold[sid] = Collections.synchronizedList(mutableListOf())
|
replayHold[sid] = Collections.synchronizedList(mutableListOf())
|
||||||
}
|
}
|
||||||
|
replayJob?.cancel()
|
||||||
|
replayJob =
|
||||||
|
wsScope.launch {
|
||||||
|
fetchReplay(sessionsToReplay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchReplay(sessionsToReplay: List<String> = lastSeenSeq.keys().toList()) {
|
||||||
try {
|
try {
|
||||||
for (sid in sessionsToReplay) {
|
for (sid in sessionsToReplay) {
|
||||||
val lastSeen = lastSeenSeq[sid] ?: continue
|
val lastSeen = lastSeenSeq[sid] ?: continue
|
||||||
@@ -926,13 +994,21 @@ object HermesWsClient {
|
|||||||
}
|
}
|
||||||
if (resultMap != null) {
|
if (resultMap != null) {
|
||||||
val epoch = resultMap["epoch"] as? String
|
val epoch = resultMap["epoch"] as? String
|
||||||
if (!epoch.isNullOrEmpty()) {
|
val epochChanged =
|
||||||
if (replayEpoch != null && replayEpoch != epoch) {
|
!epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
|
||||||
replayEpoch = epoch
|
val truncated = resultMap["truncated"] == true
|
||||||
lastSeenSeq.clear()
|
val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt()
|
||||||
continue
|
|
||||||
}
|
if (epochChanged) replayEpoch = epoch
|
||||||
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?>>()
|
val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance<Map<String, Any?>>()
|
||||||
if (eventsList != null) {
|
if (eventsList != null) {
|
||||||
@@ -962,25 +1038,28 @@ object HermesWsClient {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Replay failed for session $sid: ${e.message}")
|
Log.w(TAG, "Replay failed for session $sid: ${e.message}")
|
||||||
|
parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
for (sid in sessionsToReplay) {
|
withContext(NonCancellable) {
|
||||||
val held = replayHold.remove(sid)
|
for (sid in sessionsToReplay) {
|
||||||
if (held != null) {
|
val held = replayHold.remove(sid)
|
||||||
synchronized(held) {
|
if (held != null) {
|
||||||
for ((heldSeq, heldEvent) in held) {
|
synchronized(held) {
|
||||||
if (heldSeq != null) {
|
for ((heldSeq, heldEvent) in held) {
|
||||||
val prev = lastSeenSeq[sid] ?: 0
|
if (heldSeq != null) {
|
||||||
if (heldSeq <= prev) continue
|
val prev = lastSeenSeq[sid] ?: 0
|
||||||
lastSeenSeq[sid] = heldSeq
|
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
|
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val reconnectDelay = currentBackoff
|
val reconnectDelay = nextBackoffDelayMs()
|
||||||
currentBackoff =
|
currentBackoff =
|
||||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||||
.toLong()
|
.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 ─────────────────────────────────────────────────────────
|
// ── Listener ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private class WsListenerImpl(
|
private class WsListenerImpl(
|
||||||
@@ -1126,7 +1213,7 @@ object HermesWsClient {
|
|||||||
markQueuedMessageSent(msg)
|
markQueuedMessageSent(msg)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
|
||||||
if (!webSocket.send(msg)) {
|
if (!webSocket.send(msg)) {
|
||||||
recoverRejectedSocket(webSocket)
|
recoverRejectedSocket(webSocket)
|
||||||
break
|
break
|
||||||
@@ -1144,8 +1231,8 @@ object HermesWsClient {
|
|||||||
text: String,
|
text: String,
|
||||||
) {
|
) {
|
||||||
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
|
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
|
||||||
lastPongTimestamp = System.currentTimeMillis()
|
lastPongTimestamp = monotonicTimeMs()
|
||||||
// Resolve any in-flight `request()` awaiting this RPC result/error
|
// Resolve any in-flight `request()` awaiting this RPC result/error
|
||||||
// (issue #526) before fanning the parsed event out to collectors.
|
// (issue #526) before fanning the parsed event out to collectors.
|
||||||
val event =
|
val event =
|
||||||
@@ -1173,6 +1260,7 @@ object HermesWsClient {
|
|||||||
if (!sid.isNullOrBlank() && seq != null) {
|
if (!sid.isNullOrBlank() && seq != null) {
|
||||||
val prev = lastSeenSeq[sid] ?: 0
|
val prev = lastSeenSeq[sid] ?: 0
|
||||||
if (seq <= prev) {
|
if (seq <= prev) {
|
||||||
|
// Frame is already at or behind watermark — drop duplicate
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val parsed = EventParser.parse(rpc, text)
|
val parsed = EventParser.parse(rpc, text)
|
||||||
@@ -1203,7 +1291,7 @@ object HermesWsClient {
|
|||||||
parsed
|
parsed
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to parse message", e)
|
Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}")
|
||||||
WsEvent.Unknown(text)
|
WsEvent.Unknown(text)
|
||||||
}
|
}
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -1219,17 +1307,17 @@ object HermesWsClient {
|
|||||||
pendingPromptSubmits.isEmpty()
|
pendingPromptSubmits.isEmpty()
|
||||||
) {
|
) {
|
||||||
pendingReply = false
|
pendingReply = false
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
removeQueuedMessage(event.id)
|
removeQueuedMessage(event.id)
|
||||||
resolvePending(event.id, null, event.error)
|
resolvePending(event.id, null, event.error)
|
||||||
disconnectIfIdleInBackground()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
// tryEmit on a DROP_OLDEST flow only returns false when the
|
// 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.
|
// and always-on init collectors this is unreachable in practice.
|
||||||
parsedEvents.tryEmit(event)
|
parsedEvents.tryEmit(event)
|
||||||
}
|
}
|
||||||
@@ -1247,10 +1335,7 @@ object HermesWsClient {
|
|||||||
closingSocket = webSocket
|
closingSocket = webSocket
|
||||||
// Do NOT log [reason] — it may carry server-side context.
|
// Do NOT log [reason] — it may carry server-side context.
|
||||||
Log.d(TAG, "WebSocket closing: $code")
|
Log.d(TAG, "WebSocket closing: $code")
|
||||||
if (code == 4001 || code == 4401 ||
|
if (isTerminalAuthClose(code, reason)) {
|
||||||
reason.contains("unauthorized", ignoreCase = true) ||
|
|
||||||
reason.startsWith("auth:", ignoreCase = true)
|
|
||||||
) {
|
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
}
|
}
|
||||||
webSocket.close(code, reason)
|
webSocket.close(code, reason)
|
||||||
@@ -1275,10 +1360,7 @@ object HermesWsClient {
|
|||||||
connected.set(false)
|
connected.set(false)
|
||||||
ActiveSessionHolder.clear()
|
ActiveSessionHolder.clear()
|
||||||
stopHealthTracking()
|
stopHealthTracking()
|
||||||
if (code == 4001 || code == 4401 ||
|
if (isTerminalAuthClose(code, reason)) {
|
||||||
reason.contains("unauthorized", ignoreCase = true) ||
|
|
||||||
reason.startsWith("auth:", ignoreCase = true)
|
|
||||||
) {
|
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
@@ -1307,9 +1389,11 @@ object HermesWsClient {
|
|||||||
ActiveSessionHolder.clear()
|
ActiveSessionHolder.clear()
|
||||||
stopHealthTracking()
|
stopHealthTracking()
|
||||||
val code = response?.code ?: 0
|
val code = response?.code ?: 0
|
||||||
if (code == 401 || t.message?.contains(
|
if (code == 401 || code == 4401 || code == 4403 ||
|
||||||
"401",
|
t.message?.contains("401") == true ||
|
||||||
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
|
t.message?.contains("4401") == true ||
|
||||||
|
t.message?.contains("4403") == true ||
|
||||||
|
t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||||
) {
|
) {
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
} else if (_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,
|
val reasoning: String? = null,
|
||||||
/** Stored session id captured before background disconnect clears the active mapping. */
|
/** Stored session id captured before background disconnect clears the active mapping. */
|
||||||
val storedSessionId: String? = null,
|
val storedSessionId: String? = null,
|
||||||
|
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
|
||||||
|
val rawPayload: Map<String, Any?>? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class MessageDone(
|
data class MessageDone(
|
||||||
@@ -301,6 +303,44 @@ sealed class WsEvent {
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
) : WsEvent()
|
) : 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 ───────────────────────────────────────────
|
// ── Gateway-level errors ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -355,6 +395,13 @@ sealed class WsEvent {
|
|||||||
val kind: String = "",
|
val kind: String = "",
|
||||||
) : WsEvent()
|
) : 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 ─────────────────────────────────────────────────────────
|
// ── Fallback ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class Unknown(
|
data class Unknown(
|
||||||
|
|||||||
@@ -43,11 +43,24 @@ object WsMethods {
|
|||||||
const val SUDO_RESPOND = "sudo.respond"
|
const val SUDO_RESPOND = "sudo.respond"
|
||||||
const val SECRET_RESPOND = "secret.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) ──────────────────────────────────
|
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
|
||||||
|
|
||||||
/** Ultra-lightweight liveness check and round-trip latency measurement. */
|
/** Ultra-lightweight liveness check and round-trip latency measurement. */
|
||||||
const val PING = "ping"
|
const val PING = "ping"
|
||||||
|
|
||||||
|
/** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */
|
||||||
|
const val GATEWAY_PING = "gateway.ping"
|
||||||
|
|
||||||
// ── Commands catalog ──────────────────────────────────────────────────
|
// ── Commands catalog ──────────────────────────────────────────────────
|
||||||
const val COMMANDS_CATALOG = "commands.catalog"
|
const val COMMANDS_CATALOG = "commands.catalog"
|
||||||
const val COMMAND_DISPATCH = "command.dispatch"
|
const val COMMAND_DISPATCH = "command.dispatch"
|
||||||
@@ -70,6 +83,22 @@ object WsMethods {
|
|||||||
/** Kill a single background process (scoped to the active session). */
|
/** Kill a single background process (scoped to the active session). */
|
||||||
const val PROCESS_KILL = "process.kill"
|
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) ─────────────────────────────
|
// ── Billing / subscription (issue #628) ─────────────────────────────
|
||||||
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
||||||
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ class ChatNotificationService : Service() {
|
|||||||
showReplyNotification(preview.take(100), null)
|
showReplyNotification(preview.take(100), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.VaultUnlockRequest,
|
||||||
|
is WsEvent.VaultSaveLoginRequest,
|
||||||
|
is WsEvent.VaultCodeRequest,
|
||||||
|
-> {
|
||||||
|
showReplyNotification(getString(R.string.notif_input_needed), null)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ data class GroupChatMessage(
|
|||||||
val isSystem: Boolean = false,
|
val isSystem: Boolean = false,
|
||||||
val isPass: Boolean = false,
|
val isPass: Boolean = false,
|
||||||
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
||||||
|
val tokenCount: Int? = null,
|
||||||
|
val tps: Double? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
|
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.JsonPrimitive
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
data class GroupChatUiState(
|
data class GroupChatUiState(
|
||||||
val groupName: String = "",
|
val groupName: String = "",
|
||||||
@@ -74,22 +75,22 @@ class GroupChatViewModel(
|
|||||||
private var activeEpoch: String = ""
|
private var activeEpoch: String = ""
|
||||||
|
|
||||||
// Map of bot.name -> seen count of non-system messages
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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)
|
// 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 {
|
init {
|
||||||
if (groupName.isNotBlank()) {
|
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),
|
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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
|
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] —
|
* message whose per-turn tool-call count is a multiple of [TOOL_CALL_DIVIDER_INTERVAL] —
|
||||||
* mapped to the per-turn count at that point.
|
* 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.
|
* milestones read `5, 10, 15...` within a turn and restart at `5` on the next.
|
||||||
*/
|
*/
|
||||||
fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
|
fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
|
||||||
val milestones = mutableMapOf<Int, Int>()
|
val milestones = mutableMapOf<String, Int>()
|
||||||
var toolCount = 0
|
var toolCount = 0
|
||||||
messages.forEachIndexed { index, message ->
|
messages.forEach { message ->
|
||||||
when (message.role) {
|
when {
|
||||||
MessageRole.USER -> {
|
message.isUserTurnBoundary() -> {
|
||||||
toolCount = 0
|
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
|
toolCount += 1
|
||||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||||
milestones[index] = toolCount
|
milestones[index] = toolCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return milestones
|
return milestones
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ data class ChatMessage(
|
|||||||
* When present, rendered at the bottom beside the copy button.
|
* When present, rendered at the bottom beside the copy button.
|
||||||
*/
|
*/
|
||||||
val finishTimestamp: Long? = null,
|
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 logs: List<SubagentLogLine> = emptyList(),
|
||||||
val durationSeconds: Double? = null,
|
val durationSeconds: Double? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
|
val lastEventTimestamp: Long = 0L,
|
||||||
) {
|
) {
|
||||||
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
||||||
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
||||||
@@ -133,6 +138,20 @@ data class SubagentIndicator(
|
|||||||
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
|
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 {
|
enum class MessageRole {
|
||||||
USER,
|
USER,
|
||||||
ASSISTANT,
|
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
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ContextWrapper
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.speech.RecognizerIntent
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -50,6 +50,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -64,14 +65,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -82,13 +82,11 @@ import com.m57.hermescontrol.NavigationController
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
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.ChatConnectionBanner
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
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.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
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.SideQuestionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
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.rememberChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||||
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
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.AutoScrollingTitleText
|
||||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
|
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.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
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
|
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
@@ -159,10 +154,60 @@ fun ChatScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
val credentialWarning by HermesWsClient.credentialWarning.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
|
// Snapshot-backed search state — read directly so only the scopes that
|
||||||
// read its fields recompose on search changes (bar, matched bubbles).
|
// read its fields recompose on search changes (bar, matched bubbles).
|
||||||
val searchState = viewModel.searchState
|
val searchState = viewModel.searchState
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
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 listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||||
@@ -304,14 +349,17 @@ fun ChatScreen(
|
|||||||
viewModel.consumeComposerTextRestore()
|
viewModel.consumeComposerTextRestore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var isListening by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(showSubagentInspectionSheet) {
|
||||||
|
if (showSubagentInspectionSheet) {
|
||||||
|
viewModel.hydrateSubagents()
|
||||||
|
}
|
||||||
|
}
|
||||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val context = LocalContext.current
|
|
||||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||||
@@ -322,137 +370,63 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val micListeningPrompt = stringResource(R.string.chat_mic_listening)
|
val invalidResponseError = stringResource(R.string.session_integrations_err_invalid_response)
|
||||||
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
|
val browserLaunchError = stringResource(R.string.session_integrations_err_browser_launch)
|
||||||
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
|
|
||||||
|
|
||||||
// Speech-to-text recognition launcher (issue #194)
|
LaunchedEffect(browserEvent) {
|
||||||
val speechLauncher =
|
if (browserEvent != null) {
|
||||||
rememberLauncherForActivityResult(
|
val eventId = browserEvent.eventId
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
val taken = connectorsViewModel.takeBrowserEvent(eventId)
|
||||||
) { result ->
|
if (taken != null) {
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
if (!ConnectorUrlValidator.isValidHttpsUrl(taken.url)) {
|
||||||
isListening = false
|
connectorsViewModel.launchError(invalidResponseError)
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
return@LaunchedEffect
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
val fileName =
|
val intent =
|
||||||
"photo_${
|
Intent(Intent.ACTION_VIEW, Uri.parse(taken.url)).apply {
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
Date(),
|
}
|
||||||
)
|
launchExternalActivity {
|
||||||
}.jpg"
|
browserAuthDeparted = false
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
browserAuthInFlight = true
|
||||||
val size = inputStream?.use { it.available().toLong() } ?: 0L
|
context.startActivity(intent)
|
||||||
viewModel.addAttachment(uri.toString(), fileName, "image/jpeg", size)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("ChatScreen", "Camera capture failed", e)
|
|
||||||
scrollScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
cameraErrorMsg,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} 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
|
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||||
ChatLifecycleEffects(
|
ChatLifecycleEffects(
|
||||||
@@ -553,6 +527,39 @@ fun ChatScreen(
|
|||||||
contentDescription = stringResource(R.string.content_desc_new_chat),
|
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(
|
Column(
|
||||||
@@ -592,80 +599,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// Issue #890: launch update check — non-blocking banner when a
|
// Issue #890: launch update check — non-blocking banner when a
|
||||||
// newer release exists. Tapping "Update" opens the in-place dialog.
|
// newer release exists. Tapping "Update" opens the in-place dialog.
|
||||||
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
ChatAppUpdateSection()
|
||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = searchState.isActive,
|
visible = searchState.isActive,
|
||||||
@@ -751,6 +685,15 @@ fun ChatScreen(
|
|||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
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,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
openingAttachmentPath = state.openingAttachmentPath,
|
openingAttachmentPath = state.openingAttachmentPath,
|
||||||
@@ -808,75 +751,16 @@ fun ChatScreen(
|
|||||||
// Jump to bottom after send (serialized through the controller).
|
// Jump to bottom after send (serialized through the controller).
|
||||||
scrollController.jumpToBottom(animated = true)
|
scrollController.jumpToBottom(animated = true)
|
||||||
},
|
},
|
||||||
onMicTap = {
|
onMicTap = mediaLaunchers.onMicTap,
|
||||||
if (isListening) {
|
isListening = mediaLaunchers.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,
|
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
slashUsageCounts = state.slashUsageCounts,
|
slashUsageCounts = state.slashUsageCounts,
|
||||||
pendingAttachments = state.pendingAttachments,
|
pendingAttachments = state.pendingAttachments,
|
||||||
onCameraTap = {
|
onCameraTap = mediaLaunchers.onCameraTap,
|
||||||
try {
|
onImageTap = mediaLaunchers.onImageTap,
|
||||||
val timeStamp =
|
onFileTap = mediaLaunchers.onFileTap,
|
||||||
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("*/*")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRemoveAttachment = viewModel::removeAttachment,
|
onRemoveAttachment = viewModel::removeAttachment,
|
||||||
onPreviewAttachment = { attachment ->
|
onPreviewAttachment = { attachment ->
|
||||||
if (attachment.isImage) {
|
if (attachment.isImage) {
|
||||||
@@ -895,6 +779,10 @@ fun ChatScreen(
|
|||||||
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
||||||
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
||||||
supportsReasoning = state.currentModelCapabilities?.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(
|
SubagentInspectionSheet(
|
||||||
indicators = state.subagentIndicators,
|
indicators = state.subagentIndicators,
|
||||||
todos = state.todos,
|
todos = state.todos,
|
||||||
|
inspectingSubagentId = state.inspectingSubagentId,
|
||||||
|
subagentTranscript = state.subagentTranscript,
|
||||||
|
onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) },
|
||||||
|
onRetryTranscript = { viewModel.retrySubagentTranscript() },
|
||||||
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
|
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
|
||||||
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
|
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
|
||||||
onDismiss = { showSubagentInspectionSheet = false },
|
onDismiss = {
|
||||||
|
showSubagentInspectionSheet = false
|
||||||
|
viewModel.closeSubagentTranscript()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,5 +895,14 @@ fun ChatScreen(
|
|||||||
onDismiss = { viewingImage = null },
|
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(
|
mapped.add(
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
id = restId,
|
id = restId,
|
||||||
@@ -182,6 +183,7 @@ internal fun mapServerMessages(
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
displayKind = msg.display_kind,
|
displayKind = msg.display_kind,
|
||||||
|
tokenCount = tokenCount,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds chat message-streaming state and logic, extracted from [ChatViewModel]
|
* Holds chat message-streaming state and logic, extracted from [ChatViewModel]
|
||||||
@@ -27,14 +30,23 @@ class ChatStreamingController(
|
|||||||
private val isCurrentSession: (String?) -> Boolean,
|
private val isCurrentSession: (String?) -> Boolean,
|
||||||
private val isTestEnvironment: () -> Boolean,
|
private val isTestEnvironment: () -> Boolean,
|
||||||
) {
|
) {
|
||||||
|
private companion object {
|
||||||
|
const val FLUSH_INTERVAL_MS = 33L
|
||||||
|
}
|
||||||
|
|
||||||
private val streamingBuffer = java.lang.StringBuilder()
|
private val streamingBuffer = java.lang.StringBuilder()
|
||||||
private var lastFlushMs = 0L
|
private var lastFlushMs = 0L
|
||||||
|
private var trailingMessageFlushJob: Job? = null
|
||||||
|
|
||||||
private val thinkingBuffer = java.lang.StringBuilder()
|
private val thinkingBuffer = java.lang.StringBuilder()
|
||||||
private var lastThinkingFlushMs = 0L
|
private var lastThinkingFlushMs = 0L
|
||||||
|
private var trailingThinkingFlushJob: Job? = null
|
||||||
|
|
||||||
private val reasoningBuffer = java.lang.StringBuilder()
|
private val reasoningBuffer = java.lang.StringBuilder()
|
||||||
private var lastReasoningFlushMs = 0L
|
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
|
* Resets all streaming buffers. Centralizes the clear logic that was
|
||||||
@@ -42,6 +54,9 @@ class ChatStreamingController(
|
|||||||
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
|
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
|
||||||
*/
|
*/
|
||||||
fun resetStreaming() {
|
fun resetStreaming() {
|
||||||
|
trailingMessageFlushJob?.cancel()
|
||||||
|
trailingThinkingFlushJob?.cancel()
|
||||||
|
trailingReasoningFlushJob?.cancel()
|
||||||
flushReasoning()
|
flushReasoning()
|
||||||
streamingBuffer.clear()
|
streamingBuffer.clear()
|
||||||
thinkingBuffer.clear()
|
thinkingBuffer.clear()
|
||||||
@@ -73,6 +88,9 @@ class ChatStreamingController(
|
|||||||
* buffer-only clear is safe and prevents stale deltas from re-flushing.
|
* buffer-only clear is safe and prevents stale deltas from re-flushing.
|
||||||
*/
|
*/
|
||||||
fun clearStreamingBuffers() {
|
fun clearStreamingBuffers() {
|
||||||
|
trailingMessageFlushJob?.cancel()
|
||||||
|
trailingThinkingFlushJob?.cancel()
|
||||||
|
trailingReasoningFlushJob?.cancel()
|
||||||
streamingBuffer.clear()
|
streamingBuffer.clear()
|
||||||
thinkingBuffer.clear()
|
thinkingBuffer.clear()
|
||||||
reasoningBuffer.clear()
|
reasoningBuffer.clear()
|
||||||
@@ -94,6 +112,7 @@ class ChatStreamingController(
|
|||||||
* synchronously so the finalized message carries the latest reasoning.
|
* synchronously so the finalized message carries the latest reasoning.
|
||||||
*/
|
*/
|
||||||
fun flushPendingReasoning() {
|
fun flushPendingReasoning() {
|
||||||
|
trailingReasoningFlushJob?.cancel()
|
||||||
flushReasoning()
|
flushReasoning()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +130,10 @@ class ChatStreamingController(
|
|||||||
* tokens synchronously first makes the sealed orphan complete.
|
* tokens synchronously first makes the sealed orphan complete.
|
||||||
*/
|
*/
|
||||||
fun flushPendingTokens() {
|
fun flushPendingTokens() {
|
||||||
|
trailingMessageFlushJob?.cancel()
|
||||||
val currentContent = streamingBuffer.toString()
|
val currentContent = streamingBuffer.toString()
|
||||||
if (currentContent.isEmpty()) return
|
if (currentContent.isEmpty()) return
|
||||||
|
lastFlushMs = nowMs()
|
||||||
streamingState.update { state ->
|
streamingState.update { state ->
|
||||||
val current = state.streamingMessage ?: return@update state
|
val current = state.streamingMessage ?: return@update state
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -130,63 +151,97 @@ class ChatStreamingController(
|
|||||||
if (!isCurrentSession(event.sessionId)) return
|
if (!isCurrentSession(event.sessionId)) return
|
||||||
|
|
||||||
streamingBuffer.append(event.token)
|
streamingBuffer.append(event.token)
|
||||||
val now = System.currentTimeMillis()
|
val now = nowMs()
|
||||||
|
|
||||||
// Always flush in tests, or if enough time has passed
|
// 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) {
|
if (shouldFlush) {
|
||||||
val currentContent = streamingBuffer.toString()
|
trailingMessageFlushJob?.cancel()
|
||||||
lastFlushMs = now
|
flushTokens(now)
|
||||||
streamingState.update { state ->
|
} else {
|
||||||
val current = state.streamingMessage
|
scheduleTrailingMessageFlush()
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
|
||||||
if (!isCurrentSession(event.sessionId)) return
|
if (!isCurrentSession(event.sessionId)) return
|
||||||
|
|
||||||
thinkingBuffer.append(event.token)
|
thinkingBuffer.append(event.token)
|
||||||
val now = System.currentTimeMillis()
|
val now = nowMs()
|
||||||
|
|
||||||
// Always flush in tests, or if enough time has passed
|
// Always flush in tests, or if enough time has passed
|
||||||
val shouldFlush =
|
val shouldFlush =
|
||||||
(now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment()
|
(now - lastThinkingFlushMs >= FLUSH_INTERVAL_MS) || lastThinkingFlushMs == 0L || isTestEnvironment()
|
||||||
if (shouldFlush) {
|
if (shouldFlush) {
|
||||||
val currentContent = thinkingBuffer.toString()
|
trailingThinkingFlushJob?.cancel()
|
||||||
lastThinkingFlushMs = now
|
flushThinking(now)
|
||||||
streamingState.update { state ->
|
} else {
|
||||||
state.copy(
|
scheduleTrailingThinkingFlush()
|
||||||
isThinking = true,
|
}
|
||||||
thinkingText = currentContent,
|
}
|
||||||
)
|
|
||||||
|
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
|
if (!isCurrentSession(event.sessionId)) return
|
||||||
|
|
||||||
reasoningBuffer.append(event.token)
|
reasoningBuffer.append(event.token)
|
||||||
val now = System.currentTimeMillis()
|
val now = nowMs()
|
||||||
|
|
||||||
// Always flush in tests, or if enough time has passed
|
// Always flush in tests, or if enough time has passed
|
||||||
val shouldFlush =
|
val shouldFlush =
|
||||||
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
|
(now - lastReasoningFlushMs >= FLUSH_INTERVAL_MS) || lastReasoningFlushMs == 0L || isTestEnvironment()
|
||||||
if (shouldFlush) {
|
if (shouldFlush) {
|
||||||
|
trailingReasoningFlushJob?.cancel()
|
||||||
flushReasoning(now)
|
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()
|
val currentContent = reasoningBuffer.toString()
|
||||||
if (currentContent.isEmpty()) return
|
if (currentContent.isEmpty()) return
|
||||||
lastReasoningFlushMs = now
|
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.ReviewSummary -> event.sessionId
|
||||||
is WsEvent.BtwComplete -> event.sessionId
|
is WsEvent.BtwComplete -> event.sessionId
|
||||||
is WsEvent.SessionUsage -> 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
|
else -> null
|
||||||
}
|
}
|
||||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||||
@@ -169,11 +176,23 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
|
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
|
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
||||||
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
// Change events (issue #784) are consumed by their screens' ViewModels
|
// Change events (issue #784) are consumed by their screens' ViewModels
|
||||||
is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState)
|
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 ──────────────────────────────────────────────────
|
// ── GatewayReady ──────────────────────────────────────────────────
|
||||||
@@ -217,6 +236,8 @@ object ChatWsEventReducer {
|
|||||||
streamingState.streamingMessage.copy(
|
streamingState.streamingMessage.copy(
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
||||||
|
tps = state.latestTps?.takeIf { it > 0.0 },
|
||||||
)
|
)
|
||||||
orphan = finalized
|
orphan = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -375,17 +396,25 @@ object ChatWsEventReducer {
|
|||||||
streamingState = streamingState.copy(streamingMessage = null),
|
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 =
|
val msg =
|
||||||
streaming?.copy(
|
streaming?.copy(
|
||||||
content = text,
|
content = text,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
) ?: ChatMessage(
|
) ?: ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = text,
|
content = text,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
@@ -431,11 +460,14 @@ object ChatWsEventReducer {
|
|||||||
} else {
|
} else {
|
||||||
streaming.reasoningText
|
streaming.reasoningText
|
||||||
}
|
}
|
||||||
|
val tokenCount = TokenEstimator.estimate(streaming.content).takeIf { it > 0 }
|
||||||
val msg =
|
val msg =
|
||||||
streaming.copy(
|
streaming.copy(
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = streaming.tokenCount ?: tokenCount,
|
||||||
|
tps = streaming.tps ?: state.latestTps?.takeIf { it > 0.0 },
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
@@ -500,6 +532,8 @@ object ChatWsEventReducer {
|
|||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
||||||
|
tps = state.latestTps?.takeIf { it > 0.0 },
|
||||||
)
|
)
|
||||||
orphanToPersist = finalized
|
orphanToPersist = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -912,6 +946,7 @@ object ChatWsEventReducer {
|
|||||||
logs = trimmedLogs,
|
logs = trimmedLogs,
|
||||||
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
|
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
|
||||||
model = model ?: (if (idx >= 0) indicators[idx].model else null),
|
model = model ?: (if (idx >= 0) indicators[idx].model else null),
|
||||||
|
lastEventTimestamp = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
@@ -958,6 +993,9 @@ object ChatWsEventReducer {
|
|||||||
if (snapshot.contextMax != null && snapshot.contextMax > 0L) {
|
if (snapshot.contextMax != null && snapshot.contextMax > 0L) {
|
||||||
updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax)
|
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)
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
@@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
@@ -84,6 +90,7 @@ internal fun composeToolSummaryLines(
|
|||||||
): Pair<String, String?>? {
|
): Pair<String, String?>? {
|
||||||
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
|
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
|
||||||
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
|
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
|
||||||
|
val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: ""
|
||||||
|
|
||||||
val firstLine: String
|
val firstLine: String
|
||||||
val secondLine: String?
|
val secondLine: String?
|
||||||
@@ -93,6 +100,7 @@ internal fun composeToolSummaryLines(
|
|||||||
if (subtitle != null) {
|
if (subtitle != null) {
|
||||||
append(subtitle)
|
append(subtitle)
|
||||||
}
|
}
|
||||||
|
append(statsSuffix)
|
||||||
view.countLabel?.let { append(" ($it)") }
|
view.countLabel?.let { append(" ($it)") }
|
||||||
}
|
}
|
||||||
secondLine = null
|
secondLine = null
|
||||||
@@ -100,6 +108,7 @@ internal fun composeToolSummaryLines(
|
|||||||
firstLine =
|
firstLine =
|
||||||
buildString {
|
buildString {
|
||||||
append(view.title)
|
append(view.title)
|
||||||
|
append(statsSuffix)
|
||||||
view.countLabel?.let { append(" ($it)") }
|
view.countLabel?.let { append(" ($it)") }
|
||||||
}
|
}
|
||||||
secondLine = subtitle
|
secondLine = subtitle
|
||||||
@@ -233,6 +242,11 @@ internal fun ToolBubble(
|
|||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.8f),
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
@@ -301,6 +315,11 @@ internal fun ToolBubble(
|
|||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.8f),
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
@@ -383,6 +402,11 @@ private fun ExpandedToolContent(
|
|||||||
view.stdout?.let {
|
view.stdout?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.9f),
|
color = contentColor.copy(alpha = 0.9f),
|
||||||
@@ -394,6 +418,11 @@ private fun ExpandedToolContent(
|
|||||||
view.stderr?.let {
|
view.stderr?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.6f),
|
color = contentColor.copy(alpha = 0.6f),
|
||||||
@@ -408,13 +437,27 @@ private fun ExpandedToolContent(
|
|||||||
text = stringResource(R.string.chat_tool_exit_code, code),
|
text = stringResource(R.string.chat_tool_exit_code, code),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.labelSmall.copy(
|
MaterialTheme.typography.labelSmall.copy(
|
||||||
color = statusColors.error,
|
color = statusColors.warning,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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 ──
|
// ── File diff ──
|
||||||
if (view.inlineDiff != null) {
|
if (view.inlineDiff != null) {
|
||||||
DiffViewCard(
|
DiffViewCard(
|
||||||
@@ -479,6 +522,11 @@ private fun ExpandedToolContent(
|
|||||||
if (view.detail.isNotBlank() && view.inlineDiff == null) {
|
if (view.detail.isNotBlank() && view.inlineDiff == null) {
|
||||||
Text(
|
Text(
|
||||||
text = view.detail,
|
text = view.detail,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.9f),
|
color = contentColor.copy(alpha = 0.9f),
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = null,
|
summaryArgKey = null,
|
||||||
icon = Icons.Filled.Checklist,
|
icon = Icons.Filled.Checklist,
|
||||||
),
|
),
|
||||||
|
"todo_list" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "todo_list",
|
||||||
|
summaryArgKey = null,
|
||||||
|
icon = Icons.Filled.Checklist,
|
||||||
|
),
|
||||||
"fact_store" to
|
"fact_store" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "fact_store",
|
name = "fact_store",
|
||||||
@@ -150,6 +156,12 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = "action",
|
summaryArgKey = "action",
|
||||||
icon = Icons.Filled.Schedule,
|
icon = Icons.Filled.Schedule,
|
||||||
),
|
),
|
||||||
|
"cronjob_manage" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "cronjob_manage",
|
||||||
|
summaryArgKey = "action",
|
||||||
|
icon = Icons.Filled.Schedule,
|
||||||
|
),
|
||||||
"memory" to
|
"memory" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "memory",
|
name = "memory",
|
||||||
@@ -168,6 +180,12 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = "action",
|
summaryArgKey = "action",
|
||||||
icon = Icons.Filled.Settings,
|
icon = Icons.Filled.Settings,
|
||||||
),
|
),
|
||||||
|
"process_manage" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "process_manage",
|
||||||
|
summaryArgKey = "action",
|
||||||
|
icon = Icons.Filled.Settings,
|
||||||
|
),
|
||||||
"skill_manage" to
|
"skill_manage" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "skill_manage",
|
name = "skill_manage",
|
||||||
@@ -187,6 +205,42 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = null,
|
summaryArgKey = null,
|
||||||
icon = Icons.Filled.Keyboard,
|
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
|
"browser_cdp" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "browser_cdp",
|
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 = {},
|
onReasoningTap: (String?) -> Unit = {},
|
||||||
canDisableReasoning: Boolean? = null,
|
canDisableReasoning: Boolean? = null,
|
||||||
supportsReasoning: 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
|
// 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
|
// 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"),
|
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||||
canDisableReasoning = canDisableReasoning,
|
canDisableReasoning = canDisableReasoning,
|
||||||
supportsReasoning = supportsReasoning,
|
supportsReasoning = supportsReasoning,
|
||||||
|
fastMode = fastMode,
|
||||||
|
fastSupported = fastSupported,
|
||||||
|
isFastModeChanging = isFastModeChanging,
|
||||||
|
onToggleFastMode = onToggleFastMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
|
// 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.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
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.Mic
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
@@ -21,6 +25,8 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -29,8 +35,12 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
@@ -60,6 +70,10 @@ fun ComposerToolbar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
canDisableReasoning: Boolean? = null,
|
canDisableReasoning: Boolean? = null,
|
||||||
supportsReasoning: Boolean? = null,
|
supportsReasoning: Boolean? = null,
|
||||||
|
fastMode: Boolean = false,
|
||||||
|
fastSupported: Boolean = false,
|
||||||
|
isFastModeChanging: Boolean = false,
|
||||||
|
onToggleFastMode: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||||
val reasoningDisabledForModel = supportsReasoning == false
|
val reasoningDisabledForModel = supportsReasoning == false
|
||||||
@@ -109,24 +123,44 @@ fun ComposerToolbar(
|
|||||||
.testTag("model_chip"),
|
.testTag("model_chip"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reasoning chip with dropdown menu (right side, next to mic)
|
// Reasoning & Generation controls with dropdown menu (Option 3)
|
||||||
Box {
|
Box {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = reasoningLevel != null,
|
selected = reasoningLevel != null || fastMode,
|
||||||
onClick = { showReasoningMenu = true },
|
onClick = { showReasoningMenu = true },
|
||||||
enabled = !reasoningDisabledForModel,
|
enabled = isConnected,
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Row(
|
||||||
text =
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (reasoningDisabledForModel) {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
"No reasoning"
|
) {
|
||||||
} else {
|
if (fastMode) {
|
||||||
buildReasoningLabel(reasoningLevel)
|
Icon(
|
||||||
},
|
imageVector = Icons.Filled.Bolt,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
contentDescription = stringResource(R.string.chat_fast_mode_label),
|
||||||
maxLines = 1,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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 =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -137,11 +171,74 @@ fun ComposerToolbar(
|
|||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showReasoningMenu,
|
expanded = showReasoningMenu,
|
||||||
onDismissRequest = { showReasoningMenu = false },
|
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(
|
||||||
text = "Reasoning",
|
text = "REASONING",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
if (canDisable == false) {
|
if (canDisable == false) {
|
||||||
@@ -160,7 +257,6 @@ fun ComposerToolbar(
|
|||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
|
||||||
val allLevels =
|
val allLevels =
|
||||||
listOf(
|
listOf(
|
||||||
"none" to "None",
|
"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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.AltRoute
|
import androidx.compose.material.icons.automirrored.filled.AltRoute
|
||||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
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.HourglassEmpty
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -45,6 +53,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||||
|
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
|
||||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem
|
|||||||
fun SubagentInspectionSheet(
|
fun SubagentInspectionSheet(
|
||||||
indicators: List<SubagentIndicator> = emptyList(),
|
indicators: List<SubagentIndicator> = emptyList(),
|
||||||
todos: List<TodoItem> = emptyList(),
|
todos: List<TodoItem> = emptyList(),
|
||||||
|
inspectingSubagentId: String? = null,
|
||||||
|
subagentTranscript: SubagentTranscriptUiState? = null,
|
||||||
|
onToggleTranscript: ((String) -> Unit)? = null,
|
||||||
|
onRetryTranscript: (() -> Unit)? = null,
|
||||||
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
|
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
|
||||||
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
|
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -197,7 +211,7 @@ fun SubagentInspectionSheet(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "SUBAGENTS",
|
text = stringResource(R.string.subagent_subagents_header).uppercase(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
@@ -211,8 +225,14 @@ fun SubagentInspectionSheet(
|
|||||||
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
||||||
},
|
},
|
||||||
) { _, indicator ->
|
) { _, indicator ->
|
||||||
|
val isInspectingThis =
|
||||||
|
indicator.subagentId != null && indicator.subagentId == inspectingSubagentId
|
||||||
InspectionItemCard(
|
InspectionItemCard(
|
||||||
indicator = indicator,
|
indicator = indicator,
|
||||||
|
isInspectingTranscript = isInspectingThis,
|
||||||
|
transcriptState = if (isInspectingThis) subagentTranscript else null,
|
||||||
|
onToggleTranscript = onToggleTranscript,
|
||||||
|
onRetryTranscript = onRetryTranscript,
|
||||||
onSteer =
|
onSteer =
|
||||||
if (onSteerSubagent != null) {
|
if (onSteerSubagent != null) {
|
||||||
{ message -> onSteerSubagent(indicator, message) }
|
{ message -> onSteerSubagent(indicator, message) }
|
||||||
@@ -448,6 +468,10 @@ private fun TodoInspectionCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun InspectionItemCard(
|
private fun InspectionItemCard(
|
||||||
indicator: SubagentIndicator,
|
indicator: SubagentIndicator,
|
||||||
|
isInspectingTranscript: Boolean = false,
|
||||||
|
transcriptState: SubagentTranscriptUiState? = null,
|
||||||
|
onToggleTranscript: ((String) -> Unit)? = null,
|
||||||
|
onRetryTranscript: (() -> Unit)? = null,
|
||||||
onSteer: ((String) -> Unit)? = null,
|
onSteer: ((String) -> Unit)? = null,
|
||||||
onStop: (() -> 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)
|
// Interactive Controls for active subagent (issue #1030)
|
||||||
if (indicator.isRunning || indicator.isSteered) {
|
if (indicator.isRunning || indicator.isSteered) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
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.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
|
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -171,6 +172,44 @@ internal fun FullBleedAgentMessage(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
modifier = Modifier.testTag("fullbleed_finish_time"),
|
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.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
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.ChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
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.chat.toolCallMilestones
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
|
||||||
@@ -69,6 +75,15 @@ fun FullBleedChatList(
|
|||||||
onRespondClarify: ((String) -> Unit)? = null,
|
onRespondClarify: ((String) -> Unit)? = null,
|
||||||
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
|
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
|
||||||
onDismissClarify: (() -> 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 = {},
|
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
@@ -85,7 +100,7 @@ fun FullBleedChatList(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val toolMilestones = toolCallMilestones(messages)
|
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
|
||||||
val turns =
|
val turns =
|
||||||
remember(messages, streamingMessage) {
|
remember(messages, streamingMessage) {
|
||||||
groupIntoTurnsWithStreaming(messages, streamingMessage)
|
groupIntoTurnsWithStreaming(messages, streamingMessage)
|
||||||
@@ -148,14 +163,12 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var entryIndex = 0
|
|
||||||
turns.forEach { turn ->
|
turns.forEach { turn ->
|
||||||
when (turn) {
|
when (turn) {
|
||||||
is ChatTurn.User -> {
|
is ChatTurn.User -> {
|
||||||
// Eager captures: item lambda reads these at
|
// Eager captures: item lambda reads these at
|
||||||
// composition time (lazy), so capture now.
|
// composition time (lazy), so capture now.
|
||||||
val userMessage = turn.message
|
val userMessage = turn.message
|
||||||
val milestone = toolMilestones[entryIndex]
|
|
||||||
item(key = "user-${userMessage.id}") {
|
item(key = "user-${userMessage.id}") {
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
renderChatBubble(
|
renderChatBubble(
|
||||||
@@ -170,12 +183,8 @@ fun FullBleedChatList(
|
|||||||
openingAttachmentPath = openingAttachmentPath,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
milestone?.let { count ->
|
|
||||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entryIndex++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChatTurn.Agent -> {
|
is ChatTurn.Agent -> {
|
||||||
@@ -210,7 +219,6 @@ fun FullBleedChatList(
|
|||||||
val hoistedReasoning =
|
val hoistedReasoning =
|
||||||
turnReasoning != null &&
|
turnReasoning != null &&
|
||||||
proseMessage.id == turnReasoning.message.id
|
proseMessage.id == turnReasoning.message.id
|
||||||
val milestone = toolMilestones[entryIndex]
|
|
||||||
item(key = "prose-${proseMessage.id}") {
|
item(key = "prose-${proseMessage.id}") {
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||||
@@ -248,18 +256,14 @@ fun FullBleedChatList(
|
|||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
milestone?.let { count ->
|
|
||||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstProseSeen = true
|
firstProseSeen = true
|
||||||
entryIndex++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> {
|
is AgentEntry.ToolRow -> {
|
||||||
val toolMessage = entry.message
|
val toolMessage = entry.message
|
||||||
val milestone = toolMilestones[entryIndex]
|
val milestone = toolMilestones[toolMessage.id]
|
||||||
item(key = "tool-${toolMessage.id}") {
|
item(key = "tool-${toolMessage.id}") {
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||||
FullBleedToolRow(toolMessage)
|
FullBleedToolRow(toolMessage)
|
||||||
@@ -268,7 +272,6 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entryIndex++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.SystemEvent -> {
|
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.R
|
||||||
import com.m57.hermescontrol.data.model.HubSkill
|
import com.m57.hermescontrol.data.model.HubSkill
|
||||||
import com.m57.hermescontrol.data.model.McpServer
|
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.PluginInfo
|
||||||
import com.m57.hermescontrol.data.model.Skill
|
import com.m57.hermescontrol.data.model.Skill
|
||||||
import com.m57.hermescontrol.data.model.Toolset
|
import com.m57.hermescontrol.data.model.Toolset
|
||||||
@@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List<DetailRow> =
|
|||||||
DetailRow(stringResource(R.string.detail_dialog_description), description),
|
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
|
@Composable
|
||||||
fun Toolset.toDetailRows(): List<DetailRow> =
|
fun Toolset.toDetailRows(): List<DetailRow> =
|
||||||
listOf(
|
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])
|
* Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
|
||||||
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
|
* 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