release: promote v1.31.0-rc.2 to main (#1354)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Lx-204 <15377891490@163.com> Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
111 files changed
+10563
-998
No files matched your search
@@ -151,7 +151,29 @@ Tap **Sign in** on the landing screen and enter the dashboard host and port. The
|
||||
| **Token only** | Dashboard on same machine (loopback) | **Token** — grab from `~/.hermes/dashboard-token.txt` or `~/.hermes/.env` (`HERMES_DASHBOARD_SESSION_TOKEN`). The app can also auto-extract it from the dashboard page |
|
||||
| **Basic auth** | Dashboard on LAN with password gate | **Username** + **Password** (default `admin` / `hermes`). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically |
|
||||
|
||||
> The app communicates over plain HTTP — it's designed for **trusted local networks only**. Do not expose your Hermes gateway to untrusted networks.
|
||||
Use HTTPS for remote connections. Use HTTP only on a trusted local network.
|
||||
|
||||
### Cloudflare Access and custom headers
|
||||
|
||||
1. Enter your dashboard's HTTPS URL on the login screen.
|
||||
2. Tap **Custom headers**, then **Add Cloudflare headers**.
|
||||
3. Enter your service token's `CF-Access-Client-Id` and `CF-Access-Client-Secret` values.
|
||||
4. Tap **Save**. The app probes the dashboard again, then shows the Hermes login fields.
|
||||
|
||||
Your [Cloudflare Access policy](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
|
||||
must accept the service token. These headers authenticate with Cloudflare; you still
|
||||
need to complete Hermes authentication.
|
||||
|
||||
You can add other custom header names and values in the same editor. To edit or
|
||||
remove saved headers, open **Custom headers** from login or the saved connection's
|
||||
edit dialog in Settings. Values are masked and stored in encrypted preferences.
|
||||
Saving an empty list removes the headers for that URL.
|
||||
|
||||
Headers are shared by profiles with the same server URL. They apply to probes,
|
||||
login, API and media requests, and WebSocket handshakes. Each URL's scheme, host,
|
||||
port, and path prefix limit where its headers are sent. Redirects outside that
|
||||
scope do not receive the credentials; WebSocket redirects are not followed.
|
||||
The app manages `Authorization`, `Cookie`, and transport headers itself.
|
||||
|
||||
### Connection profiles
|
||||
|
||||
|
||||
@@ -10,13 +10,11 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -128,37 +126,4 @@ class ChatScreenTest {
|
||||
composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatScreen_primarySendUsesSavedBusyDefault() {
|
||||
val uiState =
|
||||
ChatUiState(
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
isSessionReady = true,
|
||||
busySendMode = BusySendMode.QUEUE,
|
||||
)
|
||||
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||
every { mockViewModel.connectionOperationState } returns
|
||||
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||
every { mockViewModel.actionProgress } returns
|
||||
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
|
||||
every { mockViewModel.sendMessage(any(), any()) } returns true
|
||||
|
||||
composeTestRule.setContent {
|
||||
ChatScreen(
|
||||
onOpenDrawer = {},
|
||||
sessionId = null,
|
||||
viewModel = mockViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default")
|
||||
composeTestRule.onNodeWithTag("send_button").performClick()
|
||||
|
||||
verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) }
|
||||
verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) }
|
||||
}
|
||||
}
|
||||
-153
@@ -32,7 +32,6 @@ import androidx.test.filters.MediumTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
@@ -58,158 +57,6 @@ class ComposerInteractionTest {
|
||||
private var photosTaps = 0
|
||||
private var fileTaps = 0
|
||||
|
||||
private fun setContextualComposer(
|
||||
text: String,
|
||||
isMainTurnBusy: Boolean,
|
||||
attachments: List<Attachment> = emptyList(),
|
||||
onSend: () -> Unit = {},
|
||||
onBusySend: (BusySendMode) -> Unit = {},
|
||||
isConnected: Boolean = true,
|
||||
pendingReasoningLevel: String? = null,
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
ChatInputBar(
|
||||
inputFieldValue = TextFieldValue(text),
|
||||
onInputChange = {},
|
||||
onSend = onSend,
|
||||
onBusySend = onBusySend,
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = isMainTurnBusy,
|
||||
isMainTurnBusy = isMainTurnBusy,
|
||||
isConnected = isConnected,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
pendingAttachments = attachments,
|
||||
pendingReasoningLevel = pendingReasoningLevel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idleDraft_showsRegularSendWithoutBusyActions() {
|
||||
setContextualComposer(text = "hello", isMainTurnBusy = false)
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyBlankDraft_hidesBusyActions() {
|
||||
setContextualComposer(text = "", isMainTurnBusy = true)
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() {
|
||||
var regularSends = 0
|
||||
val selected = mutableListOf<BusySendMode>()
|
||||
setContextualComposer(
|
||||
text = "change course",
|
||||
isMainTurnBusy = true,
|
||||
onSend = { regularSends++ },
|
||||
onBusySend = selected::add,
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals(1, regularSends)
|
||||
assertEquals(listOf(BusySendMode.QUEUE), selected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyAttachmentOnly_showsSendAndQueue() {
|
||||
val attachment = Attachment("content://document/1", "notes.txt", "text/plain")
|
||||
setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment))
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyDisconnectedDraft_disablesSendAndQueue() {
|
||||
setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false)
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyPendingReasoning_disablesSendAndQueue() {
|
||||
setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high")
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busySlashDraft_usesRegularSendWithoutBusyActions() {
|
||||
var regularSends = 0
|
||||
setContextualComposer(
|
||||
text = "/stop",
|
||||
isMainTurnBusy = true,
|
||||
onSend = { regularSends++ },
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag("send_button").performClick()
|
||||
composeTestRule.runOnIdle { assertEquals(1, regularSends) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() {
|
||||
val busy = mutableStateOf(true)
|
||||
composeTestRule.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("draft in progress")) }
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
onSend = {},
|
||||
onBusySend = {},
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = busy.value,
|
||||
isMainTurnBusy = busy.value,
|
||||
isConnected = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
|
||||
composeTestRule.runOnUiThread { busy.value = false }
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress")
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() {
|
||||
composeTestRule.setContent {
|
||||
Box(Modifier.width(220.dp)) {
|
||||
ChatInputBar(
|
||||
inputFieldValue = TextFieldValue("change course"),
|
||||
onInputChange = {},
|
||||
onSend = {},
|
||||
onBusySend = {},
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = true,
|
||||
isMainTurnBusy = true,
|
||||
isConnected = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
||||
private fun setComposer(
|
||||
reasoningLevel: String? = "medium",
|
||||
|
||||
+115
@@ -1,5 +1,8 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
@@ -11,6 +14,9 @@ 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.data.model.CatalogScanStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectionCatalogInfo
|
||||
import com.m57.hermescontrol.data.model.ConnectionCatalogScan
|
||||
import com.m57.hermescontrol.data.model.ConnectionEnvField
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
|
||||
@@ -212,6 +218,115 @@ class ConnectionSetupSheetTest {
|
||||
composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pluginTarget_showsSourcePinScanAndRequirements_andApprovesWithoutSecrets() {
|
||||
var response: Triple<String, Map<String, String>, Boolean>? = null
|
||||
composeTestRule.setContent {
|
||||
ConnectionSetupContent(
|
||||
state = state(pluginTarget()),
|
||||
onRespond = { name, values, approved -> response = Triple(name, values, approved) },
|
||||
onContinue = {},
|
||||
onOpenBrowser = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Install Linear").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithTag(
|
||||
"connection_catalog_source",
|
||||
).assertTextContains("NousResearch/hermes-plugins/linear", substring = true)
|
||||
composeTestRule.onNodeWithTag("connection_catalog_pin").assertTextContains("0123456789ab", substring = true)
|
||||
composeTestRule
|
||||
.onNodeWithTag("connection_catalog_scan")
|
||||
.assertTextContains("Security scan found warnings · 1 advisory")
|
||||
composeTestRule
|
||||
.onNodeWithTag(
|
||||
"connection_catalog_requirements",
|
||||
).assertTextContains("Hermes >=0.21", substring = true)
|
||||
composeTestRule.onNodeWithTag("connection_catalog_profile").assertTextContains("default", substring = true)
|
||||
composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertDoesNotExist()
|
||||
|
||||
composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Install").performClick()
|
||||
|
||||
assertEquals("linear", response?.first)
|
||||
assertEquals(mapOf("LINEAR_API_KEY" to ""), response?.second)
|
||||
assertEquals(true, response?.third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pluginTarget_installingAndFailedStates() {
|
||||
var target = pluginTarget(state = ConnectionTargetState.INITIATED)
|
||||
var current by mutableStateOf(state(target))
|
||||
composeTestRule.setContent {
|
||||
ConnectionSetupContent(
|
||||
state = current,
|
||||
onRespond = { _, _, _ -> },
|
||||
onContinue = {},
|
||||
onOpenBrowser = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("connection_catalog_installing").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("connection_setup_connect").assertDoesNotExist()
|
||||
|
||||
target = pluginTarget(state = ConnectionTargetState.FAILED, detail = "blocked by the kill list")
|
||||
current = state(target)
|
||||
composeTestRule.onNodeWithText("blocked by the kill list").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Try again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun installedSkill_reportsTheLoadableSkill() {
|
||||
composeTestRule.setContent {
|
||||
ConnectionSetupContent(
|
||||
state =
|
||||
state(
|
||||
pluginTarget(state = ConnectionTargetState.CONNECTED).copy(
|
||||
kind = ConnectionTargetKind.SKILL,
|
||||
catalog = pluginCatalog().copy(display = "Review", skill = "review", scan = null),
|
||||
),
|
||||
),
|
||||
onRespond = { _, _, _ -> },
|
||||
onContinue = {},
|
||||
onOpenBrowser = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Skill available: review").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("No security scan reported").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun pluginCatalog(): ConnectionCatalogInfo =
|
||||
ConnectionCatalogInfo(
|
||||
display = "Linear",
|
||||
description = "Linear issues.",
|
||||
tier = "official",
|
||||
platforms = emptyList(),
|
||||
repo = "NousResearch/hermes-plugins",
|
||||
sha = "0123456789abcdef0123456789abcdef01234567",
|
||||
subdir = "linear",
|
||||
scan = ConnectionCatalogScan(CatalogScanStatus.WARNINGS, "1 advisory"),
|
||||
requirements = listOf("Hermes >=0.21"),
|
||||
targetProfile = "default",
|
||||
skill = null,
|
||||
)
|
||||
|
||||
private fun pluginTarget(
|
||||
state: ConnectionTargetState = ConnectionTargetState.PENDING,
|
||||
detail: String? = null,
|
||||
): ConnectionOperationTarget =
|
||||
target(
|
||||
name = "linear",
|
||||
state = state,
|
||||
requiredEnv = listOf(env("LINEAR_API_KEY", required = false, secret = true)),
|
||||
).copy(
|
||||
kind = ConnectionTargetKind.PLUGIN,
|
||||
action = ConnectionTargetAction.INSTALL,
|
||||
detail = detail,
|
||||
catalog = pluginCatalog(),
|
||||
)
|
||||
|
||||
private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState =
|
||||
ConnectionOperationUiState(operation = operation(target))
|
||||
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PendingSendPanelTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun row(state: PendingSendState) =
|
||||
PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state)
|
||||
|
||||
@Test
|
||||
fun ordinarySendNeverShowsDeliveryPanel() {
|
||||
val state = mutableStateOf(PendingSendState.SENDING)
|
||||
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist()
|
||||
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() {
|
||||
val state = mutableStateOf(PendingSendState.QUEUED)
|
||||
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist()
|
||||
compose.runOnIdle { state.value = PendingSendState.SENDING }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedAndUncertainDeliveryRemainRecoverable() {
|
||||
val state = mutableStateOf(PendingSendState.REJECTED)
|
||||
var retried: String? = null
|
||||
compose.setContent {
|
||||
PendingSendPanel(
|
||||
listOf(row(state.value)),
|
||||
mainTurnBusy = false,
|
||||
onSendNow = { retried = it },
|
||||
)
|
||||
}
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick()
|
||||
compose.runOnIdle {
|
||||
assertEquals("receipt", retried)
|
||||
state.value = PendingSendState.UNKNOWN
|
||||
}
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() {
|
||||
val state = mutableStateOf(PendingSendState.QUEUED)
|
||||
compose.setContent {
|
||||
PendingSendPanel(
|
||||
sends = listOf(row(state.value)),
|
||||
mainTurnBusy = true,
|
||||
onSendNow = {},
|
||||
)
|
||||
}
|
||||
|
||||
listOf(
|
||||
PendingSendState.QUEUED,
|
||||
PendingSendState.PARKED,
|
||||
PendingSendState.REJECTED,
|
||||
PendingSendState.UNKNOWN,
|
||||
).forEach { pendingState ->
|
||||
compose.runOnIdle { state.value = pendingState }
|
||||
compose
|
||||
.onNodeWithText(
|
||||
compose.activity.getString(R.string.chat_pending_send_now_warning),
|
||||
).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Gesture-level coverage for the Telegram-style voice-note mic: a held press
|
||||
* arms the recorder, the release submits exactly once, sliding left cancels,
|
||||
* sliding up locks hands-free, and the shared click handlers must never
|
||||
* re-dispatch the dictation (mic tap) action for a hold.
|
||||
*
|
||||
* Regression: the action button's click handler fires on ANY stationary
|
||||
* release — including one that ended a hold — so letting go of a voice note
|
||||
* also launched the system speech-recognizer intent. The gesture loop now
|
||||
* consumes the hold phase, and these tests pin that behavior down.
|
||||
*
|
||||
* The hold threshold runs on the compose test clock, so the tests advance
|
||||
* [createAndroidComposeRule]'s mainClock instead of sleeping.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class VoiceNoteGestureTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private var micTaps = 0
|
||||
private var holdStarts = 0
|
||||
private var holdEnds = 0
|
||||
private var holdCancels = 0
|
||||
private var sendCount = 0
|
||||
private var locks = 0
|
||||
|
||||
/**
|
||||
* Renders the real input bar with the launcher's contract: hold start/end
|
||||
* flip the recording state, the lock callback flips the locked flag, and a
|
||||
* cancel releases both — exactly like ChatMediaLaunchers does.
|
||||
*/
|
||||
private fun setComposer(
|
||||
initiallyLocked: Boolean = false,
|
||||
draft: String = "",
|
||||
) {
|
||||
// The lock-hint tooltip hides after the first successful lock and
|
||||
// persists that; start every run from a clean slate so the rendered
|
||||
// UI is deterministic.
|
||||
composeTestRule.activity
|
||||
.getSharedPreferences("chat_voice_prefs", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
composeTestRule.setContent {
|
||||
var locked by remember { mutableStateOf(initiallyLocked) }
|
||||
var recording by remember { mutableStateOf(initiallyLocked) }
|
||||
// Bottom-anchor the bar like the real chat screen, so floating
|
||||
// overlays above the card render inside the captured frame.
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
ChatInputBar(
|
||||
inputFieldValue = TextFieldValue(draft),
|
||||
onInputChange = {},
|
||||
onSend = { sendCount++ },
|
||||
onMicTap = { micTaps++ },
|
||||
isListening = false,
|
||||
isAgentTyping = false,
|
||||
isConnected = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
onMicHoldStart = {
|
||||
holdStarts++
|
||||
recording = true
|
||||
},
|
||||
onMicHoldEnd = {
|
||||
holdEnds++
|
||||
recording = false
|
||||
},
|
||||
onMicHoldCancel = {
|
||||
holdCancels++
|
||||
locked = false
|
||||
recording = false
|
||||
},
|
||||
onMicLock = {
|
||||
locks++
|
||||
locked = true
|
||||
},
|
||||
isVoiceNoteLocked = locked,
|
||||
isRecordingVoice = recording,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stationaryHoldRelease_sendsWithoutTriggeringMicTap() {
|
||||
setComposer()
|
||||
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||
composeTestRule.mainClock.advanceTimeBy(600)
|
||||
// Mid-hold capture: the recording strip must be visible even though
|
||||
// the mic button sits under the finger (device follow-up, #1247).
|
||||
val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap()
|
||||
File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_recording.png")
|
||||
.outputStream()
|
||||
.use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
// The lock hint must exist while holding (it sits above the card).
|
||||
composeTestRule.onNodeWithText("Slide up to lock recording").assertExists()
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("release must submit the note", 1, holdEnds)
|
||||
assertEquals(
|
||||
"a held release must not re-dispatch the tap action",
|
||||
0,
|
||||
micTaps,
|
||||
)
|
||||
assertEquals("a submitted hold must not cancel", 0, holdCancels)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quickTap_stillDispatchesMicTap() {
|
||||
setComposer()
|
||||
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput {
|
||||
down(center)
|
||||
up()
|
||||
}
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("a quick tap must start dictation", 1, micTaps)
|
||||
assertEquals(0, holdStarts)
|
||||
assertEquals(0, holdEnds)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun holdSlideAway_cancelsWithoutSending() {
|
||||
setComposer()
|
||||
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||
composeTestRule.mainClock.advanceTimeBy(600)
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(-240f, 0f)) }
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("sliding left must cancel the recording", 1, holdCancels)
|
||||
assertEquals("a cancelled hold must not submit", 0, holdEnds)
|
||||
assertEquals("a cancelled hold must not start dictation", 0, micTaps)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun holdSlideUp_locksWithoutSending() {
|
||||
setComposer()
|
||||
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||
composeTestRule.mainClock.advanceTimeBy(600)
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) }
|
||||
// Lifting the finger after the slide-up lock: target the root, because
|
||||
// the action slot has already morphed into the send button.
|
||||
composeTestRule.onRoot().performTouchInput { up() }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("sliding up must lock the recording", 1, locks)
|
||||
assertEquals("a locked hold must not submit on release", 0, holdEnds)
|
||||
assertEquals("a locked hold must not cancel", 0, holdCancels)
|
||||
assertEquals("a locked hold must not start dictation", 0, micTaps)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("voice_note_send_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("voice_note_send_button").performClick()
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("the locked send button must dispatch the mic action", 1, micTaps)
|
||||
assertEquals("sending must not re-lock", 1, locks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun holdRelease_returnsFocusToTheInput() {
|
||||
setComposer()
|
||||
|
||||
composeTestRule.onNodeWithTag("chat_input").performClick()
|
||||
composeTestRule.onNodeWithTag("chat_input").assertIsFocused()
|
||||
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||
composeTestRule.mainClock.advanceTimeBy(600)
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// The strip replaced the input field while recording; the send must
|
||||
// hand focus back so the keyboard re-opens without an extra tap
|
||||
// (device follow-up, #1247).
|
||||
composeTestRule.onNodeWithTag("chat_input").assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockedRecording_showsCancelActionAndRoutesCancel() {
|
||||
setComposer(initiallyLocked = true)
|
||||
|
||||
composeTestRule.onNodeWithTag("voice_note_recording_panel").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("voice_note_cancel_button").assertIsDisplayed()
|
||||
// Accessibility (review, PR #1280): the locked CANCEL is a real
|
||||
// button. Its hit area is expanded by minimumInteractiveComponentSize
|
||||
// (Compose keeps the visual text compact and widens the touch target),
|
||||
// so the assertion pins the button role rather than layout bounds.
|
||||
composeTestRule
|
||||
.onNodeWithTag("voice_note_cancel_button")
|
||||
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
|
||||
|
||||
val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap()
|
||||
val file =
|
||||
File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_locked.png")
|
||||
file.outputStream().use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
|
||||
composeTestRule.onNodeWithTag("voice_note_cancel_button").performClick()
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("cancel must cancel the recording", 1, holdCancels)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun holdReleaseWithDraft_doesNotRouteTapToDictation() {
|
||||
// With a draft present the secondary mic ("mic_button") appears next to
|
||||
// the input. Review, PR #1280: its gesture modifier is chained after
|
||||
// the button's own clickable (the reverse of the action button), so a
|
||||
// stationary hold-release must not leak a finger-up into onMicTap.
|
||||
setComposer(draft = "draft")
|
||||
val tapsBefore = micTaps
|
||||
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||
composeTestRule.mainClock.advanceTimeBy(600)
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("release must submit the note", 1, holdEnds)
|
||||
assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockedWithDraft_actionButtonFinishesTheVoiceNote() {
|
||||
// Blocker, review, PR #1280: with a draft present the action button is
|
||||
// a send arrow; once the recording is locked it must finish the voice
|
||||
// note instead of sending the draft text.
|
||||
setComposer(draft = "draft")
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||
composeTestRule.mainClock.advanceTimeBy(600)
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) }
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithTag("voice_note_send_button").performClick()
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals("the tap must finish the voice note", 1, micTaps)
|
||||
assertEquals("the draft text must not be sent", 0, sendCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
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.McpServer
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.mcp.components.ServerCard
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class McpOwnershipCardTest {
|
||||
@get:Rule val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun pluginRowExplainsOwnershipAndDisablesMutations() {
|
||||
showServer("plugin", "security")
|
||||
composeRule.onNodeWithText("From plugin security · Read-only", useUnmergedTree = true).assertExists()
|
||||
composeRule.onNode(isToggleable()).assertIsNotEnabled()
|
||||
composeRule.onNodeWithContentDescription("Delete").assertIsNotEnabled()
|
||||
composeRule.onNodeWithText("Authorize").assertIsNotEnabled()
|
||||
composeRule.onNodeWithText("Test").assertIsEnabled()
|
||||
composeRule.onNodeWithText("Env vars").performClick()
|
||||
composeRule.onNodeWithContentDescription("Remove").assertIsNotEnabled()
|
||||
composeRule.onNodeWithText("Add env var").assertIsNotEnabled()
|
||||
composeRule.onNodeWithText("KEY", useUnmergedTree = true).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configRowKeepsMutationsEnabled() {
|
||||
showServer("config", null)
|
||||
composeRule.onNode(isToggleable()).assertIsEnabled()
|
||||
composeRule.onNodeWithContentDescription("Delete").assertIsEnabled()
|
||||
composeRule.onNodeWithText("Authorize").assertIsEnabled()
|
||||
composeRule.onNodeWithText("Env vars").performClick()
|
||||
composeRule.onNodeWithContentDescription("Remove").assertIsEnabled()
|
||||
composeRule.onNodeWithText("Add env var").assertIsEnabled()
|
||||
}
|
||||
|
||||
private fun showServer(
|
||||
source: String,
|
||||
plugin: String?,
|
||||
) {
|
||||
val server =
|
||||
McpServer(
|
||||
name = "scanner",
|
||||
enabled = true,
|
||||
source = source,
|
||||
plugin = plugin,
|
||||
auth = "oauth",
|
||||
env = mapOf("KEY" to "test-value"),
|
||||
)
|
||||
val viewModel = McpServersViewModel()
|
||||
composeRule.setContent {
|
||||
HermesControlTheme {
|
||||
ServerCard(
|
||||
server = server,
|
||||
state = McpServersUiState(servers = listOf(server)),
|
||||
viewModel = viewModel,
|
||||
spacing = LocalSpacing.current,
|
||||
onClick = {},
|
||||
onOpenBrowser = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object FilesScreen : NavKey
|
||||
|
||||
@Serializable data object AccountConnectorsScreen : NavKey
|
||||
|
||||
// ── Settings drill-down sub-pages ──────────────────────────────────────
|
||||
|
||||
@Serializable data object SettingsConnection : NavKey
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent
|
||||
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
|
||||
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
||||
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
||||
import com.m57.hermescontrol.ui.connectors.AccountConnectorsScreen as AccountConnectorsScreenContent
|
||||
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
|
||||
import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent
|
||||
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
||||
@@ -236,6 +237,12 @@ object ScreenRegistry {
|
||||
Icons.Filled.EmojiEvents,
|
||||
DrawerSection.INSPECT,
|
||||
) { sessionId, openDrawer -> AchievementsScreenContent(onOpenDrawer = openDrawer) },
|
||||
ScreenDefinition(
|
||||
AccountConnectorsScreen,
|
||||
R.string.screen_account_connectors,
|
||||
Icons.Filled.Extension,
|
||||
DrawerSection.CONFIGURE,
|
||||
) { _, openDrawer -> AccountConnectorsScreenContent(onOpenDrawer = openDrawer) },
|
||||
ScreenDefinition(
|
||||
SettingsScreen,
|
||||
R.string.screen_settings,
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.remote.ServerHeaders
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
@@ -215,6 +216,7 @@ object AuthManager {
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
migrateLegacyDefaultIfNeeded(p)
|
||||
ServerHeaders.initialize(p)
|
||||
synchronized(this@AuthManager) {
|
||||
readyPrefs = p
|
||||
cachedToken = getTokenInternal(p)
|
||||
|
||||
@@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot(
|
||||
val settled: Boolean,
|
||||
val settledBy: String?,
|
||||
val targets: List<ConnectionOperationTarget>,
|
||||
val accountOwned: Boolean = false,
|
||||
)
|
||||
|
||||
data class ConnectionOperationTarget(
|
||||
@@ -29,6 +30,8 @@ data class ConnectionOperationTarget(
|
||||
val requiredEnv: List<ConnectionEnvField>,
|
||||
val tools: List<String>,
|
||||
val hint: String?,
|
||||
/** Catalog facts for [ConnectionTargetKind.PLUGIN] / [ConnectionTargetKind.SKILL] rows only. */
|
||||
val catalog: ConnectionCatalogInfo? = null,
|
||||
) {
|
||||
/** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */
|
||||
val safeConnectUrl: String?
|
||||
@@ -40,9 +43,32 @@ data class ConnectionOperationTarget(
|
||||
"instructions=$instructions, discoveryError=$discoveryError, " +
|
||||
"connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " +
|
||||
"connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " +
|
||||
"tools=$tools, hint=$hint)"
|
||||
"tools=$tools, hint=$hint, catalog=$catalog)"
|
||||
}
|
||||
|
||||
/** Host-resolved catalog row facts (hermes-agent `manage_catalog`); the model supplies only the id. */
|
||||
data class ConnectionCatalogInfo(
|
||||
val display: String,
|
||||
val description: String?,
|
||||
val tier: String?,
|
||||
val platforms: List<String>,
|
||||
val repo: String?,
|
||||
val sha: String?,
|
||||
val subdir: String?,
|
||||
val scan: ConnectionCatalogScan?,
|
||||
val requirements: List<String>,
|
||||
val targetProfile: String,
|
||||
/** On an installed row: the skill the model can now load. */
|
||||
val skill: String?,
|
||||
)
|
||||
|
||||
data class ConnectionCatalogScan(
|
||||
val status: CatalogScanStatus,
|
||||
val summary: String?,
|
||||
)
|
||||
|
||||
enum class CatalogScanStatus { PASSED, WARNINGS, FAILED, UNKNOWN }
|
||||
|
||||
data class ConnectionEnvField(
|
||||
val name: String,
|
||||
val required: Boolean,
|
||||
@@ -51,7 +77,16 @@ data class ConnectionEnvField(
|
||||
val prompt: String?,
|
||||
)
|
||||
|
||||
enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN }
|
||||
enum class ConnectionTargetKind {
|
||||
CONNECTOR,
|
||||
MCP,
|
||||
PLUGIN,
|
||||
SKILL,
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
val isCatalog: Boolean get() = this == PLUGIN || this == SKILL
|
||||
}
|
||||
|
||||
enum class ConnectionTargetAction {
|
||||
AUTHORIZE,
|
||||
|
||||
@@ -231,3 +231,70 @@ sealed interface ConnectorConnectResult {
|
||||
|
||||
fun errorOrNull(): ConnectorError? = (this as? Error)?.error
|
||||
}
|
||||
|
||||
/** Account-level connector records exposed outside the chat session lifecycle. */
|
||||
data class ConnectorAccount(
|
||||
val connectionId: String,
|
||||
val connector: String,
|
||||
val status: String,
|
||||
val statusReason: String?,
|
||||
val label: String,
|
||||
val alias: String?,
|
||||
val active: Boolean,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
data class ConnectorCatalogEntry(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val category: String,
|
||||
)
|
||||
|
||||
data class ConnectorTool(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val facet: String,
|
||||
val deprecated: Boolean,
|
||||
val hints: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class ConnectorPolicy(
|
||||
val revision: String,
|
||||
val mode: String,
|
||||
val connectors: List<String>,
|
||||
val disabledConnectors: List<String>,
|
||||
val tools: Map<String, List<String>>,
|
||||
val enabledTags: List<String> = emptyList(),
|
||||
val disabledTags: List<String> = emptyList(),
|
||||
val member: ConnectorPolicy? = null,
|
||||
val inherited: List<ConnectorPolicy> = emptyList(),
|
||||
) {
|
||||
fun toolEnabled(
|
||||
slug: String,
|
||||
tool: ConnectorTool,
|
||||
): Boolean =
|
||||
connectorEnabled(slug) && tool.slug !in tools[slug].orEmpty() &&
|
||||
disabledTags.none { it in tool.hints } && (enabledTags.isEmpty() || enabledTags.any { it in tool.hints })
|
||||
|
||||
fun connectorEnabled(slug: String): Boolean =
|
||||
when (mode) {
|
||||
"unrestricted" -> true
|
||||
"deny-all" -> false
|
||||
"allow" -> slug in connectors
|
||||
"deny" -> slug !in disabledConnectors
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AccountConnectorResult<out T> {
|
||||
data class Success<T>(
|
||||
val value: T,
|
||||
) : AccountConnectorResult<T>
|
||||
|
||||
data class Failure(
|
||||
val error: ConnectorError,
|
||||
) : AccountConnectorResult<Nothing>
|
||||
}
|
||||
@@ -20,7 +20,12 @@ data class McpServer(
|
||||
val error: String? = null,
|
||||
val auth: String? = null,
|
||||
val tools: List<String>? = null,
|
||||
)
|
||||
val source: String = "config",
|
||||
val plugin: String? = null,
|
||||
) {
|
||||
// #1283: plugin-provided servers are managed by their owning plugin.
|
||||
val isPluginOwned: Boolean get() = source == "plugin"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class McpServerTestResponse(
|
||||
|
||||
@@ -81,6 +81,26 @@ data class PluginsHubResponse(
|
||||
val providers: PluginsHubProviders? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginUpdateRequest(
|
||||
@SerialName("accept_capabilities") val acceptCapabilities: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginUpdateResult(
|
||||
val ok: Boolean = false,
|
||||
val unchanged: Boolean = false,
|
||||
@SerialName("consent_required") val consentRequired: Boolean = false,
|
||||
val delta: Map<String, List<String>>? = null,
|
||||
@SerialName("delta_lines") val deltaLines: List<String>? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val capabilityDelta: List<String>
|
||||
get() =
|
||||
deltaLines?.takeIf { it.isNotEmpty() }
|
||||
?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AgentPluginInstallBody(
|
||||
val identifier: String = "",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class SessionCompressResponse(
|
||||
val status: String? = null,
|
||||
val removed: Int? = null,
|
||||
val before_messages: Int? = null,
|
||||
val after_messages: Int? = null,
|
||||
val before_tokens: Long? = null,
|
||||
val after_tokens: Long? = null,
|
||||
val summary: CompressionSummary? = null,
|
||||
val compressed: Boolean? = null,
|
||||
val lock_held: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val messages: List<SessionMessage>? = null,
|
||||
val usage: JsonElement? = null,
|
||||
val info: JsonElement? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CompressionSummary(
|
||||
val noop: Boolean = false,
|
||||
val aborted: Boolean = false,
|
||||
val refused_would_grow: Boolean? = null,
|
||||
val fallback_used: Boolean? = null,
|
||||
val headline: String = "",
|
||||
val token_line: String = "",
|
||||
val note: String? = null,
|
||||
)
|
||||
@@ -63,6 +63,7 @@ data class SessionListResponse(
|
||||
val total: Int = 0,
|
||||
val limit: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val storage: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
@@ -59,6 +60,17 @@ data class SessionMessage(
|
||||
val display_metadata: JsonElement? = null,
|
||||
/** Token count recorded by the backend. */
|
||||
val token_count: JsonElement? = null,
|
||||
/**
|
||||
* Public interim commentary projected by the backend for assistant rows
|
||||
* (agent/history_commentary.py, issue #1284). Rendered as assistant text
|
||||
* ahead of the reply. Absent on older gateways.
|
||||
*/
|
||||
val display_commentary: JsonElement? = null,
|
||||
/**
|
||||
* [reasoning] with the flattened commentary removed. When present (even
|
||||
* empty) it wins over the raw reasoning so commentary is not shown twice.
|
||||
*/
|
||||
val display_reasoning: JsonElement? = null,
|
||||
) {
|
||||
val timestampText: String?
|
||||
get() = (timestamp as? JsonPrimitive)?.content
|
||||
@@ -107,6 +119,41 @@ data class SessionMessage(
|
||||
else -> r.toString()
|
||||
}
|
||||
|
||||
/** True when the backend projected reasoning; it is then authoritative, even if empty. */
|
||||
val hasDisplayReasoning: Boolean
|
||||
get() = (display_reasoning as? JsonPrimitive)?.isString == true
|
||||
|
||||
/** [display_reasoning] when projected, else raw reasoning (older gateways). */
|
||||
val displayReasoningText: String
|
||||
get() = (display_reasoning as? JsonPrimitive)?.takeIf { it.isString }?.content ?: reasoningText
|
||||
|
||||
/** Nonblank public commentary items, in order. */
|
||||
val displayCommentary: List<String>
|
||||
get() =
|
||||
(display_commentary as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content?.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* Visible assistant text: interim commentary, then the reply. Commentary that
|
||||
* merely repeats the reply (some providers persist it as content) renders once.
|
||||
*/
|
||||
val visibleText: String
|
||||
get() {
|
||||
val reply = displayContentText ?: contentText
|
||||
val commentary = displayCommentary
|
||||
if (commentary.isEmpty()) return reply
|
||||
val joined = commentary.joinToString("\n\n")
|
||||
if (reply.isBlank()) return joined
|
||||
|
||||
fun normalized(value: String) = value.replace(WHITESPACE, " ").trim()
|
||||
if (normalized(joined) == normalized(reply)) return reply
|
||||
return "$joined\n\n$reply"
|
||||
}
|
||||
|
||||
val toolCallId: String
|
||||
get() = tool_call_id.orEmpty()
|
||||
}
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for `POST /api/audio/speak` — the dashboard's server-side
|
||||
* text-to-speech relay.
|
||||
*/
|
||||
@Serializable
|
||||
data class TtsSpeakRequest(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `POST /api/audio/speak`.
|
||||
*
|
||||
* Returns base64 encoded [data_url] with [mime_type] and the [provider] used,
|
||||
* or [ok] = false on failure.
|
||||
*/
|
||||
@Serializable
|
||||
data class TtsSpeakResponse(
|
||||
val ok: Boolean = false,
|
||||
val data_url: String? = null,
|
||||
val mime_type: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
@@ -95,9 +95,12 @@ object ApiClient {
|
||||
* the shared 30s client, and a re-submitted POST would repeat expensive
|
||||
* provider work after the request may already have reached the server
|
||||
* (the repository also passes `retries = 0`). The returned service reuses
|
||||
* the shared connection pool and the auth/profile interceptor stack, and
|
||||
* applies [readTimeoutMs] to reads and writes with transport retries
|
||||
* disabled (review, PR #1250).
|
||||
* the shared connection pool and the auth/profile interceptor stack. It
|
||||
* applies [readTimeoutMs] to reads and writes and keeps transport-level
|
||||
* retries on: they re-dial and can resend depending on when a failure
|
||||
* struck, so they do not guarantee the absence of duplicate provider
|
||||
* work — the risk is small, not zero. Application-level retry stays off
|
||||
* (review, PR #1250 / #1280).
|
||||
*/
|
||||
fun transcriptionService(readTimeoutMs: Long): HermesApiService {
|
||||
val client =
|
||||
@@ -109,7 +112,16 @@ object ApiClient {
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
// Transport-level stale-socket rescue stays ON: the shared pool
|
||||
// keeps connections far longer than the dashboard's keep-alive,
|
||||
// so the first STT call after an idle spell can ride a socket
|
||||
// the server just closed ("unexpected end of stream"). OkHttp
|
||||
// re-dials and can resend depending on when the failure struck,
|
||||
// and it may retry across routes — duplicate provider work is
|
||||
// possible, though rare. Timeouts and 5xx are never replayed
|
||||
// (safeApiCall still runs with retries = 0). Risk small, not
|
||||
// zero (review, PR #1280).
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
return Retrofit
|
||||
.Builder()
|
||||
|
||||
@@ -134,6 +134,8 @@ import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
|
||||
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
|
||||
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateCheckResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
@@ -670,7 +672,8 @@ interface HermesApiService : KanbanApiService {
|
||||
@POST("api/dashboard/agent-plugins/{name}/update")
|
||||
suspend fun updatePlugin(
|
||||
@Path("name", encoded = true) name: String,
|
||||
): Response<Unit>
|
||||
@Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest,
|
||||
): Response<com.m57.hermescontrol.data.model.PluginUpdateResult>
|
||||
|
||||
@POST("api/dashboard/agent-plugins/{name}/enable")
|
||||
suspend fun enablePlugin(
|
||||
@@ -1042,4 +1045,10 @@ interface HermesApiService : KanbanApiService {
|
||||
suspend fun transcribeAudio(
|
||||
@Body body: AudioTranscriptionRequest,
|
||||
): Response<AudioTranscriptionResponse>
|
||||
|
||||
@POST("api/audio/speak")
|
||||
suspend fun speakText(
|
||||
@Body request: TtsSpeakRequest,
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<TtsSpeakResponse>
|
||||
}
|
||||
@@ -33,6 +33,7 @@ object OkHttpProvider {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.cookieJar(resolveCookieJar())
|
||||
.addNetworkInterceptor(ServerHeadersInterceptor())
|
||||
.connectionPool(connectionPool)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -48,6 +49,9 @@ object OkHttpProvider {
|
||||
.cookieJar(resolveCookieJar())
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(ServerHeadersInterceptor())
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
/** Validated header values. Never include credentials in diagnostics or toString(). */
|
||||
class CustomHeaders private constructor(
|
||||
val entries: Map<String, String>,
|
||||
) {
|
||||
override fun toString(): String = "CustomHeaders(${entries.size} entries)"
|
||||
|
||||
companion object {
|
||||
val EMPTY = CustomHeaders(emptyMap())
|
||||
private val namePattern = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]+")
|
||||
private val reserved =
|
||||
setOf(
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"te",
|
||||
"trailer",
|
||||
)
|
||||
|
||||
fun parse(rows: List<Pair<String, String>>): CustomHeaders {
|
||||
val result = linkedMapOf<String, String>()
|
||||
val names = mutableSetOf<String>()
|
||||
rows.filterNot { it.first.isBlank() && it.second.isEmpty() }.forEach { (rawName, value) ->
|
||||
val name = rawName.trim()
|
||||
val normalized = name.lowercase(Locale.ROOT)
|
||||
// Do not expose OkHttp's validation exceptions: they can contain secret values.
|
||||
require(namePattern.matches(name)) { "Invalid header name" }
|
||||
require(normalized !in reserved && !normalized.startsWith("sec-websocket-")) {
|
||||
"Header is managed by the app"
|
||||
}
|
||||
require(names.add(normalized)) { "Duplicate header name" }
|
||||
require(value.all { it == '\t' || it in ' '..'~' }) { "Invalid header value" }
|
||||
result[name] = value
|
||||
}
|
||||
return CustomHeaders(result.toMap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Headers belong to a canonical server URL, including its reverse-proxy path. */
|
||||
class ScopedServerHeaders(
|
||||
val baseUrl: HttpUrl,
|
||||
val headers: CustomHeaders,
|
||||
) {
|
||||
fun contains(url: HttpUrl): Boolean =
|
||||
url.scheme == baseUrl.scheme && url.host == baseUrl.host && url.port == baseUrl.port &&
|
||||
(
|
||||
url.encodedPath.startsWith(baseUrl.encodedPath) ||
|
||||
url.encodedPath == baseUrl.encodedPath.removeSuffix("/")
|
||||
)
|
||||
}
|
||||
|
||||
/** Uses AuthManager's encrypted preferences, never the plaintext server_store.json. */
|
||||
object ServerHeaders {
|
||||
private const val KEY = "server_custom_headers"
|
||||
|
||||
@Volatile
|
||||
private var configured: List<ScopedServerHeaders> = emptyList()
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
@Synchronized
|
||||
fun initialize(encryptedPreferences: SharedPreferences) {
|
||||
val stored = encryptedPreferences.getString(KEY, null)
|
||||
configured =
|
||||
if (stored == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
val decoded =
|
||||
try {
|
||||
Json.decodeFromString<Map<String, Map<String, String>>>(stored)
|
||||
} catch (_: SerializationException) {
|
||||
// JSON parser exceptions include the input, which contains credentials.
|
||||
throw IOException("Could not read custom headers")
|
||||
}
|
||||
decoded.map { (url, values) ->
|
||||
ScopedServerHeaders(ServerEndpoint.parseForBuild(url).baseUrl, CustomHeaders.parse(values.toList()))
|
||||
}
|
||||
}
|
||||
preferences = encryptedPreferences
|
||||
}
|
||||
|
||||
fun get(baseUrl: HttpUrl): CustomHeaders =
|
||||
configured.firstOrNull { it.baseUrl == baseUrl }?.headers ?: CustomHeaders.EMPTY
|
||||
|
||||
fun forRequest(url: HttpUrl): ScopedServerHeaders? =
|
||||
configured.filter { it.contains(url) }.maxByOrNull { it.baseUrl.encodedPath.length }
|
||||
|
||||
/** Call on IO. Publish only after the encrypted write succeeds. */
|
||||
@Synchronized
|
||||
fun save(
|
||||
baseUrl: HttpUrl,
|
||||
headers: CustomHeaders,
|
||||
) {
|
||||
val prefs = checkNotNull(preferences) { "Server headers are not initialized" }
|
||||
val updated = configured.filterNot { it.baseUrl == baseUrl } + ScopedServerHeaders(baseUrl, headers)
|
||||
val serialized = Json.encodeToString(updated.associate { it.baseUrl.toString() to it.headers.entries })
|
||||
if (!prefs.edit().putString(KEY, serialized).commit()) throw IOException("Could not save custom headers")
|
||||
configured = updated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP uses this as a network interceptor so redirects are checked before every exchange.
|
||||
* OkHttp skips network interceptors for WebSockets, which use it as an application interceptor
|
||||
* with redirects disabled. Credentials never follow a request outside its original server scope.
|
||||
*/
|
||||
class ServerHeadersInterceptor(
|
||||
private val resolve: (HttpUrl) -> ScopedServerHeaders? = ServerHeaders::forRequest,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val scope = resolve(chain.call().request().url) ?: return chain.proceed(request)
|
||||
val builder = request.newBuilder()
|
||||
val withinScope = scope.contains(request.url) && resolve(request.url)?.baseUrl == scope.baseUrl
|
||||
scope.headers.entries.forEach { (name, value) ->
|
||||
if (withinScope) builder.header(name, value) else builder.removeHeader(name)
|
||||
}
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.m57.hermescontrol.data.repository
|
||||
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
@@ -33,6 +37,10 @@ const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
|
||||
* - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
|
||||
* re-submitting after a timeout or 5xx would repeat expensive provider
|
||||
* work although the request may already have reached the server.
|
||||
* - Connect-never-established failures (unreachable route, DNS blip — the
|
||||
* usual suspects across Wi-Fi/cellular switches) get ONE delayed
|
||||
* re-attempt: that request never reached the server, so no provider work
|
||||
* is duplicated (device follow-up, #1247).
|
||||
*
|
||||
* Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern).
|
||||
*/
|
||||
@@ -54,16 +62,23 @@ open class VoiceNoteRepository(
|
||||
)
|
||||
}
|
||||
val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
|
||||
val result =
|
||||
safeApiCall(retries = 0) {
|
||||
serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio(
|
||||
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
|
||||
)
|
||||
val service = serviceProvider(transcriptionTimeoutMs(dataUrl))
|
||||
val result = requestTranscription(service, dataUrl)
|
||||
// One bounded re-attempt, and only for connection-never-established
|
||||
// failures — the one class where the request provably never reached
|
||||
// the server, so no provider work is duplicated (device follow-up,
|
||||
// #1247).
|
||||
val finalResult =
|
||||
if (result is NetworkResult.Failure && isConnectNeverEstablished(result.error)) {
|
||||
delay(CONNECT_RETRY_DELAY_MS)
|
||||
requestTranscription(service, dataUrl)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
return when (result) {
|
||||
return when (finalResult) {
|
||||
is NetworkResult.Success -> {
|
||||
NetworkResult.Success(
|
||||
result.data
|
||||
finalResult.data
|
||||
?.transcript
|
||||
.orEmpty()
|
||||
.trim(),
|
||||
@@ -71,11 +86,26 @@ open class VoiceNoteRepository(
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
result
|
||||
finalResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestTranscription(
|
||||
service: HermesApiService,
|
||||
dataUrl: String,
|
||||
): NetworkResult<AudioTranscriptionResponse> =
|
||||
safeApiCall(retries = 0) {
|
||||
service.transcribeAudio(
|
||||
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isConnectNeverEstablished(error: NetworkError): Boolean {
|
||||
val cause = (error as? NetworkError.Connection)?.cause ?: return false
|
||||
return cause is ConnectException || cause is UnknownHostException
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
|
||||
const val MIN_REQUEST_TIMEOUT_MS = 180_000L
|
||||
@@ -85,6 +115,9 @@ open class VoiceNoteRepository(
|
||||
|
||||
private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L
|
||||
|
||||
/** One re-attempt window for connection-never-established failures. */
|
||||
private const val CONNECT_RETRY_DELAY_MS = 750L
|
||||
|
||||
/**
|
||||
* Timeout for the blocking STT endpoint, mirroring the desktop's
|
||||
* `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorAccount
|
||||
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
interface AccountConnectorRepository {
|
||||
suspend fun catalog(): AccountConnectorResult<List<ConnectorCatalogEntry>>
|
||||
|
||||
suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
|
||||
suspend fun connect(
|
||||
slugs: List<String>,
|
||||
reconnect: Boolean = false,
|
||||
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
|
||||
|
||||
suspend fun accounts(): AccountConnectorResult<List<ConnectorAccount>>
|
||||
|
||||
suspend fun remove(connectionId: String): AccountConnectorResult<Unit>
|
||||
|
||||
suspend fun tools(
|
||||
slug: String,
|
||||
refresh: Boolean = false,
|
||||
): AccountConnectorResult<List<ConnectorTool>>
|
||||
|
||||
suspend fun policy(): AccountConnectorResult<ConnectorPolicy>
|
||||
|
||||
suspend fun setConnectorEnabled(
|
||||
slug: String,
|
||||
enabled: Boolean,
|
||||
expectedRevision: String,
|
||||
): AccountConnectorResult<ConnectorPolicy>
|
||||
|
||||
suspend fun setDisabledTools(
|
||||
slug: String,
|
||||
disabled: List<String>,
|
||||
expectedRevision: String,
|
||||
): AccountConnectorResult<ConnectorPolicy>
|
||||
|
||||
suspend fun operationRequest(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Any?
|
||||
|
||||
suspend fun operationStatus(
|
||||
opId: String,
|
||||
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
|
||||
|
||||
companion object : AccountConnectorRepository by HermesAccountConnectorRepository()
|
||||
}
|
||||
|
||||
class HermesAccountConnectorRepository(
|
||||
private val rpc: suspend (String, 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()
|
||||
}
|
||||
},
|
||||
) : AccountConnectorRepository {
|
||||
private suspend fun request(
|
||||
method: String,
|
||||
params: Map<String, Any> = emptyMap(),
|
||||
) = rpc(method, params)
|
||||
|
||||
private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> =
|
||||
try {
|
||||
AccountConnectorResult.Success(block())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
AccountConnectorResult.Failure(ConnectorParser.mapRpcError(e.code, e.message, e.data))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
AccountConnectorResult.Failure(ConnectorError.InvalidResponse())
|
||||
} catch (_: IllegalStateException) {
|
||||
AccountConnectorResult.Failure(ConnectorError.InvalidResponse())
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway."))
|
||||
}
|
||||
|
||||
private fun accountParams(params: Map<String, Any> = emptyMap()): Map<String, Any> =
|
||||
buildMap {
|
||||
putAll(params)
|
||||
AuthManager.activeProfileId.value?.let { put("profile", it) }
|
||||
}
|
||||
|
||||
private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject
|
||||
|
||||
private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
|
||||
private fun JsonObject.bool(k: String) = this[k]?.jsonPrimitive?.contentOrNull == "true"
|
||||
|
||||
private fun JsonObject.array(k: String): JsonArray = this[k]?.jsonArray ?: JsonArray(emptyList())
|
||||
|
||||
private fun JsonObject.stringList(k: String) = array(k).mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
|
||||
override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult =
|
||||
try {
|
||||
ConnectorParser.parseListResult(
|
||||
rpc(
|
||||
WsMethods.CONNECTORS_LIST,
|
||||
accountParams(
|
||||
mapOf("owner" to mapOf("type" to "account")),
|
||||
),
|
||||
),
|
||||
)
|
||||
} catch (
|
||||
e: CancellationException,
|
||||
) {
|
||||
throw e
|
||||
} catch (
|
||||
e: HermesWsClient.HermesRpcException,
|
||||
) {
|
||||
com.m57.hermescontrol.data.model.ConnectorListResult.Error(
|
||||
ConnectorParser.mapRpcError(e.code, e.message, e.data),
|
||||
)
|
||||
} catch (
|
||||
_: Exception,
|
||||
) {
|
||||
com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
.Error(ConnectorError.NetworkError())
|
||||
}
|
||||
|
||||
override suspend fun connect(
|
||||
slugs: List<String>,
|
||||
reconnect: Boolean,
|
||||
) = guarded {
|
||||
require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug))
|
||||
parseOperation(
|
||||
request(
|
||||
WsMethods.CONNECTORS_CONNECT,
|
||||
accountParams(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"connectors" to slugs,
|
||||
"reconnect" to reconnect,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun operationRequest(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account"))))
|
||||
|
||||
override suspend fun operationStatus(opId: String) =
|
||||
guarded {
|
||||
parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId)))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun parseOperation(raw: Any?): com.m57.hermescontrol.data.model.ConnectionOperationSnapshot =
|
||||
checkNotNull(
|
||||
ConnectionOperationParser.parse(
|
||||
(ConnectorParser.toJsonElement(raw).toAny() as Map<String, Any?>) +
|
||||
("owner" to mapOf("type" to "account")),
|
||||
),
|
||||
)
|
||||
|
||||
override suspend fun catalog() =
|
||||
guarded {
|
||||
obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map {
|
||||
val row = it.jsonObject
|
||||
ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun accounts() =
|
||||
guarded {
|
||||
obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e ->
|
||||
val r = e.jsonObject
|
||||
ConnectorAccount(
|
||||
r.str("connection_id"),
|
||||
r.str("connector"),
|
||||
r.str("status"),
|
||||
r["status_reason"]?.jsonPrimitive?.contentOrNull,
|
||||
r.str("label"),
|
||||
r["alias"]?.jsonPrimitive?.contentOrNull,
|
||||
r.bool("active"),
|
||||
r.str("created_at"),
|
||||
r.str("updated_at"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(connectionId: String) =
|
||||
guarded {
|
||||
require(connectionId.isNotBlank())
|
||||
val response =
|
||||
obj(
|
||||
request(
|
||||
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||
accountParams(
|
||||
mapOf("connection_id" to connectionId),
|
||||
),
|
||||
),
|
||||
)
|
||||
check(response.str("status") == "removed" && response.str("connection_id") == connectionId)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun tools(
|
||||
slug: String,
|
||||
refresh: Boolean,
|
||||
) = guarded {
|
||||
require(ConnectorRepository.isValidSlug(slug))
|
||||
val params =
|
||||
buildMap<String, Any> {
|
||||
put("slug", slug)
|
||||
if (refresh) put("refresh", true)
|
||||
}
|
||||
obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e ->
|
||||
val r = e.jsonObject
|
||||
ConnectorTool(
|
||||
r.str("slug"),
|
||||
r.str("name"),
|
||||
r.str("description"),
|
||||
r.str("facet"),
|
||||
r.bool("deprecated"),
|
||||
r.stringList("hints"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun policy() =
|
||||
guarded {
|
||||
val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams()))
|
||||
val layers = root.array("layers").map { it.jsonObject }
|
||||
val member = layers.firstOrNull { it.str("kind") == "member" }
|
||||
parsePolicy(root["effective"]!!.jsonObject).copy(
|
||||
member = member?.let { parsePolicy(it["body"]!!.jsonObject, it.str("revision")) },
|
||||
inherited =
|
||||
layers.filter { it.str("kind") != "member" }.map {
|
||||
parsePolicy(it["body"]!!.jsonObject, it.str("revision"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun setConnectorEnabled(
|
||||
slug: String,
|
||||
enabled: Boolean,
|
||||
expectedRevision: String,
|
||||
) = setPolicy(
|
||||
expectedRevision,
|
||||
mapOf(
|
||||
"type" to "connector",
|
||||
"connector" to slug,
|
||||
"enabled" to enabled,
|
||||
),
|
||||
)
|
||||
|
||||
override suspend fun setDisabledTools(
|
||||
slug: String,
|
||||
disabled: List<String>,
|
||||
expectedRevision: String,
|
||||
) = setPolicy(
|
||||
expectedRevision,
|
||||
mapOf(
|
||||
"type" to "tools",
|
||||
"connector" to slug,
|
||||
"disabled_tools" to disabled,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun setPolicy(
|
||||
expectedRevision: String,
|
||||
change: Map<String, Any>,
|
||||
) = guarded {
|
||||
val scope = AuthManager.currentDataScope()
|
||||
require(ConnectorRepository.isValidSlug(change["connector"] as? String))
|
||||
require(expectedRevision.isNotBlank())
|
||||
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
|
||||
val result =
|
||||
obj(
|
||||
request(
|
||||
WsMethods.CONNECTORS_POLICY_SET,
|
||||
accountParams(
|
||||
mapOf(
|
||||
"change" to change,
|
||||
"expected_revision" to expectedRevision,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
|
||||
when (val refreshed = policy()) {
|
||||
is AccountConnectorResult.Success -> refreshed.value
|
||||
is AccountConnectorResult.Failure -> parsePolicy(result["effective"]!!.jsonObject)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePolicy(
|
||||
r: JsonObject,
|
||||
revision: String = r.str("revision"),
|
||||
) = ConnectorPolicy(
|
||||
revision,
|
||||
r.str("mode"),
|
||||
r.stringList("connectors"),
|
||||
r.stringList("disabled_connectors"),
|
||||
(r["tools"] as? JsonObject)?.mapValues { (_, v) ->
|
||||
(v as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
}
|
||||
?: emptyMap(),
|
||||
enabledTags =
|
||||
(r["tags"] as? JsonObject)
|
||||
?.let {
|
||||
(it["enable"] as? JsonArray)?.mapNotNull { tag ->
|
||||
tag.jsonPrimitive.contentOrNull
|
||||
}
|
||||
}.orEmpty(),
|
||||
disabledTags =
|
||||
(r["tags"] as? JsonObject)
|
||||
?.let {
|
||||
(it["disable"] as? JsonArray)?.mapNotNull { tag ->
|
||||
tag.jsonPrimitive.contentOrNull
|
||||
}
|
||||
}.orEmpty(),
|
||||
)
|
||||
}
|
||||
@@ -73,7 +73,6 @@ object CommandBlocklist {
|
||||
"/image",
|
||||
"/quit",
|
||||
// TUI-only extras (meaningless outside the TUI)
|
||||
"/compact",
|
||||
"/logs",
|
||||
"/mouse",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.CatalogScanStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectionCatalogInfo
|
||||
import com.m57.hermescontrol.data.model.ConnectionCatalogScan
|
||||
import com.m57.hermescontrol.data.model.ConnectionEnvField
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
|
||||
@@ -24,7 +27,7 @@ object ConnectionOperationParser {
|
||||
return null
|
||||
}
|
||||
return ConnectionOperationSnapshot(
|
||||
sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() },
|
||||
sessionId = resolveSessionId(payload, sessionId),
|
||||
opId = opId,
|
||||
seq = seq,
|
||||
deadlineAt = deadlineAt,
|
||||
@@ -36,21 +39,38 @@ object ConnectionOperationParser {
|
||||
settled = payload["settled"] as? Boolean ?: false,
|
||||
settledBy = payload["settled_by"] as? String,
|
||||
targets = targets,
|
||||
accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Session that owns the operation. Since hermes-agent v0.21.5 updates carry `owner` (#1281):
|
||||
* session-owned ones name their session; account-owned ones never bind to a chat.
|
||||
*/
|
||||
private fun resolveSessionId(
|
||||
payload: Map<String, Any?>,
|
||||
envelopeSessionId: String?,
|
||||
): String? {
|
||||
val owner = payload["owner"] as? Map<*, *>
|
||||
if (owner?.get("type") == "account") return null
|
||||
val ownerSessionId = (owner?.get("session_id") as? String).takeIf { owner?.get("type") == "session" }
|
||||
return listOf(envelopeSessionId, ownerSessionId, payload["session_id"] as? String)
|
||||
.firstOrNull { !it.isNullOrBlank() }
|
||||
}
|
||||
|
||||
private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? {
|
||||
val map = raw ?: return null
|
||||
val name = (map["name"] as? String)?.trim() ?: return null
|
||||
if (name.isBlank()) return null
|
||||
val kind =
|
||||
enumValue(
|
||||
map["kind"] as? String,
|
||||
ConnectionTargetKind.values(),
|
||||
ConnectionTargetKind.UNKNOWN,
|
||||
)
|
||||
return ConnectionOperationTarget(
|
||||
name = name,
|
||||
kind =
|
||||
enumValue(
|
||||
map["kind"] as? String,
|
||||
ConnectionTargetKind.values(),
|
||||
ConnectionTargetKind.UNKNOWN,
|
||||
),
|
||||
kind = kind,
|
||||
action =
|
||||
enumValue(
|
||||
map["action"] as? String,
|
||||
@@ -75,9 +95,50 @@ object ConnectionOperationParser {
|
||||
?: emptyList(),
|
||||
tools = (map["tools"] as? List<*>)?.filterIsInstance<String>() ?: emptyList(),
|
||||
hint = map["hint"] as? String,
|
||||
catalog = if (kind.isCatalog) parseCatalog(map, name) else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCatalog(
|
||||
map: Map<*, *>,
|
||||
name: String,
|
||||
): ConnectionCatalogInfo =
|
||||
ConnectionCatalogInfo(
|
||||
display = map.text("display") ?: name,
|
||||
description = map.text("description"),
|
||||
tier = map.text("tier"),
|
||||
platforms = map.strings("platforms"),
|
||||
repo = map.text("repo"),
|
||||
sha = map.text("sha"),
|
||||
subdir = map.text("subdir"),
|
||||
scan =
|
||||
(map["scan"] as? Map<*, *>)?.let { scan ->
|
||||
ConnectionCatalogScan(
|
||||
status =
|
||||
enumValue(
|
||||
scan["status"] as? String,
|
||||
CatalogScanStatus.values(),
|
||||
CatalogScanStatus.UNKNOWN,
|
||||
),
|
||||
summary = scan.text("summary"),
|
||||
)
|
||||
},
|
||||
requirements = map.strings("requirements"),
|
||||
targetProfile = map.text("target_profile") ?: DEFAULT_PROFILE,
|
||||
skill = map.text("skill"),
|
||||
)
|
||||
|
||||
private fun Map<*, *>.text(key: String): String? = (this[key] as? String)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun Map<*, *>.strings(key: String): List<String> =
|
||||
(this[key] as? List<*>)
|
||||
?.filterIsInstance<String>()
|
||||
?.map(String::trim)
|
||||
?.filter(String::isNotEmpty)
|
||||
?: emptyList()
|
||||
|
||||
private const val DEFAULT_PROFILE = "default"
|
||||
|
||||
private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? {
|
||||
val map = raw ?: return null
|
||||
val name = (map["name"] as? String)?.trim() ?: return null
|
||||
|
||||
@@ -32,6 +32,9 @@ interface ConnectorRepository {
|
||||
if (slug.isNullOrBlank()) return false
|
||||
return SLUG_REGEX.matches(slug)
|
||||
}
|
||||
|
||||
/** `owner` discriminated union required by connector RPCs since hermes-agent v0.21.5 (#1281). */
|
||||
fun sessionOwner(sessionId: String): Map<String, String> = mapOf("type" to "session", "session_id" to sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ class HermesConnectorRepository(
|
||||
}
|
||||
|
||||
return try {
|
||||
val params = mapOf("session_id" to sessionId)
|
||||
val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId))
|
||||
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
|
||||
ConnectorParser.parseListResult(result)
|
||||
} catch (e: CancellationException) {
|
||||
@@ -105,7 +108,7 @@ class HermesConnectorRepository(
|
||||
return try {
|
||||
val params =
|
||||
mapOf(
|
||||
"session_id" to sessionId,
|
||||
"owner" to ConnectorRepository.sessionOwner(sessionId),
|
||||
"connectors" to connectors,
|
||||
"reconnect" to reconnect,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
enum class PluginSettingFieldType {
|
||||
@SerialName("string")
|
||||
STRING,
|
||||
|
||||
@SerialName("number")
|
||||
NUMBER,
|
||||
|
||||
@SerialName("boolean")
|
||||
BOOLEAN,
|
||||
|
||||
@SerialName("enum")
|
||||
ENUM,
|
||||
|
||||
@SerialName("secret")
|
||||
SECRET,
|
||||
|
||||
@SerialName("json")
|
||||
JSON,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PluginSettingField(
|
||||
val key: String,
|
||||
val type: PluginSettingFieldType,
|
||||
val label: String,
|
||||
val description: String = "",
|
||||
val required: Boolean = false,
|
||||
val value: JsonElement? = null,
|
||||
val default: JsonElement? = null,
|
||||
val choices: List<String>? = null,
|
||||
val env: String? = null,
|
||||
@SerialName("has_value") val hasValue: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class PluginServerState {
|
||||
@SerialName("connected")
|
||||
CONNECTED,
|
||||
|
||||
@SerialName("app_not_running")
|
||||
APP_NOT_RUNNING,
|
||||
|
||||
@SerialName("endpoint_unavailable")
|
||||
ENDPOINT_UNAVAILABLE,
|
||||
|
||||
@SerialName("no_interactive_session")
|
||||
NO_INTERACTIVE_SESSION,
|
||||
|
||||
@SerialName("version_too_old")
|
||||
VERSION_TOO_OLD,
|
||||
|
||||
@SerialName("missing_app")
|
||||
MISSING_APP,
|
||||
|
||||
@SerialName("unknown")
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PluginServerRow(
|
||||
val name: String,
|
||||
val state: PluginServerState,
|
||||
val sentence: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AgentPluginRow(
|
||||
val name: String,
|
||||
val key: String? = null,
|
||||
val version: String = "",
|
||||
val description: String = "",
|
||||
val source: String = "",
|
||||
val status: String = "",
|
||||
val portable: Boolean = false,
|
||||
@SerialName("install_dir") val installDir: String = "",
|
||||
@SerialName("has_desktop_half") val hasDesktopHalf: Boolean = false,
|
||||
val servers: List<PluginServerRow> = emptyList(),
|
||||
@SerialName("settings_schema") val settingsSchema: List<PluginSettingField>? = null,
|
||||
@SerialName("catalog_name") val catalogName: String? = null,
|
||||
@SerialName("catalog_tier") val catalogTier: String? = null,
|
||||
@SerialName("installed_sha") val installedSha: String? = null,
|
||||
@SerialName("catalog_sha") val catalogSha: String? = null,
|
||||
@SerialName("catalog_version") val catalogVersion: String? = null,
|
||||
@SerialName("update_available") val updateAvailable: Boolean? = null,
|
||||
@SerialName("pinned_sha") val pinnedSha: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginsManageSettingsResult(
|
||||
val ok: Boolean = false,
|
||||
val name: String? = null,
|
||||
val written: List<String>? = null,
|
||||
val plugin: AgentPluginRow? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
|
||||
/**
|
||||
* Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC.
|
||||
*/
|
||||
class PluginManageRepository(
|
||||
private val rpcRequest: suspend (String, Map<String, Any?>) -> Any? = { method, params ->
|
||||
val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" })
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (e: CancellationException) {
|
||||
deferred.cancel(e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
) {
|
||||
suspend fun listPlugins(profile: String? = null): List<AgentPluginRow> {
|
||||
val params = mutableMapOf<String, Any?>("action" to "list")
|
||||
if (!profile.isNullOrBlank()) {
|
||||
params["profile"] = profile
|
||||
}
|
||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
|
||||
val element = result.toJsonElement()
|
||||
val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList()
|
||||
return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement)
|
||||
}
|
||||
|
||||
suspend fun saveSettings(
|
||||
key: String,
|
||||
values: Map<String, JsonElement>,
|
||||
profile: String? = null,
|
||||
): PluginsManageSettingsResult {
|
||||
val params =
|
||||
mutableMapOf<String, Any?>(
|
||||
"action" to "settings",
|
||||
"key" to key,
|
||||
"values" to JsonObject(values),
|
||||
)
|
||||
if (!profile.isNullOrBlank()) {
|
||||
params["profile"] = profile
|
||||
}
|
||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
|
||||
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result.toJsonElement())
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ object WsMethods {
|
||||
const val SESSION_DELETE = "session.delete"
|
||||
const val SESSION_TITLE = "session.title"
|
||||
const val SESSION_BRANCH = "session.branch"
|
||||
const val SESSION_BRANCH_WHOLE = "session.branch_whole"
|
||||
const val SESSION_COMPRESS = "session.compress"
|
||||
|
||||
/** Replay recorded events newer than client's last-seen seq for a session. */
|
||||
const val SESSION_EVENTS_SINCE = "session.events.since"
|
||||
@@ -104,12 +106,19 @@ object WsMethods {
|
||||
|
||||
const val CONNECTION_RESPOND = "connection.respond"
|
||||
const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake"
|
||||
const val CONNECTORS_OPERATION_STATUS = "connectors.operation.status"
|
||||
|
||||
/** 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"
|
||||
const val CONNECTORS_CATALOG = "connectors.catalog"
|
||||
const val CONNECTORS_ACCOUNTS = "connectors.accounts"
|
||||
const val CONNECTORS_ACCOUNTS_REMOVE = "connectors.accounts.remove"
|
||||
const val CONNECTORS_TOOLS = "connectors.tools"
|
||||
const val CONNECTORS_POLICY_GET = "connectors.policy.get"
|
||||
const val CONNECTORS_POLICY_SET = "connectors.policy.set"
|
||||
|
||||
// ── Plugins ───────────────────────────────────────────────────────────
|
||||
const val PLUGINS_MANAGE = "plugins.manage"
|
||||
@@ -165,6 +174,12 @@ object WsMethods {
|
||||
SESSION_CREATE, // session.create
|
||||
SESSION_LIST, // session.list
|
||||
MODEL_OPTIONS, // model.options: @_profile_scoped
|
||||
CONNECTORS_CATALOG,
|
||||
CONNECTORS_ACCOUNTS,
|
||||
CONNECTORS_ACCOUNTS_REMOVE,
|
||||
CONNECTORS_TOOLS,
|
||||
CONNECTORS_POLICY_GET,
|
||||
CONNECTORS_POLICY_SET,
|
||||
SESSION_RESUME, // session.resume
|
||||
SESSION_DELETE, // session.delete
|
||||
SESSION_STATUS, // session.status
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.common.CustomHeadersButton
|
||||
|
||||
@Composable
|
||||
fun AuthLoginScreen(
|
||||
@@ -162,7 +163,13 @@ fun AuthLoginScreen(
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
enabled = state.authMode == null,
|
||||
enabled = state.authMode == null && !state.probing && !state.isLoading,
|
||||
)
|
||||
|
||||
CustomHeadersButton(
|
||||
baseUrl = state.baseUrl,
|
||||
enabled = !state.probing && !state.isLoading,
|
||||
onSaved = viewModel::probe,
|
||||
)
|
||||
|
||||
// Cleartext transport warning
|
||||
|
||||
@@ -11,6 +11,21 @@ import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal fun buildLegacyClarifyBatchResponses(
|
||||
questions: List<ClarifyQuestionUi>,
|
||||
answers: Map<String, String>,
|
||||
lockedAnswers: Map<String, String>,
|
||||
): List<Pair<String, String>> =
|
||||
questions
|
||||
.asSequence()
|
||||
// A replayed pending_clarify can contain answers already locked by the
|
||||
// server. ClarifyBubble has no editable input for those rows, so its
|
||||
// answer map carries an empty placeholder. Never send that placeholder
|
||||
// back through legacy clarify.respond and overwrite accepted state.
|
||||
.filterNot { it.qid in lockedAnswers }
|
||||
.map { it.qid to answers[it.qid]?.trim().orEmpty() }
|
||||
.toList()
|
||||
|
||||
class ChatClarifyDelegate(
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val scope: CoroutineScope,
|
||||
@@ -114,14 +129,17 @@ class ChatClarifyDelegate(
|
||||
}
|
||||
|
||||
if (isBatch) {
|
||||
for (q in questions) {
|
||||
val ans = answers[q.qid]?.trim().orEmpty()
|
||||
for ((questionId, ans) in buildLegacyClarifyBatchResponses(
|
||||
questions = questions,
|
||||
answers = answers,
|
||||
lockedAnswers = lockedAnswers,
|
||||
)) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"response" to ans,
|
||||
"answer" to ans,
|
||||
"question_id" to q.qid,
|
||||
"question_id" to questionId,
|
||||
)
|
||||
if (clarifyId != null) {
|
||||
params["clarify_id"] = clarifyId
|
||||
|
||||
+35
-10
@@ -1,6 +1,9 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.ws.ConnectorParser
|
||||
import com.m57.hermescontrol.data.ws.ConnectorRepository
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -57,7 +60,10 @@ internal class ConnectionResumeCheckpoint(
|
||||
/** Session-scoped authoritative reducer for backend-owned connector operations. */
|
||||
class ChatConnectionOperationDelegate(
|
||||
private val requester: ConnectionOperationRequester,
|
||||
private val accountOwned: Boolean,
|
||||
) {
|
||||
constructor(requester: ConnectionOperationRequester) : this(requester, false)
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionOperationUiState())
|
||||
val state: StateFlow<ConnectionOperationUiState> = _state.asStateFlow()
|
||||
private var sessionId: String? = null
|
||||
@@ -167,7 +173,7 @@ class ChatConnectionOperationDelegate(
|
||||
method = WsMethods.CONNECTION_RESPOND,
|
||||
params =
|
||||
mapOf(
|
||||
"session_id" to requireSessionId(),
|
||||
"owner" to ownerParams(),
|
||||
"op_id" to snapshot.opId,
|
||||
"result" to mapOf("targets" to listOf(answer)),
|
||||
),
|
||||
@@ -180,7 +186,7 @@ class ChatConnectionOperationDelegate(
|
||||
method = WsMethods.CONNECTION_RESPOND,
|
||||
params =
|
||||
mapOf(
|
||||
"session_id" to requireSessionId(),
|
||||
"owner" to ownerParams(),
|
||||
"op_id" to snapshot.opId,
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
),
|
||||
@@ -193,7 +199,7 @@ class ChatConnectionOperationDelegate(
|
||||
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
|
||||
dispatch(
|
||||
method = WsMethods.CONNECTORS_OPERATION_WAKE,
|
||||
params = mapOf("session_id" to requireSessionId(), "op_id" to snapshot.opId),
|
||||
params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId),
|
||||
clearOnSuccess = true,
|
||||
unknownOperationSettles = true,
|
||||
)
|
||||
@@ -225,7 +231,7 @@ class ChatConnectionOperationDelegate(
|
||||
_state.value =
|
||||
_state.value.copy(
|
||||
pendingAction = null,
|
||||
error = ConnectionOperationError("request_failed"),
|
||||
error = operationError(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +240,7 @@ class ChatConnectionOperationDelegate(
|
||||
@Synchronized
|
||||
private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? {
|
||||
val current = _state.value.operation ?: return null
|
||||
if (sessionId.isNullOrBlank() ||
|
||||
if ((!accountOwned && sessionId.isNullOrBlank()) ||
|
||||
_state.value.pendingAction != null ||
|
||||
current.opId != action.opId ||
|
||||
current.seq != action.observedSeq
|
||||
@@ -245,10 +251,26 @@ class ChatConnectionOperationDelegate(
|
||||
return current
|
||||
}
|
||||
|
||||
private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean =
|
||||
!sessionId.isNullOrBlank() && snapshot.sessionId == sessionId
|
||||
/** Typed, sanitized error: ownership/runtime failures cannot succeed on retry (#1281). */
|
||||
private fun operationError(error: Exception): ConnectionOperationError {
|
||||
val rpc = error as? HermesWsClient.HermesRpcException ?: return ConnectionOperationError(REQUEST_FAILED)
|
||||
return when (ConnectorParser.mapRpcError(code = rpc.code, data = rpc.data)) {
|
||||
is ConnectorError.NotOwner -> ConnectionOperationError(NOT_OWNER, retryable = false)
|
||||
is ConnectorError.UnsupportedRuntime -> ConnectionOperationError(UNSUPPORTED_RUNTIME, retryable = false)
|
||||
else -> ConnectionOperationError(REQUEST_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() })
|
||||
private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean =
|
||||
if (accountOwned) {
|
||||
snapshot.accountOwned
|
||||
} else {
|
||||
!snapshot.accountOwned && !sessionId.isNullOrBlank() &&
|
||||
snapshot.sessionId == sessionId
|
||||
}
|
||||
|
||||
private fun ownerParams(): Map<String, String> =
|
||||
if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId))
|
||||
|
||||
private fun currentOp(): String =
|
||||
_state.value.operation
|
||||
@@ -263,7 +285,10 @@ class ChatConnectionOperationDelegate(
|
||||
_state.value = ConnectionOperationUiState()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_SETTLED_IDS = 32
|
||||
companion object {
|
||||
private const val MAX_SETTLED_IDS = 32
|
||||
const val REQUEST_FAILED = "request_failed"
|
||||
const val NOT_OWNER = "not_owner"
|
||||
const val UNSUPPORTED_RUNTIME = "unsupported_runtime"
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,12 @@ data class ChatMessage(
|
||||
* Transient; UNKNOWN provenance and the persisted row remain unchanged.
|
||||
*/
|
||||
val isRestoredUnconfirmed: Boolean = false,
|
||||
/**
|
||||
* Gateway SQLite row id (#1285): from REST `id`, the `prompt.submit` `user_row_id`
|
||||
* receipt, or `persisted_turn.final_assistant_row_id`. Null means unproven, never rejected.
|
||||
* Scoped to the owning profile store; transient.
|
||||
*/
|
||||
val serverRowId: Long? = null,
|
||||
)
|
||||
|
||||
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/**
|
||||
* `message.complete` `persisted_turn` (hermes-agent `PersistedTurn`, #1285). Only the anchors Mobile
|
||||
* can bind without inference are kept; `row_ids` for intermediate rows would need positional
|
||||
* guessing. A missing id is unproven, never a rejection, and only [complete] permits retiring the
|
||||
* whole local turn. Row ids are scoped to the owning profile store.
|
||||
*/
|
||||
internal data class PersistedTurn(
|
||||
val complete: Boolean,
|
||||
val userRowId: Long? = null,
|
||||
val finalAssistantRowId: Long? = null,
|
||||
)
|
||||
|
||||
/** Decoded JSON numbers may arrive as Int, Long, or Double; booleans and non-positive ids are not ids. */
|
||||
internal fun positiveRowId(value: Any?): Long? =
|
||||
when (value) {
|
||||
is Int, is Long -> (value as Number).toLong()
|
||||
is Double -> value.takeIf { it % 1.0 == 0.0 }?.toLong()
|
||||
else -> null
|
||||
}?.takeIf { it > 0L }
|
||||
|
||||
internal fun parsePersistedTurn(payload: Map<*, *>?): PersistedTurn? {
|
||||
val raw = payload?.get("persisted_turn") as? Map<*, *> ?: return null
|
||||
return PersistedTurn(
|
||||
complete = raw["complete"] == true,
|
||||
userRowId = positiveRowId(raw["user_row_id"]),
|
||||
finalAssistantRowId = positiveRowId(raw["final_assistant_row_id"]),
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/** Presentation only: retain queued messages internally for persistence and receipt matching. */
|
||||
internal fun messagesWithoutUnsentQueue(
|
||||
messages: List<ChatMessage>,
|
||||
pendingSends: List<PendingSend>,
|
||||
): List<ChatMessage> {
|
||||
val waitingIds =
|
||||
pendingSends
|
||||
.filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
if (waitingIds.isEmpty()) return messages
|
||||
return messages.filterNot {
|
||||
it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,11 @@ import com.m57.hermescontrol.HistoryScreen
|
||||
import com.m57.hermescontrol.LogsScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.reasoningSupport
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
@@ -111,10 +112,13 @@ import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
||||
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.SpeechRequest
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
@@ -173,11 +177,7 @@ fun ChatScreen(
|
||||
// Snapshot-backed search state — read directly so only the scopes that
|
||||
// read its fields recompose on search changes (bar, matched bubbles).
|
||||
val searchState = viewModel.searchState
|
||||
val sourceMessages = timelineState.historyMessages ?: state.messages
|
||||
val displayedMessages =
|
||||
remember(sourceMessages, state.pendingSends) {
|
||||
messagesWithoutUnsentQueue(sourceMessages, state.pendingSends)
|
||||
}
|
||||
val displayedMessages = timelineState.historyMessages ?: state.messages
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -249,6 +249,10 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
val browserEvent = connectorsState.browserLaunchEvent
|
||||
val speechController = rememberChatSpeech()
|
||||
val speakingMessageId by speechController.speakingId.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||
val scrollScope = rememberCoroutineScope()
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
@@ -798,7 +802,20 @@ fun ChatScreen(
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
openingAttachmentPath = state.openingAttachmentPath,
|
||||
isCompressing = state.isCompressing && !timelineState.isHistorical,
|
||||
compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical },
|
||||
onImageClick = { viewingImage = it },
|
||||
speakingMessageId = speakingMessageId,
|
||||
onToggleSpeak = { message ->
|
||||
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
|
||||
speechController.toggle(
|
||||
SpeechRequest(
|
||||
scopeKey = scopeKey,
|
||||
messageId = message.id,
|
||||
text = SpeechText.stripMarkdownForSpeech(message.content),
|
||||
),
|
||||
)
|
||||
},
|
||||
replyErrorContent =
|
||||
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
|
||||
{
|
||||
@@ -882,12 +899,6 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
|
||||
com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
|
||||
sends = state.pendingSends,
|
||||
mainTurnBusy = state.isMainTurnBusy,
|
||||
onSendNow = viewModel::sendQueuedNow,
|
||||
)
|
||||
|
||||
ChatInputBar(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
@@ -898,22 +909,16 @@ fun ChatScreen(
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
},
|
||||
onBusySend = { mode ->
|
||||
if (viewModel.sendMessage(inputFieldValue.text, mode)) {
|
||||
inputFieldValue = TextFieldValue("")
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
},
|
||||
onMicTap = mediaLaunchers.onMicTap,
|
||||
onMicHoldStart = mediaLaunchers.onMicHoldStart,
|
||||
onMicHoldEnd = mediaLaunchers.onMicHoldEnd,
|
||||
onMicHoldCancel = mediaLaunchers.onMicHoldCancel,
|
||||
onMicLock = mediaLaunchers.onMicLock,
|
||||
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
|
||||
isRecordingVoice = mediaLaunchers.isRecordingVoice,
|
||||
voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude,
|
||||
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
|
||||
onStopGeneration = { viewModel.interruptSession() },
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isMainTurnBusy = state.isMainTurnBusy,
|
||||
canInterrupt = state.canInterrupt,
|
||||
isConnected = state.isConnected,
|
||||
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
||||
|
||||
@@ -21,6 +21,8 @@ data class PendingSend(
|
||||
val attempts: Int = 0,
|
||||
/** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */
|
||||
val requiresAttachmentRecovery: Boolean = false,
|
||||
/** `prompt.submit` `user_row_id` receipt (#1285); null means unproven, not rejected. */
|
||||
val userRowId: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||
@@ -179,13 +181,30 @@ internal fun mapServerMessages(
|
||||
?: existingById[restId]?.timestamp
|
||||
?: System.currentTimeMillis()
|
||||
|
||||
val rawContent = msg.displayContentText ?: msg.contentText
|
||||
val rowReasoning =
|
||||
msg.reasoningText.ifBlank {
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
reasoningSources[index]?.reasoningText.orEmpty()
|
||||
val rawContent =
|
||||
if (role == MessageRole.TOOL && msg.display_metadata != null) {
|
||||
// Enrich tool content with display_metadata so persisted edit previews
|
||||
// (inline_diff) survive reload into the diff renderer
|
||||
val parsedContent = ToolJson.parseMaybeObject(msg.content)
|
||||
if (parsedContent != null && !parsedContent.containsKey("display_metadata")) {
|
||||
JsonObject(parsedContent + ("display_metadata" to msg.display_metadata)).toString()
|
||||
} else {
|
||||
existingById[restId]?.reasoningText.orEmpty()
|
||||
msg.visibleText
|
||||
}
|
||||
} else {
|
||||
msg.visibleText
|
||||
}
|
||||
// #1284: projected reasoning already excludes public commentary; it is authoritative.
|
||||
val rowReasoning =
|
||||
if (msg.hasDisplayReasoning) {
|
||||
msg.displayReasoningText
|
||||
} else {
|
||||
msg.reasoningText.ifBlank {
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
reasoningSources[index]?.reasoningText.orEmpty()
|
||||
} else {
|
||||
existingById[restId]?.reasoningText.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +273,7 @@ internal fun mapServerMessages(
|
||||
displayKind = msg.display_kind,
|
||||
tokenCount = tokenCount,
|
||||
completionId = completionId,
|
||||
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -269,12 +289,13 @@ internal fun mapServerMessages(
|
||||
// A canonical tool-result row settles a cached tool.start, not the reverse.
|
||||
local.copy(
|
||||
restId = message.canonicalRestId,
|
||||
serverRowId = message.serverRowId,
|
||||
content = message.content,
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
isHistoricalCache = false,
|
||||
)
|
||||
} else {
|
||||
local?.copy(restId = message.canonicalRestId) ?: message
|
||||
local?.copy(restId = message.canonicalRestId, serverRowId = message.serverRowId) ?: message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,8 @@ internal class TranscriptComparison(
|
||||
b: ChatMessage,
|
||||
): Boolean {
|
||||
if (a.role != b.role) return false
|
||||
// #1285: two proven row ids decide identity; a missing one is unproven, so fall through.
|
||||
if (a.serverRowId != null && b.serverRowId != null) return a.serverRowId == b.serverRowId
|
||||
val aRestId = a.canonicalRestId
|
||||
val bRestId = b.canonicalRestId
|
||||
if (aRestId != null && bRestId != null) return aRestId == bRestId
|
||||
@@ -213,10 +215,20 @@ internal fun matchTranscriptMessages(
|
||||
val byId = existing.withIndex().associate { it.value.id to it.index }
|
||||
val byRestId =
|
||||
existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap()
|
||||
val byRowId =
|
||||
existing
|
||||
.withIndex()
|
||||
.mapNotNull { (index, message) ->
|
||||
message.serverRowId?.let { (message.role to it) to index }
|
||||
}.toMap()
|
||||
val used = BooleanArray(existing.size)
|
||||
val matches = arrayOfNulls<ChatMessage>(incoming.size)
|
||||
incoming.forEachIndexed { index, message ->
|
||||
(byId[message.id] ?: message.canonicalRestId?.let { byRestId[it] })?.let { match ->
|
||||
(
|
||||
byId[message.id]
|
||||
?: message.canonicalRestId?.let { byRestId[it] }
|
||||
?: message.serverRowId?.let { byRowId[message.role to it] }
|
||||
)?.let { match ->
|
||||
if (!used[match]) {
|
||||
used[match] = true
|
||||
matches[index] = existing[match]
|
||||
@@ -333,6 +345,7 @@ internal fun dedupeCachedMessages(
|
||||
aliases[message.id]?.let {
|
||||
message.copy(
|
||||
restId = it.canonicalRestId,
|
||||
serverRowId = message.serverRowId ?: it.serverRowId,
|
||||
completionId = message.completionId ?: it.completionId,
|
||||
displayKind = message.displayKind ?: it.displayKind,
|
||||
isRestoredUnconfirmed = false,
|
||||
@@ -402,6 +415,7 @@ internal fun mergeCachedTranscriptPage(
|
||||
id = match.id,
|
||||
content = preservedContent ?: rich.content,
|
||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||
serverRowId = match.serverRowId ?: message.serverRowId,
|
||||
completionId = match.completionId ?: message.completionId,
|
||||
displayKind = match.displayKind ?: message.displayKind,
|
||||
isRestoredUnconfirmed =
|
||||
@@ -439,6 +453,7 @@ internal fun mergeTranscriptWithLive(
|
||||
match?.role == MessageRole.USER -> {
|
||||
match.copy(
|
||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||
displayKind = message.displayKind ?: match.displayKind,
|
||||
isHistoricalCache = false,
|
||||
isRestoredUnconfirmed = false,
|
||||
@@ -457,6 +472,7 @@ internal fun mergeTranscriptWithLive(
|
||||
}
|
||||
match.copy(
|
||||
restId = message.canonicalRestId ?: match.canonicalRestId,
|
||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||
content = mergedContent,
|
||||
timestamp = message.timestamp,
|
||||
isStreaming = message.isStreaming,
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.model.SessionCompressResponse
|
||||
import com.m57.hermescontrol.data.model.SessionTimelineEntry
|
||||
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||
@@ -234,6 +235,8 @@ data class ChatUiState(
|
||||
// until the first successful session.usage fetch) — drives the
|
||||
// "compressed ×N" badge on the context chip.
|
||||
val compressionCount: Int? = null,
|
||||
val isCompressing: Boolean = false,
|
||||
val compressionStatus: String? = null,
|
||||
/** Rolling output tokens/sec over the last ~10 calls. */
|
||||
val latestTps: Double? = null,
|
||||
/** Latest cumulative backend usage snapshot for the current session. */
|
||||
@@ -483,7 +486,13 @@ class ChatViewModel(
|
||||
val originalPending: PendingSend? = null,
|
||||
)
|
||||
|
||||
private data class PendingBranchRequest(
|
||||
val generation: Long,
|
||||
val params: Map<String, Any>,
|
||||
)
|
||||
|
||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
||||
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
||||
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
||||
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
||||
@@ -1063,6 +1072,7 @@ class ChatViewModel(
|
||||
if (isCurrentSession(event.sessionId)) {
|
||||
mainTurnBusy = false
|
||||
lastMainCompletionAt = System.currentTimeMillis()
|
||||
retireReceiptForPersistedTurn(parsePersistedTurn(event.rawPayload))
|
||||
refreshSendReceipts()
|
||||
}
|
||||
// Buffers cleared before reduce; ViewModel resets them after
|
||||
@@ -1602,7 +1612,8 @@ class ChatViewModel(
|
||||
result: Any?,
|
||||
) {
|
||||
val messageId = outgoingRequestById.remove(requestId) ?: return
|
||||
val status = rpcResultMap(result)?.get("status") as? String
|
||||
val resultMap = rpcResultMap(result)
|
||||
val status = resultMap?.get("status") as? String
|
||||
val accepted =
|
||||
when (method) {
|
||||
WsMethods.SESSION_REDIRECT -> status == "redirected" || status == "queued"
|
||||
@@ -1611,6 +1622,24 @@ class ChatViewModel(
|
||||
}
|
||||
when {
|
||||
accepted -> {
|
||||
// #1285: the submit ack names the row written for THIS input. Absent = unproven.
|
||||
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
|
||||
sendStore.update(messageId) { it.copy(userRowId = rowId) }
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
messages =
|
||||
state.messages.map {
|
||||
if (it.id == messageId &&
|
||||
it.serverRowId == null
|
||||
) {
|
||||
it.copy(serverRowId = rowId)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
markPendingSend(messageId, PendingSendState.ACCEPTED)
|
||||
if (sendStore.all().any { it.id == messageId && lastMainCompletionAt >= it.createdAt }) {
|
||||
refreshSendReceipts()
|
||||
@@ -1643,6 +1672,7 @@ class ChatViewModel(
|
||||
) {
|
||||
val method = idToMethod.remove(id) ?: return
|
||||
val request = sessionRequestById.remove(id)
|
||||
branchWholeRequests.remove(id)
|
||||
if (request != null && isStaleSessionRequest(request)) {
|
||||
outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) }
|
||||
hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) }
|
||||
@@ -1729,7 +1759,9 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
WsMethods.SESSION_BRANCH -> {
|
||||
WsMethods.SESSION_BRANCH,
|
||||
WsMethods.SESSION_BRANCH_WHOLE,
|
||||
-> {
|
||||
val resultMap = result as? Map<String, Any?> ?: return
|
||||
// The result carries BOTH ids: `session_id` is the runtime
|
||||
// registry id, `stored_session_id` is the DB key. currentSessionId
|
||||
@@ -2066,6 +2098,7 @@ class ChatViewModel(
|
||||
) {
|
||||
val method = idToMethod.remove(id) ?: return
|
||||
val request = sessionRequestById.remove(id)
|
||||
val pendingBranch = branchWholeRequests.remove(id)
|
||||
if (request != null && isStaleSessionRequest(request)) {
|
||||
outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) }
|
||||
hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) }
|
||||
@@ -2145,6 +2178,26 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
if (method == WsMethods.SESSION_BRANCH_WHOLE) {
|
||||
val code =
|
||||
(error as? JsonRpcError)?.code
|
||||
?: (error as? Map<*, *>)?.get("code") as? Int
|
||||
if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) {
|
||||
val params = pendingBranch.params
|
||||
val generation = pendingBranch.generation
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_BRANCH,
|
||||
params,
|
||||
onSent = { branchId ->
|
||||
trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation)
|
||||
},
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Surface error in UI (these are server-pushed RpcError for
|
||||
// fire-and-forget RPCs — awaited RPCs handle their own failure
|
||||
// via the HermesWsClient.request() deferred).
|
||||
@@ -2489,6 +2542,23 @@ class ChatViewModel(
|
||||
publishPendingSends()
|
||||
}
|
||||
|
||||
/**
|
||||
* #1285: only a complete receipt proves the whole turn is durable, and only the receipt
|
||||
* already bound to that exact `user_row_id` is retired. Partial or id-less receipts leave
|
||||
* every local row to REST reconciliation; no row is ever inferred from text or position.
|
||||
*/
|
||||
private fun retireReceiptForPersistedTurn(turn: PersistedTurn?) {
|
||||
val userRowId = turn?.userRowId?.takeIf { turn.complete } ?: return
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
val scope = sendScope()
|
||||
sendStore
|
||||
.all()
|
||||
.filter {
|
||||
it.scope == scope && it.sessionId == sessionId && it.userRowId == userRowId &&
|
||||
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
||||
}.forEach { removePendingSend(it.id) }
|
||||
}
|
||||
|
||||
private fun removeUnconfirmedBubble(messageId: String) {
|
||||
val row = _uiState.value.messages.firstOrNull { it.id == messageId && it.canonicalRestId == null } ?: return
|
||||
_uiState.update { state -> state.copy(messages = state.messages.filterNot { it.id == row.id }) }
|
||||
@@ -3634,6 +3704,10 @@ class ChatViewModel(
|
||||
handleUndoCommand(result.count)
|
||||
}
|
||||
|
||||
is SlashResult.Compress -> {
|
||||
compressSession(result.focusTopic)
|
||||
}
|
||||
|
||||
is SlashResult.RpcDispatch -> {
|
||||
dispatchViaRpc(command)
|
||||
}
|
||||
@@ -3903,9 +3977,8 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the active conversation via the session.branch WS RPC (issue #533).
|
||||
* The backend already supports session.branch; the mobile previously had
|
||||
* no client surface, so `/fork` fell through to command.dispatch and 4018'd.
|
||||
* Fork the active conversation via the lightweight session.branch_whole WS RPC (issue #1289).
|
||||
* Falls back to session.branch if the gateway answers -32601 (unknown method).
|
||||
* The optional arg becomes the new branch's title.
|
||||
*/
|
||||
private fun branchSession(command: String) {
|
||||
@@ -3920,9 +3993,12 @@ class ChatViewModel(
|
||||
val generation = sessionGeneration
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_BRANCH,
|
||||
WsMethods.SESSION_BRANCH_WHOLE,
|
||||
params,
|
||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_BRANCH, generation) },
|
||||
onSent = { id ->
|
||||
branchWholeRequests[id] = PendingBranchRequest(generation, params)
|
||||
trackSessionRequest(id, WsMethods.SESSION_BRANCH_WHOLE, generation)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4056,6 +4132,113 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun compressSession(focusTopic: String) {
|
||||
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
|
||||
if (sessionId == null) {
|
||||
addAssistantMessage("No active session to compress.")
|
||||
return
|
||||
}
|
||||
if (_uiState.value.isCompressing) {
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCompressing = true,
|
||||
compressionStatus = "⏳ Compressing context...",
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
val params =
|
||||
buildMap<String, Any> {
|
||||
put("session_id", sessionId)
|
||||
if (focusTopic.isNotBlank()) {
|
||||
put("focus_topic", focusTopic)
|
||||
}
|
||||
}
|
||||
// Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout
|
||||
val result =
|
||||
wsClient
|
||||
.request(
|
||||
WsMethods.SESSION_COMPRESS,
|
||||
params,
|
||||
timeoutMs = 300_000L,
|
||||
).await()
|
||||
|
||||
handleCompressionResult(result)
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
addAssistantMessage("/compress: ${e.message ?: "compression failed"}")
|
||||
} catch (e: Exception) {
|
||||
addAssistantMessage("/compress: ${e.message ?: "compression failed"}")
|
||||
} finally {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleCompressionResult(result: Any?) {
|
||||
val response =
|
||||
try {
|
||||
val jsonElement = result.toJsonElement()
|
||||
OkHttpProvider.json.decodeFromJsonElement<SessionCompressResponse>(jsonElement)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode session.compress response", e)
|
||||
null
|
||||
}
|
||||
|
||||
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
|
||||
|
||||
// 1. Authoritative transcript replacement
|
||||
if (response?.messages != null && sessionId != null) {
|
||||
val replacementMessages =
|
||||
withContext(historyDispatcher) {
|
||||
mapServerMessages(
|
||||
sessionId = sessionId,
|
||||
messages = response.messages,
|
||||
offset = 0,
|
||||
latestPaging = latestPaging,
|
||||
liveMessages = emptyList(),
|
||||
context = getApplication(),
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(messages = replacementMessages) }
|
||||
persistHistoryPage(replacementMessages, sessionId)
|
||||
}
|
||||
|
||||
// 2. Display result summary via addAssistantMessage
|
||||
val summary = response?.summary
|
||||
val lines = mutableListOf<String>()
|
||||
summary?.headline?.takeIf { it.isNotBlank() }?.let { lines.add(it) }
|
||||
summary?.token_line?.takeIf { it.isNotBlank() }?.let { lines.add(it) }
|
||||
summary?.note?.takeIf { it.isNotBlank() }?.let { lines.add(it) }
|
||||
|
||||
val feedback =
|
||||
if (lines.isNotEmpty()) {
|
||||
lines.joinToString("\n")
|
||||
} else {
|
||||
response?.message ?: "Context compressed."
|
||||
}
|
||||
addAssistantMessage(feedback)
|
||||
|
||||
// 3. Refresh usage & sessions
|
||||
fetchContextUsage()
|
||||
loadSessions()
|
||||
|
||||
// 4. Handle lineage/session info if provided
|
||||
response?.info?.let { infoElement ->
|
||||
val infoMap = (infoElement.toAny() as? Map<*, *>)?.filterKeys { it is String } as? Map<String, Any?>
|
||||
handleSessionInfo(infoMap)
|
||||
}
|
||||
sessionHasServerPresence = true
|
||||
}
|
||||
|
||||
private fun handlePrefillResult(
|
||||
message: String,
|
||||
notice: String,
|
||||
@@ -4648,6 +4831,7 @@ class ChatViewModel(
|
||||
content = it.text,
|
||||
attachments = it.attachments,
|
||||
timestamp = it.createdAt,
|
||||
serverRowId = it.userRowId,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5968,6 +6152,7 @@ class ChatViewModel(
|
||||
private fun forgetRequest(id: String) {
|
||||
idToMethod.remove(id)
|
||||
sessionRequestById.remove(id)
|
||||
branchWholeRequests.remove(id)
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -208,7 +208,7 @@ object ChatWsEventReducer {
|
||||
|
||||
is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState)
|
||||
|
||||
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
|
||||
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState, event)
|
||||
|
||||
is WsEvent.ConnectionRequest,
|
||||
is WsEvent.ConnectionUpdate,
|
||||
@@ -515,6 +515,9 @@ object ChatWsEventReducer {
|
||||
effects = effects,
|
||||
)
|
||||
}
|
||||
// #1285: bind the committed final row only when this bubble carries the whole reply body;
|
||||
// a stripped commentary prefix means the local bubble is not that row's exact content.
|
||||
val finalRowId = parsePersistedTurn(event.rawPayload)?.finalAssistantRowId?.takeIf { text == event.text }
|
||||
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
|
||||
val tokenCount =
|
||||
if (turnUsage?.outputTokens != null) {
|
||||
@@ -531,6 +534,7 @@ object ChatWsEventReducer {
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = event.completionId,
|
||||
serverRowId = finalRowId ?: streaming.serverRowId,
|
||||
) ?: ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = text,
|
||||
@@ -539,6 +543,7 @@ object ChatWsEventReducer {
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = event.completionId,
|
||||
serverRowId = finalRowId,
|
||||
)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
@@ -1041,7 +1046,38 @@ object ChatWsEventReducer {
|
||||
private fun onStatusUpdate(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
): ReducerResult = ReducerResult(state = state, streamingState = streamingState)
|
||||
event: WsEvent.StatusUpdate,
|
||||
): ReducerResult {
|
||||
val kind = event.data?.get("kind") as? String
|
||||
val text = event.data?.get("text") as? String
|
||||
return when (kind) {
|
||||
"compressing", "compacting" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = true,
|
||||
compressionStatus = text ?: "⏳ Compressing context...",
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
"compacted" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = false,
|
||||
compressionStatus = text,
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ReducerResult(state = state, streamingState = streamingState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUnknown(
|
||||
state: ChatUiState,
|
||||
|
||||
@@ -10,7 +10,8 @@ package com.m57.hermescontrol.ui.chat
|
||||
* `/fork` and `/model` are NOT sent via [SlashResult.RpcDispatch] (which maps
|
||||
* to the `command.dispatch` RPC — that RPC only knows quick/plugin/bundle/skill
|
||||
* commands and 4018s on everything else). They are real backend commands that
|
||||
* get their own results: `/fork` goes via the `session.branch` RPC and `/model`
|
||||
* get their own results: `/fork` goes via the `session.branch_whole` RPC (with
|
||||
* fallback to `session.branch` on older gateways) and `/model`
|
||||
* via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the
|
||||
* TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as
|
||||
* a normal prompt makes the LLM treat it as text). `/update` is intercepted
|
||||
@@ -76,6 +77,11 @@ class SlashCommandDispatcher {
|
||||
SlashResult.Undo(count = arg)
|
||||
}
|
||||
|
||||
"/compress", "/compact" -> {
|
||||
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||
SlashResult.Compress(focusTopic = arg)
|
||||
}
|
||||
|
||||
else -> {
|
||||
SlashResult.RpcDispatch
|
||||
}
|
||||
@@ -171,4 +177,11 @@ sealed class SlashResult {
|
||||
data class Undo(
|
||||
val count: String,
|
||||
) : SlashResult()
|
||||
|
||||
/**
|
||||
* Compress session context via the session.compress WebSocket RPC.
|
||||
*/
|
||||
data class Compress(
|
||||
val focusTopic: String,
|
||||
) : SlashResult()
|
||||
}
|
||||
@@ -179,7 +179,7 @@ internal fun ToolBubble(
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
// ── Header row: icon + tool name ──
|
||||
HeaderRow(message, config, contentColor, statusColors)
|
||||
HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName)
|
||||
|
||||
// ── Tool progress preview (tool.progress) ──
|
||||
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
|
||||
@@ -528,6 +528,7 @@ private fun HeaderRow(
|
||||
config: ToolDisplayConfig,
|
||||
contentColor: Color,
|
||||
statusColors: HermesStatusColors,
|
||||
displayName: String? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -562,7 +563,7 @@ private fun HeaderRow(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
||||
text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
color = contentColor,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolView
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
@@ -42,8 +43,31 @@ fun parseToolOutput(
|
||||
// Extract args sub-object if present (new tool.complete format) and
|
||||
// the result sub-object, falling back to the top-level payload (old
|
||||
// format where result fields sat at the root).
|
||||
val args = element["args"] as? JsonObject
|
||||
val result = element["result"] as? JsonObject ?: element
|
||||
val rawArgs = element["args"] as? JsonObject
|
||||
// Gateway labels are envelope metadata; never interpret a real args.labels value as display text.
|
||||
val labels = element["labels"] as? JsonArray
|
||||
val args =
|
||||
if (labels != null) {
|
||||
JsonObject(rawArgs.orEmpty() + ("hermes_tool_labels" to labels))
|
||||
} else {
|
||||
rawArgs
|
||||
}
|
||||
val rawResultObj = element["result"] as? JsonObject
|
||||
val displayMetadata = element["display_metadata"]
|
||||
val result =
|
||||
when {
|
||||
rawResultObj != null && displayMetadata != null && !rawResultObj.containsKey("display_metadata") -> {
|
||||
JsonObject(rawResultObj + ("display_metadata" to displayMetadata))
|
||||
}
|
||||
|
||||
rawResultObj != null -> {
|
||||
rawResultObj
|
||||
}
|
||||
|
||||
else -> {
|
||||
element
|
||||
}
|
||||
}
|
||||
|
||||
ToolViewBuilder.build(
|
||||
toolName = resolvedToolName ?: "tool",
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
private const val TTS_CACHE_DIR_NAME = "speech_tts"
|
||||
private const val TTS_FILE_PREFIX = "tts_"
|
||||
private const val TTS_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000L // 24 hours
|
||||
|
||||
class FileSpeechAudio(
|
||||
val file: File,
|
||||
) : SpeechAudio {
|
||||
override fun dispose() {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android implementation of [SpeechSynthesizer] backed by Hermes TTS REST API.
|
||||
*
|
||||
* Audio is decoded from base64 data URLs directly to temporary files in application cache.
|
||||
* Stale orphan cache files (created in previous sessions or left behind by unexpected crashes)
|
||||
* older than [TTS_STALE_CACHE_MAX_AGE_MS] are opportunistically pruned during synthesis on the
|
||||
* IO dispatcher before creating new files.
|
||||
*
|
||||
* Limitation note: Single-active controller usage is expected. Active audio files in current use
|
||||
* by the active session are protected from cleanup by the age threshold, but concurrent multi-process
|
||||
* or out-of-band manipulation of cache files is not synchronized.
|
||||
*/
|
||||
class AndroidSpeechSynthesizer(
|
||||
context: Context,
|
||||
private val api: HermesApiService,
|
||||
private val profile: String?,
|
||||
) : SpeechSynthesizer {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
|
||||
val response =
|
||||
api.speakText(
|
||||
request = TtsSpeakRequest(request.text),
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("TTS request failed with code ${response.code()}")
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw IOException("Empty TTS response body")
|
||||
if (!body.ok) {
|
||||
throw IOException("TTS synthesis reported failure")
|
||||
}
|
||||
|
||||
val dataUrl = body.data_url
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Missing data URL in TTS response")
|
||||
}
|
||||
|
||||
var ownedFile: File? = null
|
||||
try {
|
||||
val audioFile =
|
||||
withContext(Dispatchers.IO) {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
if (comma < 0) {
|
||||
throw IOException("Malformed data URL in TTS response")
|
||||
}
|
||||
|
||||
val meta = dataUrl.substring(0, comma)
|
||||
if (!meta.startsWith("data:audio/", ignoreCase = true)) {
|
||||
throw IOException("Expected audio data URL")
|
||||
}
|
||||
if (!meta.endsWith(";base64", ignoreCase = true)) {
|
||||
throw IOException("Expected base64-encoded audio data URL")
|
||||
}
|
||||
|
||||
val ext =
|
||||
when {
|
||||
meta.contains(
|
||||
"audio/mpeg",
|
||||
ignoreCase = true,
|
||||
) || meta.contains("audio/mp3", ignoreCase = true) -> "mp3"
|
||||
|
||||
meta.contains("audio/wav", ignoreCase = true) -> "wav"
|
||||
|
||||
meta.contains("audio/ogg", ignoreCase = true) -> "ogg"
|
||||
|
||||
meta.contains("audio/aac", ignoreCase = true) -> "aac"
|
||||
|
||||
meta.contains("audio/opus", ignoreCase = true) -> "opus"
|
||||
|
||||
meta.contains("audio/flac", ignoreCase = true) -> "flac"
|
||||
|
||||
else -> "audio"
|
||||
}
|
||||
|
||||
val cacheDir = File(appContext.cacheDir, TTS_CACHE_DIR_NAME).apply { mkdirs() }
|
||||
cleanStaleCacheFiles(cacheDir)
|
||||
|
||||
val file = File(cacheDir, "${TTS_FILE_PREFIX}${UUID.randomUUID()}.$ext")
|
||||
ownedFile = file
|
||||
|
||||
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
|
||||
val decodingStream = Base64.getDecoder().wrap(rawStream)
|
||||
|
||||
var totalBytesWritten = 0L
|
||||
decodingStream.use { input ->
|
||||
FileOutputStream(file).use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalBytesWritten += bytesRead
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Java's Base64.getDecoder().wrap() stops decoding upon encountering padding ('=').
|
||||
// Verify that the underlying stream contains no unparsed trailing non-whitespace characters.
|
||||
if (rawStream.read() != -1) {
|
||||
throw IOException("Trailing garbage characters found after base64 data")
|
||||
}
|
||||
|
||||
if (totalBytesWritten == 0L) {
|
||||
throw IOException("Decoded audio file is empty")
|
||||
}
|
||||
|
||||
file
|
||||
}
|
||||
|
||||
ownedFile = null
|
||||
return FileSpeechAudio(audioFile)
|
||||
} catch (e: CancellationException) {
|
||||
ownedFile?.let { runCatching { it.delete() } }
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
ownedFile?.let { runCatching { it.delete() } }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanStaleCacheFiles(cacheDir: File) {
|
||||
val now = System.currentTimeMillis()
|
||||
val files =
|
||||
cacheDir.listFiles { _, name ->
|
||||
name.startsWith(TTS_FILE_PREFIX)
|
||||
} ?: return
|
||||
|
||||
for (file in files) {
|
||||
if (now - file.lastModified() > TTS_STALE_CACHE_MAX_AGE_MS) {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CharSequenceInputStream(
|
||||
private val value: CharSequence,
|
||||
startIndex: Int,
|
||||
) : InputStream() {
|
||||
private var index = startIndex
|
||||
|
||||
override fun read(): Int {
|
||||
while (index < value.length) {
|
||||
val c = value[index++]
|
||||
if (!c.isWhitespace()) {
|
||||
if (c.code > 127) {
|
||||
throw IOException("Non-ASCII character in base64 data")
|
||||
}
|
||||
return c.code
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int {
|
||||
if (len == 0) return 0
|
||||
var bytesRead = 0
|
||||
while (bytesRead < len) {
|
||||
val c = read()
|
||||
if (c == -1) {
|
||||
return if (bytesRead == 0) -1 else bytesRead
|
||||
}
|
||||
b[off + bytesRead] = c.toByte()
|
||||
bytesRead++
|
||||
}
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AndroidSpeechPlayer(
|
||||
context: Context,
|
||||
) : SpeechPlayer {
|
||||
private val appContext = context.applicationContext
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
private var activeListener: Player.Listener? = null
|
||||
|
||||
override fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
if (audio !is FileSpeechAudio) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
val file = audio.file
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
val player =
|
||||
exoPlayer ?: ExoPlayer
|
||||
.Builder(appContext)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
true,
|
||||
).setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
.also { exoPlayer = it }
|
||||
|
||||
activeListener?.let { player.removeListener(it) }
|
||||
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
player.removeListener(this)
|
||||
activeListener = null
|
||||
onEnded()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
player.removeListener(this)
|
||||
activeListener = null
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
activeListener = listener
|
||||
player.addListener(listener)
|
||||
|
||||
val mediaItem = MediaItem.fromUri(Uri.fromFile(file))
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val player = exoPlayer ?: return
|
||||
activeListener?.let { player.removeListener(it) }
|
||||
activeListener = null
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
player.release()
|
||||
exoPlayer = null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -16,11 +18,14 @@ 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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -30,6 +35,7 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -47,9 +53,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -62,7 +73,6 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
@@ -79,11 +89,9 @@ fun ChatInputBar(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onBusySend: (BusySendMode) -> Unit = {},
|
||||
onMicTap: () -> Unit,
|
||||
isListening: Boolean,
|
||||
isAgentTyping: Boolean,
|
||||
isMainTurnBusy: Boolean = false,
|
||||
canInterrupt: Boolean = false,
|
||||
isConnected: Boolean,
|
||||
commandCatalog: CommandCatalog,
|
||||
@@ -113,8 +121,9 @@ fun ChatInputBar(
|
||||
onMicHoldStart: () -> Unit = {},
|
||||
onMicHoldEnd: () -> Unit = {},
|
||||
onMicHoldCancel: () -> Unit = {},
|
||||
onMicLock: () -> Unit = {},
|
||||
isVoiceNoteLocked: Boolean = false,
|
||||
isRecordingVoice: Boolean = false,
|
||||
voiceNoteAmplitude: State<Float> = remember { mutableStateOf(0f) },
|
||||
onStopGeneration: () -> Unit = {},
|
||||
) {
|
||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||
@@ -125,265 +134,408 @@ fun ChatInputBar(
|
||||
pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/")
|
||||
val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
// The voice-note strip replaces the input field, so the IME closes the
|
||||
// moment recording starts. When the strip goes away (send, delete, or
|
||||
// cancel), focus returns to the input and the keyboard re-opens if it was
|
||||
// up before — the next message must not need an extra tap (device
|
||||
// follow-up, #1247).
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
val softwareKeyboard = LocalSoftwareKeyboardController.current
|
||||
var inputWasFocused by remember { mutableStateOf(false) }
|
||||
var restoreInputFocus by remember { mutableStateOf(false) }
|
||||
// Snapshot BEFORE the launcher flips isRecordingVoice — by the time the
|
||||
// recomposition swaps the field out, focus is already on its way to the
|
||||
// root, so the effect alone cannot read it reliably.
|
||||
val handleMicHoldStart = {
|
||||
restoreInputFocus = inputWasFocused
|
||||
onMicHoldStart()
|
||||
}
|
||||
// A hold can arm and still fail to start the recorder (permission denied,
|
||||
// mic busy). Drop the focus snapshot then, so the end of some later
|
||||
// recording cannot pop the keyboard on its behalf (review, PR #1280).
|
||||
val handleMicHoldEnd = {
|
||||
if (!isRecordingVoice) restoreInputFocus = false
|
||||
onMicHoldEnd()
|
||||
}
|
||||
val handleMicHoldCancel = {
|
||||
if (!isRecordingVoice) restoreInputFocus = false
|
||||
onMicHoldCancel()
|
||||
}
|
||||
LaunchedEffect(isRecordingVoice) {
|
||||
if (!isRecordingVoice && restoreInputFocus) {
|
||||
restoreInputFocus = false
|
||||
inputFocusRequester.requestFocus()
|
||||
softwareKeyboard?.show()
|
||||
}
|
||||
}
|
||||
// Telegram parity: the strip's hint slides and fades with the drag, so
|
||||
// the gesture reports its 1 → 0 cancel fraction here.
|
||||
val voiceSlideProgress = remember { mutableStateOf(1f) }
|
||||
// The "Slide up to lock recording" tooltip shows until the first
|
||||
// successful lock, then stays away (persisted, like Telegram).
|
||||
val context = LocalContext.current
|
||||
var voiceLockHintDone by remember {
|
||||
mutableStateOf(
|
||||
context
|
||||
.getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(VOICE_LOCK_HINT_KEY, false),
|
||||
)
|
||||
}
|
||||
LaunchedEffect(isVoiceNoteLocked) {
|
||||
if (isVoiceNoteLocked && !voiceLockHintDone) {
|
||||
voiceLockHintDone = true
|
||||
context
|
||||
.getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(VOICE_LOCK_HINT_KEY, true)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
val palette = composerPalette()
|
||||
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
) {
|
||||
// One floating card holds the whole composer: suggestions, attachments,
|
||||
// the input and the controls row. Flat fill, hairline edge, no shadow.
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = palette.card,
|
||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||
Box {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||
// TUI-only commands that don't function on mobile (issue #574).
|
||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||
// also enforced at dispatch time (issue #576, deliverable #3).
|
||||
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
||||
val commandNames =
|
||||
(commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop"))
|
||||
.distinct()
|
||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||
// One floating card holds the whole composer: suggestions, attachments,
|
||||
// the input and the controls row. Flat fill, hairline edge, no shadow.
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = palette.card,
|
||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||
// TUI-only commands that don't function on mobile (issue #574).
|
||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||
// also enforced at dispatch time (issue #576, deliverable #3).
|
||||
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
||||
val commandNames =
|
||||
(
|
||||
commandCatalog.pairs.map { it[0] } +
|
||||
listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")
|
||||
).distinct()
|
||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 200.dp),
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
),
|
||||
) {
|
||||
items(filteredCommands, key = { it }) { cmd ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
cmd,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 200.dp),
|
||||
) {
|
||||
items(filteredCommands, key = { it }) { cmd ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
cmd,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment preview chips
|
||||
AnimatedVisibility(
|
||||
visible = pendingAttachments.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
LazyRow(
|
||||
// Attachment preview chips
|
||||
AnimatedVisibility(
|
||||
visible = pendingAttachments.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
LazyRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
itemsIndexed(pendingAttachments) { index, attachment ->
|
||||
AttachmentChip(
|
||||
attachment = attachment,
|
||||
onPreview = { onPreviewAttachment(attachment) },
|
||||
onRemove = { onRemoveAttachment(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnected && !isSessionReady) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (sessionPreparationFailed) {
|
||||
R.string.chat_session_not_ready
|
||||
} else {
|
||||
R.string.chat_session_preparing
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = palette.placeholder,
|
||||
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── TOP ROW: Borderless input field ──
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
itemsIndexed(pendingAttachments) { index, attachment ->
|
||||
AttachmentChip(
|
||||
attachment = attachment,
|
||||
onPreview = { onPreviewAttachment(attachment) },
|
||||
onRemove = { onRemoveAttachment(index) },
|
||||
val placeholderText =
|
||||
when {
|
||||
!isConnected -> {
|
||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||
}
|
||||
|
||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||
stringResource(R.string.chat_input_placeholder_queue)
|
||||
}
|
||||
|
||||
isAgentTyping -> {
|
||||
stringResource(R.string.chat_input_placeholder_waiting)
|
||||
}
|
||||
|
||||
else -> {
|
||||
stringResource(R.string.chat_input_placeholder_type_message)
|
||||
}
|
||||
}
|
||||
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(
|
||||
inputFieldValue.text,
|
||||
fallback = ambientLayoutDirection,
|
||||
)
|
||||
}
|
||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||
|
||||
if (isRecordingVoice) {
|
||||
VoiceNoteRecordingPanel(
|
||||
slideProgress = voiceSlideProgress,
|
||||
locked = isVoiceNoteLocked,
|
||||
onCancel = onMicHoldCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnected && !isSessionReady) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (sessionPreparationFailed) {
|
||||
R.string.chat_session_not_ready
|
||||
} else {
|
||||
R.string.chat_session_preparing
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = palette.placeholder,
|
||||
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── TOP ROW: Borderless input field ──
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val placeholderText =
|
||||
when {
|
||||
!isConnected -> {
|
||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||
}
|
||||
|
||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||
stringResource(R.string.chat_input_placeholder_queue)
|
||||
}
|
||||
|
||||
isAgentTyping -> {
|
||||
stringResource(R.string.chat_input_placeholder_waiting)
|
||||
}
|
||||
|
||||
else -> {
|
||||
stringResource(R.string.chat_input_placeholder_type_message)
|
||||
}
|
||||
}
|
||||
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
|
||||
}
|
||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||
|
||||
if (isRecordingVoice) {
|
||||
VoiceNoteRecordingPanel(
|
||||
amplitude = voiceNoteAmplitude,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = onInputChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 42.dp, max = 200.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.testTag("chat_input"),
|
||||
enabled = isConnected,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
if (isInputRtl) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
} else {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = onInputChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 42.dp, max = 200.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(inputFocusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (!isRecordingVoice) {
|
||||
inputWasFocused = focusState.isFocused
|
||||
}
|
||||
}.testTag("chat_input"),
|
||||
enabled = isConnected,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
if (isInputRtl) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
},
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign =
|
||||
if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOTTOM ROW: Toolbar ──
|
||||
ComposerToolbar(
|
||||
isConnected = isConnected,
|
||||
currentSessionModel = currentSessionModel,
|
||||
reasoningLevel = reasoningLevel,
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = hasDraft,
|
||||
onSend = onSend,
|
||||
canInterrupt = canInterrupt,
|
||||
onStopGeneration = onStopGeneration,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
onReasoningSelected = onReasoningTap,
|
||||
onMicTap = onMicTap,
|
||||
onMicHoldStart = handleMicHoldStart,
|
||||
onMicHoldEnd = handleMicHoldEnd,
|
||||
onMicHoldCancel = handleMicHoldCancel,
|
||||
onMicLock = onMicLock,
|
||||
isVoiceNoteLocked = isVoiceNoteLocked,
|
||||
onSlideProgress = { voiceSlideProgress.value = it },
|
||||
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||
canDisableReasoning = canDisableReasoning,
|
||||
supportsReasoning = supportsReasoning,
|
||||
fastMode = fastMode,
|
||||
fastSupported = fastSupported,
|
||||
isFastModeChanging = isFastModeChanging,
|
||||
onToggleFastMode = onToggleFastMode,
|
||||
showModelProvider = showModelProvider,
|
||||
reasoningWireLevel = reasoningWireLevel,
|
||||
pendingReasoningLevel = pendingReasoningLevel,
|
||||
isSessionReady = isSessionReady,
|
||||
)
|
||||
|
||||
AttachmentTray(
|
||||
visible = showAttachmentTray,
|
||||
onDismissRequest = { showAttachmentTray = false },
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
onFileTap = onFileTap,
|
||||
)
|
||||
}
|
||||
|
||||
// ── BOTTOM ROW: Toolbar ──
|
||||
ComposerToolbar(
|
||||
isConnected = isConnected,
|
||||
currentSessionModel = currentSessionModel,
|
||||
reasoningLevel = reasoningLevel,
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = hasDraft,
|
||||
showQueue = showBusyActions,
|
||||
onSend = onSend,
|
||||
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
||||
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
||||
canInterrupt = canInterrupt,
|
||||
onStopGeneration = onStopGeneration,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
onReasoningSelected = onReasoningTap,
|
||||
onMicTap = onMicTap,
|
||||
onMicHoldStart = onMicHoldStart,
|
||||
onMicHoldEnd = onMicHoldEnd,
|
||||
onMicHoldCancel = onMicHoldCancel,
|
||||
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||
canDisableReasoning = canDisableReasoning,
|
||||
supportsReasoning = supportsReasoning,
|
||||
fastMode = fastMode,
|
||||
fastSupported = fastSupported,
|
||||
isFastModeChanging = isFastModeChanging,
|
||||
onToggleFastMode = onToggleFastMode,
|
||||
showModelProvider = showModelProvider,
|
||||
reasoningWireLevel = reasoningWireLevel,
|
||||
pendingReasoningLevel = pendingReasoningLevel,
|
||||
isSessionReady = isSessionReady,
|
||||
)
|
||||
|
||||
AttachmentTray(
|
||||
visible = showAttachmentTray,
|
||||
onDismissRequest = { showAttachmentTray = false },
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
onFileTap = onFileTap,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Zero-height anchor so the tooltip and lock icon float above the
|
||||
// card, over the message list, like Telegram's lock control. It sits
|
||||
// outside the animated card because AnimatedVisibility clips its
|
||||
// children to the animation bounds.
|
||||
Box(modifier = Modifier.fillMaxWidth().height(0.dp)) {
|
||||
VoiceNoteLockHintOverlay(
|
||||
visible = isRecordingVoice && !isVoiceNoteLocked,
|
||||
showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f,
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentHeight(align = Alignment.Top, unbounded = true)
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(y = (-42).dp)
|
||||
.padding(end = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram-style lock affordance floating over the composer while a voice
|
||||
* note records: the outlined lock icon with the "Slide up to lock recording"
|
||||
* tooltip beside it. The tooltip shows only until the first successful lock
|
||||
* (persisted in [VOICE_PREFS_NAME]); the icon rides along until the recording
|
||||
* locks hands-free.
|
||||
*/
|
||||
@Composable
|
||||
private fun VoiceNoteLockHintOverlay(
|
||||
visible: Boolean,
|
||||
showTextHint: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
val palette = composerPalette()
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showTextHint) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_voice_lock_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = palette.text,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(palette.card, RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.background(palette.control, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = palette.placeholder,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared prefs file for the voice-note UI. */
|
||||
private const val VOICE_PREFS_NAME = "chat_voice_prefs"
|
||||
|
||||
/** Set after the first successful hands-free lock, silencing the hint. */
|
||||
private const val VOICE_LOCK_HINT_KEY = "voice_note_lock_hint_done"
|
||||
|
||||
/**
|
||||
* Reusable attachment chip composable for showing a pending attachment
|
||||
* with a remove button.
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -40,7 +39,7 @@ import java.util.Locale
|
||||
class ChatMediaLaunchers(
|
||||
val isListening: Boolean,
|
||||
val isRecordingVoice: Boolean,
|
||||
val voiceNoteAmplitude: State<Float>,
|
||||
val isVoiceNoteLocked: Boolean,
|
||||
val onMicTap: () -> Unit,
|
||||
val onCameraTap: () -> Unit,
|
||||
val onImageTap: () -> Unit,
|
||||
@@ -48,6 +47,7 @@ class ChatMediaLaunchers(
|
||||
val onMicHoldStart: () -> Unit = {},
|
||||
val onMicHoldEnd: () -> Unit = {},
|
||||
val onMicHoldCancel: () -> Unit = {},
|
||||
val onMicLock: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -85,28 +85,14 @@ fun rememberChatMediaLaunchers(
|
||||
val voiceNoteRecorder = remember { VoiceNoteRecorder(context) }
|
||||
var isRecordingVoice by remember { mutableStateOf(false) }
|
||||
|
||||
// Live mic level for the recording panel — rises fast, decays slowly so
|
||||
// the meter reads as voice activity instead of flicker.
|
||||
val voiceNoteAmplitude = remember { mutableStateOf(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(90)
|
||||
val previous = voiceNoteAmplitude.value
|
||||
val next =
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
if (next != previous) {
|
||||
voiceNoteAmplitude.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
// Locked = the recording continues after the finger lifts (slide up
|
||||
// during the hold); the action button then submits it.
|
||||
var isVoiceNoteLocked by remember { mutableStateOf(false) }
|
||||
|
||||
fun finishVoiceRecording() {
|
||||
val recordedFile = voiceNoteRecorder.stop()
|
||||
isRecordingVoice = false
|
||||
isVoiceNoteLocked = false
|
||||
if (recordedFile != null) {
|
||||
currentOnVoiceNoteRecorded(recordedFile)
|
||||
}
|
||||
@@ -264,6 +250,9 @@ fun rememberChatMediaLaunchers(
|
||||
if (isTranscribingVoiceNote) {
|
||||
// Single-flight: a server transcription owns the voice pipeline
|
||||
// until it lands (review, PR #1250).
|
||||
} else if (isVoiceNoteLocked) {
|
||||
// The locked recording's action button submits the note.
|
||||
finishVoiceRecording()
|
||||
} else if (isRecordingVoice) {
|
||||
// A tap while recording discards the in-flight voice note.
|
||||
voiceNoteRecorder.cancel()
|
||||
@@ -334,6 +323,15 @@ fun rememberChatMediaLaunchers(
|
||||
voiceNoteRecorder.cancel()
|
||||
}
|
||||
isRecordingVoice = false
|
||||
isVoiceNoteLocked = false
|
||||
}
|
||||
|
||||
// Locking keeps the recorder running once the finger lifts; only the
|
||||
// panel's delete action or the action button can end it then.
|
||||
val onMicLock: () -> Unit = {
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
isVoiceNoteLocked = true
|
||||
}
|
||||
}
|
||||
|
||||
val onCameraTap: () -> Unit = {
|
||||
@@ -369,11 +367,11 @@ fun rememberChatMediaLaunchers(
|
||||
}
|
||||
}
|
||||
|
||||
return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) {
|
||||
return remember(isListening, isRecordingVoice, isVoiceNoteLocked, isTranscribingVoiceNote) {
|
||||
ChatMediaLaunchers(
|
||||
isListening = isListening || isRecordingVoice,
|
||||
isRecordingVoice = isRecordingVoice,
|
||||
voiceNoteAmplitude = voiceNoteAmplitude,
|
||||
isVoiceNoteLocked = isVoiceNoteLocked,
|
||||
onMicTap = onMicTap,
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
@@ -381,6 +379,7 @@ fun rememberChatMediaLaunchers(
|
||||
onMicHoldStart = onMicHoldStart,
|
||||
onMicHoldEnd = onMicHoldEnd,
|
||||
onMicHoldCancel = onMicHoldCancel,
|
||||
onMicLock = onMicLock,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AddToQueue
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
@@ -61,13 +60,15 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -109,10 +110,7 @@ fun ComposerToolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
canSend: Boolean = false,
|
||||
showSend: Boolean = canSend,
|
||||
showQueue: Boolean = false,
|
||||
onSend: () -> Unit = {},
|
||||
onQueue: () -> Unit = {},
|
||||
onStopAndSend: () -> Unit = {},
|
||||
canDisableReasoning: Boolean? = null,
|
||||
supportsReasoning: Boolean? = null,
|
||||
fastMode: Boolean = false,
|
||||
@@ -127,6 +125,9 @@ fun ComposerToolbar(
|
||||
onMicHoldStart: () -> Unit = {},
|
||||
onMicHoldEnd: () -> Unit = {},
|
||||
onMicHoldCancel: () -> Unit = {},
|
||||
onMicLock: () -> Unit = {},
|
||||
isVoiceNoteLocked: Boolean = false,
|
||||
onSlideProgress: (Float) -> Unit = {},
|
||||
onStopGeneration: () -> Unit = {},
|
||||
) {
|
||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||
@@ -134,36 +135,74 @@ fun ComposerToolbar(
|
||||
val reasoningDisabledForModel = supportsReasoning == false
|
||||
val canDisable = canDisableReasoning
|
||||
|
||||
// Telegram-style voice notes: hold to record, release to send, slide away
|
||||
// to cancel. Both mic controls run the same single pointer loop
|
||||
// (micHoldHandler), which owns the whole press until finger-up — so the
|
||||
// release that submits the note can never be lost between recompositions.
|
||||
// Telegram-style voice notes: hold to record, release to send, slide left
|
||||
// to cancel, slide up to lock hands-free. Both mic controls run the same
|
||||
// single pointer loop (micHoldHandler), which owns the whole press until
|
||||
// finger-up — so the release that submits the note can never be lost
|
||||
// between recompositions.
|
||||
val currentIsConnected = rememberUpdatedState(isConnected)
|
||||
val currentShowSend = rememberUpdatedState(showSend)
|
||||
val currentCanInterrupt = rememberUpdatedState(canInterrupt)
|
||||
val currentIsVoiceNoteLocked = rememberUpdatedState(isVoiceNoteLocked)
|
||||
val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart)
|
||||
val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd)
|
||||
val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel)
|
||||
val currentOnMicLock = rememberUpdatedState(onMicLock)
|
||||
val currentOnSlideProgress = rememberUpdatedState(onSlideProgress)
|
||||
// The mic button is under the thumb during the gesture, so the outcome —
|
||||
// lock or cancel — must be felt as well as seen.
|
||||
val haptic = LocalHapticFeedback.current
|
||||
// Telegram's slide-to-cancel span: a third of the composer width, capped
|
||||
// at [MAX_CANCEL_DISTANCE_DP]. The gesture modifier sits on the mic button
|
||||
// itself (only ~40 dp wide), so the span derives from the screen width.
|
||||
val cancelDistancePx =
|
||||
with(LocalDensity.current) {
|
||||
minOf(
|
||||
LocalConfiguration.current.screenWidthDp.dp
|
||||
.toPx() * 0.35f,
|
||||
MAX_CANCEL_DISTANCE_DP.dp.toPx(),
|
||||
)
|
||||
}
|
||||
val flatMicGesture =
|
||||
Modifier.pointerInput(Unit) {
|
||||
micHoldHandler(
|
||||
isEnabled = {
|
||||
currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value
|
||||
currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value &&
|
||||
!currentIsVoiceNoteLocked.value
|
||||
},
|
||||
onHoldStart = { currentOnMicHoldStart.value() },
|
||||
onHoldEnd = { currentOnMicHoldEnd.value() },
|
||||
onHoldCancel = { currentOnMicHoldCancel.value() },
|
||||
onHoldCancel = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicHoldCancel.value()
|
||||
},
|
||||
onHoldLock = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicLock.value()
|
||||
},
|
||||
onSlideProgress = { currentOnSlideProgress.value(it) },
|
||||
cancelDistance = cancelDistancePx,
|
||||
)
|
||||
}
|
||||
val actionMicGesture =
|
||||
Modifier.pointerInput(Unit) {
|
||||
micHoldHandler(
|
||||
isEnabled = {
|
||||
currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value
|
||||
currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value &&
|
||||
!currentIsVoiceNoteLocked.value
|
||||
},
|
||||
onHoldStart = { currentOnMicHoldStart.value() },
|
||||
onHoldEnd = { currentOnMicHoldEnd.value() },
|
||||
onHoldCancel = { currentOnMicHoldCancel.value() },
|
||||
onHoldCancel = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicHoldCancel.value()
|
||||
},
|
||||
onHoldLock = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicLock.value()
|
||||
},
|
||||
onSlideProgress = { currentOnSlideProgress.value(it) },
|
||||
cancelDistance = cancelDistancePx,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -488,46 +527,47 @@ fun ComposerToolbar(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledIconButton(
|
||||
onClick = onMicTap,
|
||||
enabled = isConnected,
|
||||
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
|
||||
// Plain Box, not FilledIconButton: the hold gesture must sit
|
||||
// innermost in the pointer-modifier chain so it consumes the
|
||||
// release before the tap handler — impossible while the button
|
||||
// supplies its own clickable (review, PR #1280). Same
|
||||
// arrangement as the action button below.
|
||||
val micColors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ControlSize)
|
||||
.testTag(if (isListening) "mic_stop_button" else "mic_button")
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isConnected) micColors.containerColor else micColors.disabledContainerColor,
|
||||
).combinedClickable(
|
||||
enabled = isConnected,
|
||||
onClick = onMicTap,
|
||||
).testTag(if (isListening) "mic_stop_button" else "mic_button")
|
||||
.then(flatMicGesture),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
|
||||
contentDescription = if (isListening) "Stop listening" else "Mic",
|
||||
tint = if (isConnected) micColors.contentColor else micColors.disabledContentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showQueue,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.8f),
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onQueue,
|
||||
enabled = canSend,
|
||||
colors = flatIconButtonColors(palette),
|
||||
modifier = Modifier.size(48.dp).testTag("queue_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddToQueue,
|
||||
contentDescription = stringResource(R.string.chat_busy_queue_action),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Action button — send when a send is possible, mic / stop otherwise
|
||||
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
|
||||
val actionEnabled = if (showSend) canSend else isConnected
|
||||
// While a recording is locked this button finishes the voice note, so
|
||||
// it must stay enabled even when the draft alone would not send
|
||||
// (review, PR #1280).
|
||||
val actionEnabled =
|
||||
if (isVoiceNoteLocked) {
|
||||
true
|
||||
} else if (showSend) {
|
||||
canSend
|
||||
} else {
|
||||
isConnected
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -539,29 +579,25 @@ fun ComposerToolbar(
|
||||
} else {
|
||||
palette.action
|
||||
},
|
||||
).semantics {
|
||||
if (showSend && showQueue && canSend) {
|
||||
customActions =
|
||||
listOf(
|
||||
CustomAccessibilityAction(stopAndSendLabel) {
|
||||
onStopAndSend()
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
}.combinedClickable(
|
||||
).combinedClickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = {
|
||||
when {
|
||||
// A locked recording always finishes with this
|
||||
// button, draft or agent state regardless
|
||||
// (review, PR #1280).
|
||||
isVoiceNoteLocked -> onMicTap()
|
||||
|
||||
canInterrupt -> onStopGeneration()
|
||||
|
||||
showSend -> onSend()
|
||||
|
||||
else -> onMicTap()
|
||||
}
|
||||
},
|
||||
onLongClick = if (showSend && showQueue) onStopAndSend else null,
|
||||
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
|
||||
).testTag(
|
||||
when {
|
||||
isVoiceNoteLocked -> "voice_note_send_button"
|
||||
canInterrupt -> "stop_button"
|
||||
showSend -> "send_button"
|
||||
isListening -> "mic_stop_button"
|
||||
@@ -573,6 +609,7 @@ fun ComposerToolbar(
|
||||
Crossfade(
|
||||
targetState =
|
||||
when {
|
||||
isVoiceNoteLocked -> ActionGlyph.SEND
|
||||
canInterrupt -> ActionGlyph.STOP
|
||||
showSend -> ActionGlyph.SEND
|
||||
isListening -> ActionGlyph.STOP
|
||||
@@ -584,7 +621,12 @@ fun ComposerToolbar(
|
||||
ActionGlyph.SEND -> {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send_desc),
|
||||
contentDescription =
|
||||
if (isVoiceNoteLocked) {
|
||||
stringResource(R.string.chat_voice_send_desc)
|
||||
} else {
|
||||
stringResource(R.string.chat_send_desc)
|
||||
},
|
||||
tint = palette.onAction,
|
||||
)
|
||||
}
|
||||
@@ -720,36 +762,53 @@ fun buildReasoningLabel(
|
||||
|
||||
/**
|
||||
* Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS]
|
||||
* records a voice note, release sends it, and sliding away (left or up) from
|
||||
* the button cancels instead of sending. Short taps use the control's normal
|
||||
* click handler for dictation, so touch and accessibility actions share one
|
||||
* callback path.
|
||||
* records a voice note, release sends it, dragging into the cancel zone
|
||||
* cancels, and sliding up past [MIC_SLIDE_LOCK_DP] locks the recording
|
||||
* hands-free. Short taps use the control's normal click handler for dictation,
|
||||
* so touch and accessibility actions share one callback path.
|
||||
*
|
||||
* Distances and outcomes mirror Telegram's recorder: the full cancel distance
|
||||
* is a third of the composer capped at [MAX_CANCEL_DISTANCE_DP]; releasing
|
||||
* below [CANCEL_RELEASE_ALPHA] of it cancels (dragging back out before release
|
||||
* un-cancels); dragging all the way cancels mid-gesture; [onSlideProgress]
|
||||
* reports the 1 → 0 drag fraction so the recording strip can track and fade
|
||||
* its hint; locking only arms while the gesture stays clear of the cancel zone
|
||||
* ([LOCK_MIN_ALPHA]).
|
||||
*
|
||||
* The whole down-to-up sequence lives in this one pointer loop, so the release
|
||||
* that submits can never be lost to a recomposition between press and
|
||||
* finger-up — an earlier interaction-source based approach could lose it and
|
||||
* leave the recorder running until some later tap "sent" the stale clip.
|
||||
*
|
||||
* Once the hold phase arms, every event is consumed: the shared click handler
|
||||
* otherwise also observes the stationary release that ends a hold (there is no
|
||||
* drag to cancel it), and re-dispatches the tap action — the system dictation
|
||||
* intent launched every time a voice note was sent (regression, #1247).
|
||||
*/
|
||||
private suspend fun PointerInputScope.micHoldHandler(
|
||||
isEnabled: () -> Boolean,
|
||||
onHoldStart: () -> Unit,
|
||||
onHoldEnd: () -> Unit,
|
||||
onHoldCancel: () -> Unit,
|
||||
onHoldLock: () -> Unit,
|
||||
onSlideProgress: (Float) -> Unit,
|
||||
cancelDistance: Float,
|
||||
) {
|
||||
val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx()
|
||||
val lockSlop = MIC_SLIDE_LOCK_DP.dp.toPx()
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
if (!isEnabled()) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
fun slidAway(position: Offset): Boolean {
|
||||
val dx = position.x - down.position.x
|
||||
val dy = position.y - down.position.y
|
||||
return dx < -cancelSlop || dy < -cancelSlop
|
||||
}
|
||||
fun slideAlpha(position: Offset): Float =
|
||||
(1f + (position.x - down.position.x) / cancelDistance).coerceIn(0f, 1f)
|
||||
|
||||
// Phase 1 — a finger-up before the threshold is a plain tap.
|
||||
fun slidUp(position: Offset): Boolean = position.y - down.position.y < -lockSlop
|
||||
|
||||
// Phase 1 — a finger-up before the threshold is a plain tap, left to
|
||||
// the click handler. Dragging into the cancel zone aborts the gesture.
|
||||
// Events are not consumed yet, so the tap path stays intact.
|
||||
var earlyUp = false
|
||||
var gestureAborted = false
|
||||
withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) {
|
||||
@@ -765,7 +824,7 @@ private suspend fun PointerInputScope.micHoldHandler(
|
||||
earlyUp = true
|
||||
return@withTimeoutOrNull
|
||||
}
|
||||
if (change.isConsumed || slidAway(change.position)) {
|
||||
if (change.isConsumed || slideAlpha(change.position) < CANCEL_RELEASE_ALPHA) {
|
||||
gestureAborted = true
|
||||
return@withTimeoutOrNull
|
||||
}
|
||||
@@ -779,23 +838,44 @@ private suspend fun PointerInputScope.micHoldHandler(
|
||||
}
|
||||
|
||||
// Phase 2 — the threshold passed with the finger down: record until
|
||||
// the finger lifts (send) or slides away (cancel). No timeout here.
|
||||
// the finger lifts (send, unless dragged into the cancel zone), drags
|
||||
// all the way left (cancel), or slides up (lock hands-free).
|
||||
onHoldStart()
|
||||
var send = false
|
||||
var lock = false
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
val alpha = slideAlpha(change.position)
|
||||
onSlideProgress(alpha)
|
||||
if (!change.pressed) {
|
||||
send = true
|
||||
change.consume()
|
||||
send = alpha >= CANCEL_RELEASE_ALPHA
|
||||
break
|
||||
}
|
||||
if (change.isConsumed || slidAway(change.position)) {
|
||||
if (change.isConsumed) {
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
if (alpha <= 0f) {
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
if (slidUp(change.position) && alpha >= LOCK_MIN_ALPHA) {
|
||||
lock = true
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
change.consume()
|
||||
}
|
||||
} finally {
|
||||
if (send) onHoldEnd() else onHoldCancel()
|
||||
onSlideProgress(1f)
|
||||
when {
|
||||
lock -> onHoldLock()
|
||||
send -> onHoldEnd()
|
||||
else -> onHoldCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -803,5 +883,14 @@ private suspend fun PointerInputScope.micHoldHandler(
|
||||
/** A press shorter than this is a tap; longer arms voice-note recording. */
|
||||
private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L
|
||||
|
||||
/** Sliding this far from the mic button (left or up) cancels the recording. */
|
||||
private const val MIC_SLIDE_CANCEL_DP = 64
|
||||
/** Sliding this far up from the mic button locks the recording hands-free. */
|
||||
private const val MIC_SLIDE_LOCK_DP = 57
|
||||
|
||||
/** Full slide-to-cancel distance: a third of the composer (Telegram). */
|
||||
private const val MAX_CANCEL_DISTANCE_DP = 140
|
||||
|
||||
/** Releasing below this drag fraction of the cancel distance cancels. */
|
||||
private const val CANCEL_RELEASE_ALPHA = 0.45f
|
||||
|
||||
/** Locking only arms while the gesture stays clear of the cancel zone. */
|
||||
private const val LOCK_MIN_ALPHA = 0.7f
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.CatalogScanStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectionCatalogInfo
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetKind
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
|
||||
private const val SHORT_SHA_LENGTH = 12
|
||||
|
||||
/** What a plugin/skill install row would bring in: source, pin, scan and requirements (#1287). */
|
||||
@Composable
|
||||
internal fun ConnectionCatalogDetails(
|
||||
kind: ConnectionTargetKind,
|
||||
catalog: ConnectionCatalogInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Column(
|
||||
modifier = modifier.testTag("connection_catalog_details"),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val kindLabel =
|
||||
stringResource(
|
||||
if (kind == ConnectionTargetKind.SKILL) {
|
||||
R.string.connection_catalog_kind_skill
|
||||
} else {
|
||||
R.string.connection_catalog_kind_plugin
|
||||
},
|
||||
)
|
||||
val tierLabel =
|
||||
when (catalog.tier) {
|
||||
null -> null
|
||||
"official" -> stringResource(R.string.connection_catalog_tier_official)
|
||||
"community" -> stringResource(R.string.connection_catalog_tier_community)
|
||||
else -> catalog.tier
|
||||
}
|
||||
Text(
|
||||
text = listOfNotNull(kindLabel, tierLabel).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = muted,
|
||||
)
|
||||
catalog.description?.let { Text(it) }
|
||||
catalog.repo?.let { repo ->
|
||||
val source = catalog.subdir?.let { "$repo/$it" } ?: repo
|
||||
Text(
|
||||
text = stringResource(R.string.connection_catalog_source, source),
|
||||
color = muted,
|
||||
modifier = Modifier.testTag("connection_catalog_source"),
|
||||
)
|
||||
}
|
||||
catalog.sha?.let { sha ->
|
||||
Text(
|
||||
text = stringResource(R.string.connection_catalog_pin, sha.take(SHORT_SHA_LENGTH)),
|
||||
color = muted,
|
||||
modifier = Modifier.testTag("connection_catalog_pin"),
|
||||
)
|
||||
}
|
||||
if (catalog.platforms.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.connection_catalog_platforms, catalog.platforms.joinToString(", ")),
|
||||
color = muted,
|
||||
)
|
||||
}
|
||||
CatalogScanLine(catalog)
|
||||
if (catalog.requirements.isNotEmpty()) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.connection_catalog_requirements,
|
||||
catalog.requirements.joinToString(", "),
|
||||
),
|
||||
color = muted,
|
||||
modifier = Modifier.testTag("connection_catalog_requirements"),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.connection_catalog_target_profile, catalog.targetProfile),
|
||||
color = muted,
|
||||
modifier = Modifier.testTag("connection_catalog_profile"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogScanLine(catalog: ConnectionCatalogInfo) {
|
||||
val status = LocalHermesStatusColors.current
|
||||
val scan = catalog.scan
|
||||
val (label, color) =
|
||||
when (scan?.status) {
|
||||
CatalogScanStatus.PASSED -> {
|
||||
R.string.connection_catalog_scan_passed to status.success
|
||||
}
|
||||
|
||||
CatalogScanStatus.WARNINGS -> {
|
||||
R.string.connection_catalog_scan_warnings to status.warning
|
||||
}
|
||||
|
||||
CatalogScanStatus.FAILED -> {
|
||||
R.string.connection_catalog_scan_failed to status.error
|
||||
}
|
||||
|
||||
CatalogScanStatus.UNKNOWN, null -> {
|
||||
R.string.connection_catalog_scan_unreported to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
}
|
||||
val summary = scan?.summary
|
||||
Text(
|
||||
text = stringResource(label) + if (summary != null) " · $summary" else "",
|
||||
color = color,
|
||||
modifier = Modifier.testTag("connection_catalog_scan"),
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetState
|
||||
import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate
|
||||
import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -101,8 +102,14 @@ internal fun ConnectionSetupContent(
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
val catalog = target.catalog
|
||||
Text(
|
||||
text = stringResource(R.string.connection_setup_target_title, target.name),
|
||||
text =
|
||||
if (catalog != null) {
|
||||
stringResource(R.string.connection_catalog_title, catalog.display)
|
||||
} else {
|
||||
stringResource(R.string.connection_setup_target_title, target.name)
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.testTag("connection_setup_title"),
|
||||
)
|
||||
@@ -119,6 +126,7 @@ internal fun ConnectionSetupContent(
|
||||
target.instructions?.takeIf { it.isNotBlank() }?.let { instructions ->
|
||||
Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
catalog?.let { ConnectionCatalogDetails(kind = target.kind, catalog = it) }
|
||||
|
||||
when {
|
||||
target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> {
|
||||
@@ -140,6 +148,12 @@ internal fun ConnectionSetupContent(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
catalog?.skill?.let { skill ->
|
||||
Text(
|
||||
text = stringResource(R.string.connection_catalog_skill_ready, skill),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
target.detail?.takeIf { it.isNotBlank() }?.let { detail ->
|
||||
Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -166,6 +180,21 @@ internal fun ConnectionSetupContent(
|
||||
}
|
||||
}
|
||||
|
||||
catalog != null && target.state == ConnectionTargetState.INITIATED -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.testTag("connection_catalog_installing"),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text =
|
||||
target.detail?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.connection_catalog_installing),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
target.state == ConnectionTargetState.UNKNOWN ||
|
||||
(target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> {
|
||||
Text(
|
||||
@@ -219,14 +248,37 @@ internal fun ConnectionSetupContent(
|
||||
.fillMaxWidth()
|
||||
.testTag("connection_setup_connect"),
|
||||
) {
|
||||
Text(stringResource(R.string.connection_setup_connect))
|
||||
Text(
|
||||
stringResource(
|
||||
when {
|
||||
catalog == null -> R.string.connection_setup_connect
|
||||
target.state == ConnectionTargetState.FAILED -> R.string.connection_catalog_retry
|
||||
else -> R.string.connection_catalog_install
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let {
|
||||
state.error?.let { error ->
|
||||
Text(
|
||||
text = stringResource(R.string.connection_setup_request_failed),
|
||||
text =
|
||||
stringResource(
|
||||
when (error.message) {
|
||||
ChatConnectionOperationDelegate.NOT_OWNER -> {
|
||||
R.string.session_integrations_err_not_owner
|
||||
}
|
||||
|
||||
ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME -> {
|
||||
R.string.session_integrations_err_unsupported_runtime
|
||||
}
|
||||
|
||||
else -> {
|
||||
R.string.connection_setup_request_failed
|
||||
}
|
||||
},
|
||||
),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("connection_setup_error"),
|
||||
)
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
|
||||
@Composable
|
||||
fun PendingSendPanel(
|
||||
sends: List<PendingSend>,
|
||||
mainTurnBusy: Boolean,
|
||||
onSendNow: (String) -> Unit,
|
||||
) {
|
||||
// Delivery receipts stay internal; only queued work and recovery need UI.
|
||||
val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED }
|
||||
if (visibleSends.isEmpty()) return
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall)
|
||||
if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) {
|
||||
Text(
|
||||
stringResource(R.string.chat_pending_send_now_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) {
|
||||
items(visibleSends, key = { it.id }) { send ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
send.text.ifBlank { stringResource(R.string.chat_pending_attachments) },
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when (send.state) {
|
||||
PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued)
|
||||
PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked)
|
||||
PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending)
|
||||
PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted)
|
||||
PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown)
|
||||
PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (send.state in
|
||||
setOf(
|
||||
PendingSendState.QUEUED,
|
||||
PendingSendState.PARKED,
|
||||
PendingSendState.UNKNOWN,
|
||||
PendingSendState.REJECTED,
|
||||
PendingSendState.ACCEPTED,
|
||||
)
|
||||
) {
|
||||
TextButton(onClick = { onSendNow(send.id) }) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (mainTurnBusy) {
|
||||
R.string.chat_busy_stop_and_send
|
||||
} else if (send.state in
|
||||
setOf(
|
||||
PendingSendState.UNKNOWN,
|
||||
PendingSendState.ACCEPTED,
|
||||
)
|
||||
) {
|
||||
R.string.chat_pending_send_again
|
||||
} else {
|
||||
R.string.chat_pending_send_now
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
|
||||
/**
|
||||
* Creates and remembers a screen/list-level [SpeechController] scoped to the active
|
||||
* chat session, server endpoint, profile, and auth state.
|
||||
*
|
||||
* Handles lifecycle events:
|
||||
* - Resets (stops playback and cleans cache) on [Lifecycle.Event.ON_STOP]
|
||||
* - Resets on composition disposal or when the server endpoint, profile, auth state, or session changes
|
||||
*/
|
||||
@Composable
|
||||
fun rememberChatSpeech(
|
||||
synthesizer: SpeechSynthesizer? = null,
|
||||
player: SpeechPlayer? = null,
|
||||
onError: (() -> Unit)? = null,
|
||||
): SpeechController {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val errorSynthesisMessage = stringResource(R.string.tts_error_synthesis)
|
||||
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = dataScope?.activeProfileId
|
||||
val scopeKey = dataScope?.inMemoryKey(localKey = activeSessionId ?: "none").orEmpty()
|
||||
|
||||
val resolvedSynthesizer =
|
||||
remember(synthesizer, dataScope) {
|
||||
synthesizer ?: AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = ApiClient.hermesApi,
|
||||
profile = profile,
|
||||
)
|
||||
}
|
||||
|
||||
val resolvedPlayer =
|
||||
remember(player, context) {
|
||||
player ?: AndroidSpeechPlayer(context)
|
||||
}
|
||||
|
||||
val controller =
|
||||
remember(scope, resolvedSynthesizer, resolvedPlayer, errorSynthesisMessage) {
|
||||
SpeechController(
|
||||
scope = scope,
|
||||
synthesizer = resolvedSynthesizer,
|
||||
player = resolvedPlayer,
|
||||
onError = {
|
||||
onError?.invoke() ?: Toast
|
||||
.makeText(
|
||||
context,
|
||||
errorSynthesisMessage,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Reset when session scope changes (session/profile/server/auth switch)
|
||||
DisposableEffect(controller, scopeKey) {
|
||||
onDispose {
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset on ON_STOP lifecycle event
|
||||
DisposableEffect(lifecycleOwner, controller) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SpeechRequest(
|
||||
val scopeKey: String,
|
||||
val messageId: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
interface SpeechAudio {
|
||||
fun dispose()
|
||||
}
|
||||
|
||||
interface SpeechSynthesizer {
|
||||
suspend fun synthesize(request: SpeechRequest): SpeechAudio
|
||||
}
|
||||
|
||||
interface SpeechPlayer {
|
||||
fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
)
|
||||
|
||||
fun stop()
|
||||
}
|
||||
|
||||
class SpeechController(
|
||||
private val scope: CoroutineScope,
|
||||
private val synthesizer: SpeechSynthesizer,
|
||||
private val player: SpeechPlayer,
|
||||
private val onError: () -> Unit,
|
||||
) {
|
||||
private val _speakingId = MutableStateFlow<String?>(null)
|
||||
val speakingId: StateFlow<String?> = _speakingId.asStateFlow()
|
||||
|
||||
private var activeRequest: SpeechRequest? = null
|
||||
private var activeJob: Job? = null
|
||||
private var generation: Long = 0L
|
||||
|
||||
private var cachedRequest: SpeechRequest? = null
|
||||
private var cachedAudio: SpeechAudio? = null
|
||||
|
||||
fun toggle(request: SpeechRequest) {
|
||||
if (request.text.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeRequest == request) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
start(request)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
generation++
|
||||
activeJob?.cancel()
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
stop()
|
||||
disposeCache()
|
||||
}
|
||||
|
||||
private fun start(request: SpeechRequest) {
|
||||
generation++
|
||||
val currentGen = generation
|
||||
|
||||
activeJob?.cancel()
|
||||
player.stop()
|
||||
|
||||
if (cachedRequest != null && cachedRequest != request) {
|
||||
disposeCache()
|
||||
}
|
||||
|
||||
activeRequest = request
|
||||
_speakingId.value = request.messageId
|
||||
|
||||
val cached = cachedAudio
|
||||
if (cached != null && cachedRequest == request) {
|
||||
playAudio(cached, currentGen)
|
||||
return
|
||||
}
|
||||
|
||||
activeJob =
|
||||
scope.launch {
|
||||
var synthesizedAudio: SpeechAudio? = null
|
||||
try {
|
||||
val audio = synthesizer.synthesize(request)
|
||||
synthesizedAudio = audio
|
||||
|
||||
if (generation != currentGen) {
|
||||
audio.dispose()
|
||||
return@launch
|
||||
}
|
||||
|
||||
cachedAudio?.dispose()
|
||||
cachedRequest = request
|
||||
cachedAudio = audio
|
||||
synthesizedAudio = null
|
||||
|
||||
playAudio(audio, currentGen)
|
||||
} catch (e: CancellationException) {
|
||||
synthesizedAudio?.dispose()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
synthesizedAudio?.dispose()
|
||||
if (generation == currentGen) {
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAudio(
|
||||
audio: SpeechAudio,
|
||||
gen: Long,
|
||||
) {
|
||||
try {
|
||||
var completed = false
|
||||
player.play(
|
||||
audio = audio,
|
||||
onEnded = {
|
||||
if (generation == gen && !completed) {
|
||||
completed = true
|
||||
handlePlaybackFinished()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
if (generation == gen && !completed) {
|
||||
completed = true
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (generation == gen) {
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlaybackFinished() {
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
}
|
||||
|
||||
private fun handlePlaybackFailure() {
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
onError()
|
||||
}
|
||||
|
||||
private fun disposeCache() {
|
||||
cachedAudio?.dispose()
|
||||
cachedAudio = null
|
||||
cachedRequest = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
/**
|
||||
* Plain-text preparation for per-message read-aloud (TTS speak button).
|
||||
*
|
||||
* Pure Kotlin — no Android imports — so it stays unit-testable on the JVM.
|
||||
* Strips Markdown formatting while preserving readable prose, inline code,
|
||||
* fenced code contents, link titles/alt texts, and Unicode/emojis.
|
||||
*/
|
||||
object SpeechText {
|
||||
// Fenced code blocks: ``` or ~~~ with optional info string, spanning to closing fence or EOF.
|
||||
private val FENCED_CODE_BLOCK_REGEX =
|
||||
Regex("(?m)^[ \\t]*(`{3,}|~{3,})[^\\r\\n`~]*\\r?\\n([\\s\\S]*?)(?:^[ \\t]*\\1[ \\t]*(?:\\r?\\n|\\Z)|\\Z)")
|
||||
|
||||
// Placeholders with null byte to guarantee safety from standard markdown text
|
||||
private const val CODE_SPAN_PREFIX = "\u0000HERMESCODE"
|
||||
private const val CODE_SPAN_SUFFIX = "\u0000"
|
||||
|
||||
// Inline code: `code` or multiple backticks ``code``
|
||||
private val INLINE_CODE_REGEX = Regex("(`+)(.+?)\\1")
|
||||
|
||||
// Markdown structures
|
||||
private val IMAGE_LINK_REGEX = Regex("!\\[([^\\]]*)\\]\\([^)]*\\)")
|
||||
private val LINK_REGEX = Regex("\\[([^\\]]*)\\]\\([^)]*\\)")
|
||||
private val URL_REGEX = Regex("https?://\\S+")
|
||||
private val HTML_TAG_REGEX = Regex("<[^>]+>")
|
||||
|
||||
// Block elements (multiline)
|
||||
private val HEADING_REGEX = Regex("(?m)^\\s{0,3}#{1,6}\\s+")
|
||||
private val BLOCKQUOTE_REGEX = Regex("(?m)^\\s*>\\s?")
|
||||
private val LIST_MARKER_REGEX = Regex("(?m)^\\s*(?:[-*+]|\\d+[.)])\\s+")
|
||||
private val HORIZONTAL_RULE_REGEX = Regex("(?m)^\\s*([-_*])(?:\\s*\\1){2,}\\s*$")
|
||||
|
||||
// Emphasis / strikethrough markers
|
||||
private val BOLD_REGEX = Regex("(\\*\\*|__)(.*?)\\1")
|
||||
private val ITALIC_REGEX = Regex("(\\*|_)(.*?)\\1")
|
||||
private val STRIKETHROUGH_REGEX = Regex("~~(.*?)~~")
|
||||
|
||||
// Punctuation set including Arabic question mark (\u061F) and semicolon (\u061B), and standard ones
|
||||
// Punctuations that can end a sentence or pause: . , ! ? ; : ؟ ؛
|
||||
private const val PUNCTUATION_CLASS = "[.,!?;:\\u061F\\u061B]"
|
||||
private val PUNCT_BEFORE_NEWLINE_REGEX = Regex("($PUNCTUATION_CLASS)[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
|
||||
private val PLAIN_NEWLINE_REGEX = Regex("[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
|
||||
private val WHITESPACE_REGEX = Regex("\\s+")
|
||||
private val SPACE_BEFORE_PUNCT_REGEX = Regex("\\s+($PUNCTUATION_CLASS)")
|
||||
|
||||
fun stripMarkdownForSpeech(text: String): String {
|
||||
if (text.isBlank()) return ""
|
||||
|
||||
val codeSpans = mutableListOf<String>()
|
||||
|
||||
// 1. Protect fenced code blocks before prose processing.
|
||||
// Delimiters and info string are stripped; code content whitespace is normalized for speech.
|
||||
var out =
|
||||
FENCED_CODE_BLOCK_REGEX.replace(text) { matchResult ->
|
||||
val rawContent = matchResult.groupValues[2]
|
||||
val normalizedContent =
|
||||
rawContent
|
||||
.trim()
|
||||
.split(
|
||||
WHITESPACE_REGEX,
|
||||
).filter { it.isNotEmpty() }
|
||||
.joinToString(" ")
|
||||
val index = codeSpans.size
|
||||
codeSpans.add(normalizedContent)
|
||||
"\n$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX\n"
|
||||
}
|
||||
|
||||
// 2. Protect inline code spans before prose processing.
|
||||
out =
|
||||
INLINE_CODE_REGEX.replace(out) { matchResult ->
|
||||
val content = matchResult.groupValues[2].trim()
|
||||
val index = codeSpans.size
|
||||
codeSpans.add(content)
|
||||
"$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX"
|
||||
}
|
||||
|
||||
// 3. Process prose markdown elements
|
||||
// Images: keep alt text, drop the URL.
|
||||
out = out.replace(IMAGE_LINK_REGEX, "$1")
|
||||
// Links: keep visible text, drop the target.
|
||||
out = out.replace(LINK_REGEX, "$1")
|
||||
// Autolinks / bare URLs: drop the URL, keep surrounding prose.
|
||||
out = out.replace(URL_REGEX, " ")
|
||||
// HTML tags.
|
||||
out = out.replace(HTML_TAG_REGEX, " ")
|
||||
// Headings, blockquotes, list markers, horizontal rules.
|
||||
out = out.replace(HEADING_REGEX, "")
|
||||
out = out.replace(BLOCKQUOTE_REGEX, "")
|
||||
out = out.replace(LIST_MARKER_REGEX, "")
|
||||
out = out.replace(HORIZONTAL_RULE_REGEX, " ")
|
||||
// Table pipes
|
||||
out = out.replace("|", " ")
|
||||
// Emphasis / strikethrough markers
|
||||
out = out.replace(BOLD_REGEX, "$2")
|
||||
out = out.replace(ITALIC_REGEX, "$2")
|
||||
out = out.replace(STRIKETHROUGH_REGEX, "$1")
|
||||
|
||||
// 4. Normalise newlines into sensible pauses without accidental global punctuation mutation.
|
||||
// If line ends with punctuation, keep it with a trailing space.
|
||||
// Otherwise, replace newline with ". " to create a pause.
|
||||
out = out.replace(PUNCT_BEFORE_NEWLINE_REGEX, "$1 ")
|
||||
out = out.replace(PLAIN_NEWLINE_REGEX, ". ")
|
||||
|
||||
// 5. Restore protected code spans (both fenced and inline)
|
||||
for ((index, code) in codeSpans.withIndex()) {
|
||||
out = out.replace("$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX", code)
|
||||
}
|
||||
|
||||
// 6. Collapse multiple whitespaces and trim
|
||||
out = out.replace(WHITESPACE_REGEX, " ").trim()
|
||||
|
||||
// 7. Tidy spaces before punctuation left behind by marker removal.
|
||||
out = out.replace(SPACE_BEFORE_PUNCT_REGEX, "$1")
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
+143
-56
@@ -1,51 +1,79 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Telegram-style recording strip shown in place of the input field while a
|
||||
* voice note is being recorded: a slide-to-cancel hint, an elapsed timer, and
|
||||
* a live mic level meter so the press and the captured audio are both visible
|
||||
* even when the mic button itself is under the thumb.
|
||||
* voice note is being recorded. Layout mirrors Telegram's recorder: a blinking
|
||||
* red dot and the elapsed timer sit on the left; "◀ Slide to cancel" (with the
|
||||
* word "cancel" emphasized) sits centered to their right, gently nudging back
|
||||
* and forth, and slides left and fades as the gesture drags toward cancel
|
||||
* ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to
|
||||
* lock, the hint is replaced by a bold tappable "CANCEL" and the action button
|
||||
* submits the note.
|
||||
*/
|
||||
@Composable
|
||||
internal fun VoiceNoteRecordingPanel(
|
||||
amplitude: State<Float>,
|
||||
slideProgress: State<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
locked: Boolean = false,
|
||||
onCancel: () -> Unit = {},
|
||||
) {
|
||||
val palette = composerPalette()
|
||||
val density = LocalDensity.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var elapsedSeconds by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(Unit) {
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
@@ -54,71 +82,130 @@ internal fun VoiceNoteRecordingPanel(
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
val levels = remember { mutableStateListOf<Float>() }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { amplitude.value }.collect { level ->
|
||||
levels.add(level)
|
||||
while (levels.size > VOICE_NOTE_BARS) {
|
||||
levels.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Telegram blinks the record dot on a 1200 ms cycle.
|
||||
val blink = rememberInfiniteTransition(label = "voice_dot")
|
||||
val dotAlpha by
|
||||
blink.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.35f,
|
||||
animationSpec = infiniteRepeatable(tween(600, easing = LinearEasing), RepeatMode.Reverse),
|
||||
label = "voice_dot_alpha",
|
||||
)
|
||||
// ...and nudges the cancel hint ±6 dp while the gesture is idle (Telegram
|
||||
// moves it 3 dp / 250 ms), so the slide affordance announces itself.
|
||||
val nudgeDp by
|
||||
blink.animateFloat(
|
||||
initialValue = -6f,
|
||||
targetValue = 6f,
|
||||
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing), RepeatMode.Reverse),
|
||||
label = "voice_hint_nudge",
|
||||
)
|
||||
val progress = slideProgress.value.coerceIn(0f, 1f)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.heightIn(min = 42.dp)
|
||||
.testTag("voice_note_recording_panel"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
// Decorative: the adjacent "Slide to cancel" text carries the
|
||||
// instruction, so the arrow must not repeat it (review, PR #1250).
|
||||
contentDescription = null,
|
||||
tint = palette.placeholder,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_voice_slide_to_cancel),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.background(MaterialTheme.colorScheme.error, CircleShape),
|
||||
)
|
||||
modifier = Modifier.size(28.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(12.dp)
|
||||
.background(MaterialTheme.colorScheme.error.copy(alpha = dotAlpha), CircleShape),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = formatElapsed(elapsedSeconds),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = palette.text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = palette.placeholder,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.height(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
BoxWithConstraints(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clipToBounds(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val filler = VOICE_NOTE_BARS - levels.size
|
||||
repeat(VOICE_NOTE_BARS) { index ->
|
||||
val level = if (index < filler) 0f else levels[index - filler]
|
||||
Box(
|
||||
val containerWidthPx = with(density) { maxWidth.toPx() }
|
||||
if (locked) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_voice_cancel).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = palette.text,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height((4f + 18f * level).dp)
|
||||
.background(palette.placeholder, RoundedCornerShape(2.dp)),
|
||||
.minimumInteractiveComponentSize()
|
||||
.clickable(role = Role.Button) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onCancel()
|
||||
}.testTag("voice_note_cancel_button"),
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset {
|
||||
val shift = -containerWidthPx * 0.25f * (1f - progress)
|
||||
val nudge = nudgeDp * density.density * progress
|
||||
IntOffset((shift + nudge).roundToInt(), 0)
|
||||
}.alpha(progress),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CancelChevron(tint = palette.placeholder)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = slideToCancelText(palette),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of level-meter bars in the recording panel. */
|
||||
private const val VOICE_NOTE_BARS = 14
|
||||
/** "Slide to cancel" with the cancel word carrying the visual emphasis. */
|
||||
@Composable
|
||||
private fun slideToCancelText(palette: ComposerPalette) =
|
||||
buildAnnotatedString {
|
||||
// Two resources so translators control both parts independently; the
|
||||
// space between them is added here (review, PR #1280).
|
||||
withStyle(SpanStyle(color = palette.placeholder)) {
|
||||
append(stringResource(R.string.chat_voice_slide_to_cancel_prefix))
|
||||
append(" ")
|
||||
}
|
||||
withStyle(SpanStyle(color = palette.text, fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.chat_voice_slide_to_cancel_action))
|
||||
}
|
||||
}
|
||||
|
||||
/** The small drawn left chevron that precedes the cancel hint. */
|
||||
@Composable
|
||||
private fun CancelChevron(tint: Color) {
|
||||
Canvas(modifier = Modifier.size(width = 4.dp, height = 10.dp)) {
|
||||
val stroke = 1.6.dp.toPx()
|
||||
val midY = size.height / 2f
|
||||
val path =
|
||||
Path().apply {
|
||||
moveTo(size.width - stroke / 2f, midY - 5.dp.toPx())
|
||||
lineTo(stroke / 2f, midY)
|
||||
lineTo(size.width - stroke / 2f, midY + 5.dp.toPx())
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = tint,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats elapsed recording time as m:ss. */
|
||||
private fun formatElapsed(seconds: Long): String {
|
||||
|
||||
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -65,6 +67,8 @@ internal fun FullBleedAgentMessage(
|
||||
openingAttachmentPath: String? = null,
|
||||
canSaveAttachment: Boolean = true,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
isSpeaking: Boolean = false,
|
||||
onToggleSpeak: (() -> Unit)? = null,
|
||||
messageStatsEnabled: Boolean = false,
|
||||
showAssistantMessageTokens: Boolean = true,
|
||||
showTokensPerSecond: Boolean = true,
|
||||
@@ -162,6 +166,31 @@ internal fun FullBleedAgentMessage(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (onToggleSpeak != null) {
|
||||
IconButton(
|
||||
onClick = onToggleSpeak,
|
||||
modifier = Modifier.size(28.dp).testTag("fullbleed_speak"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isSpeaking) {
|
||||
Icons.Filled.Stop
|
||||
} else {
|
||||
Icons.AutoMirrored.Filled.VolumeUp
|
||||
},
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (isSpeaking) {
|
||||
R.string.content_desc_stop_speaking
|
||||
} else {
|
||||
R.string.content_desc_speak
|
||||
},
|
||||
),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showTokenStat) {
|
||||
AssistantStatItem(
|
||||
value =
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -105,6 +109,10 @@ fun FullBleedChatList(
|
||||
pagingSessionId: String? = null,
|
||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||
speakingMessageId: String? = null,
|
||||
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
|
||||
isCompressing: Boolean = false,
|
||||
compressionStatus: String? = null,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
Box(
|
||||
@@ -147,6 +155,23 @@ fun FullBleedChatList(
|
||||
val tailItems =
|
||||
buildMap<String, @Composable () -> Unit> {
|
||||
replyErrorContent?.let { put("reply_error", it) }
|
||||
if (isCompressing || compressionStatus != null) {
|
||||
put("compression_status") {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = compressionStatus ?: "⏳ Compressing context...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
agentStatus?.let { status ->
|
||||
put("agent_status") {
|
||||
AgentStatusIndicator(status = status)
|
||||
@@ -363,6 +388,15 @@ fun FullBleedChatList(
|
||||
showReasoning = !hoistedReasoning,
|
||||
)
|
||||
} else {
|
||||
val isSpeakingThis =
|
||||
speakingMessageId != null &&
|
||||
speakingMessageId == proseMessage.id
|
||||
val toggleSpeakAction =
|
||||
if (onToggleSpeak != null) {
|
||||
{ onToggleSpeak(proseMessage) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
FullBleedAgentMessage(
|
||||
message = proseMessage,
|
||||
// Highlight only bubbles that actually contain a match —
|
||||
@@ -385,6 +419,8 @@ fun FullBleedChatList(
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
canSaveAttachment = savingAttachmentPath == null,
|
||||
onImageClick = onImageClick,
|
||||
isSpeaking = isSpeakingThis,
|
||||
onToggleSpeak = toggleSpeakAction,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||
showTokensPerSecond = showTokensPerSecond,
|
||||
|
||||
@@ -64,4 +64,5 @@ data class ToolView(
|
||||
val searchQuery: String? = null,
|
||||
/** Detected error text (destructive styling in the view). */
|
||||
val error: String? = null,
|
||||
val serverDisplayName: String? = null,
|
||||
)
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* The tool-display engine's orchestrator — a port of the desktop app's
|
||||
@@ -48,12 +51,14 @@ object ToolViewBuilder {
|
||||
ToolStatusResolver.errorText(call, isError)
|
||||
}
|
||||
|
||||
val serverTitle = serverLabelTitle(call.args)
|
||||
val title =
|
||||
when {
|
||||
running -> renderer.pendingTitle(call)
|
||||
error.isNotEmpty() -> renderer.errorTitle(call)
|
||||
else -> renderer.doneTitle(call)
|
||||
} ?: ToolJson.titleForTool(toolName)
|
||||
serverTitle
|
||||
?: when {
|
||||
running -> renderer.pendingTitle(call)
|
||||
error.isNotEmpty() -> renderer.errorTitle(call)
|
||||
else -> renderer.doneTitle(call)
|
||||
} ?: ToolJson.titleForTool(toolName)
|
||||
|
||||
val subtitle =
|
||||
if (error.isNotEmpty()) {
|
||||
@@ -103,6 +108,7 @@ object ToolViewBuilder {
|
||||
return ToolView(
|
||||
status = status,
|
||||
title = title,
|
||||
serverDisplayName = serverTitle,
|
||||
subtitle = subtitle,
|
||||
detail = clampedDetail,
|
||||
detailLabel = extras.detailLabel,
|
||||
@@ -125,6 +131,20 @@ object ToolViewBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
/** #1288: server display metadata, not the real tool's `labels` argument. */
|
||||
internal fun serverLabelTitle(args: JsonObject?): String? =
|
||||
(args?.get("hermes_tool_labels") as? JsonArray)
|
||||
?.mapNotNull { entry ->
|
||||
val row = entry as? JsonObject ?: return@mapNotNull null
|
||||
val text =
|
||||
(row["text"] as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank)
|
||||
?: return@mapNotNull null
|
||||
val app = (row["app"] as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||
if (app.isNotBlank() && !text.contains(app, ignoreCase = true)) "$app · $text" else text
|
||||
}?.distinct()
|
||||
?.joinToString(" · ")
|
||||
?.takeIf(String::isNotBlank)
|
||||
|
||||
/** Subtitle fallback: first line of the heuristic summary, then contexts. */
|
||||
private fun genericSubtitle(call: ToolCall): String {
|
||||
val summary = ToolResultSummary.formatToolResultSummary(call.rawResult)
|
||||
|
||||
@@ -58,6 +58,18 @@ internal object FileEditSupport {
|
||||
}
|
||||
}
|
||||
|
||||
// Support persisted edit previews from display_metadata.tool_result_metadata.inline_diff or display_metadata.inline_diff
|
||||
val displayMetadata =
|
||||
record["display_metadata"] as? JsonObject
|
||||
?: ToolJson.parseMaybeObject(record["display_metadata"])
|
||||
val toolResultMeta =
|
||||
displayMetadata?.get("tool_result_metadata") as? JsonObject
|
||||
?: ToolJson.parseMaybeObject(displayMetadata?.get("tool_result_metadata"))
|
||||
val metaDiff = toolResultMeta?.get("inline_diff") ?: displayMetadata?.get("inline_diff")
|
||||
if (metaDiff is JsonPrimitive && metaDiff.isString && metaDiff.content.trim().isNotEmpty()) {
|
||||
return stripInlineDiffChrome(metaDiff.content)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.remote.CustomHeaders
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.remote.ServerHeaders
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.IOException
|
||||
|
||||
@Composable
|
||||
fun CustomHeadersButton(
|
||||
baseUrl: String,
|
||||
enabled: Boolean = true,
|
||||
onSaved: () -> Unit,
|
||||
) {
|
||||
val endpoint = remember(baseUrl) { runCatching { ServerEndpoint.parseForBuild(baseUrl) }.getOrNull() }
|
||||
var editing by remember(baseUrl) { mutableStateOf(false) }
|
||||
OutlinedButton(
|
||||
onClick = { editing = true },
|
||||
enabled = enabled && endpoint != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.custom_headers_title))
|
||||
}
|
||||
if (editing && endpoint != null) {
|
||||
CustomHeadersDialog(
|
||||
baseUrl = endpoint.baseUrl,
|
||||
onDismiss = { editing = false },
|
||||
onSaved = {
|
||||
editing = false
|
||||
onSaved()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomHeadersDialog(
|
||||
baseUrl: HttpUrl,
|
||||
onDismiss: () -> Unit,
|
||||
onSaved: () -> Unit,
|
||||
) {
|
||||
var rows by remember { mutableStateOf(ServerHeaders.get(baseUrl).entries.toList()) }
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<Int?>(null) }
|
||||
var deleting by remember { mutableStateOf<Int?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(stringResource(R.string.custom_headers_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.md),
|
||||
) {
|
||||
Text(baseUrl.toString(), style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.custom_headers_description))
|
||||
rows.forEachIndexed { index, (name, value) ->
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { new ->
|
||||
rows = rows.mapIndexed { i, row -> if (i == index) new to row.second else row }
|
||||
error = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.custom_headers_name)) },
|
||||
singleLine = true,
|
||||
enabled = !saving,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { new ->
|
||||
rows = rows.mapIndexed { i, row -> if (i == index) row.first to new else row }
|
||||
error = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.custom_headers_value)) },
|
||||
singleLine = true,
|
||||
enabled = !saving,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
visualTransformation =
|
||||
if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { visible = !visible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (visible) R.string.custom_headers_hide else R.string.custom_headers_show,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
TextButton(onClick = { deleting = index }, enabled = !saving) {
|
||||
Text(stringResource(R.string.custom_headers_remove))
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { rows = rows + ("" to "") }, enabled = !saving) {
|
||||
Text(stringResource(R.string.custom_headers_add))
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
rows = listOf("CF-Access-Client-Id" to "", "CF-Access-Client-Secret" to "")
|
||||
},
|
||||
enabled = !saving,
|
||||
) {
|
||||
Text(stringResource(R.string.custom_headers_cloudflare))
|
||||
}
|
||||
}
|
||||
error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
enabled = !saving,
|
||||
onClick = {
|
||||
val headers = runCatching { CustomHeaders.parse(rows) }.getOrNull()
|
||||
if (headers == null) {
|
||||
error = R.string.custom_headers_invalid
|
||||
} else {
|
||||
saving = true
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { ServerHeaders.save(baseUrl, headers) }
|
||||
onSaved()
|
||||
} catch (_: IOException) {
|
||||
error = R.string.custom_headers_save_failed
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
deleting?.let { index ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleting = null },
|
||||
title = { Text(stringResource(R.string.custom_headers_remove)) },
|
||||
text = { Text(stringResource(R.string.custom_headers_remove_confirm)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
rows = rows.filterIndexed { i, _ -> i != index }
|
||||
deleting = null
|
||||
error = null
|
||||
}) { Text(stringResource(R.string.action_delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleting = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List<DetailRow> =
|
||||
* Map a free-form status string to a semantic tone. Recognises the app's
|
||||
* common status vocabulary (enabled/active/running/ok → success, etc.).
|
||||
*/
|
||||
private fun toneForStatus(status: String?): DetailRowTone {
|
||||
fun toneForStatus(status: String?): DetailRowTone {
|
||||
if (status.isNullOrBlank()) return DetailRowTone.NONE
|
||||
return when (status.lowercase()) {
|
||||
"enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.m57.hermescontrol.ui.connectors
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.ConnectorAccount
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||
|
||||
@Composable
|
||||
fun AccountConnectorsScreen(
|
||||
onOpenDrawer: () -> Unit,
|
||||
vm: AccountConnectorsViewModel = viewModel { AccountConnectorsViewModel() },
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val connection by vm.connectionState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val spacing = LocalSpacing.current
|
||||
var pendingRemoval by remember(state.scope) { mutableStateOf<ConnectorAccount?>(null) }
|
||||
var browserOperation by remember(state.scope) { mutableStateOf<String?>(null) }
|
||||
var departed by remember(state.scope) { mutableStateOf(false) }
|
||||
DisposableEffect(lifecycleOwner, state.scope) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE && browserOperation != null) departed = true
|
||||
if (event == Lifecycle.Event.ON_RESUME && departed) {
|
||||
val opId = browserOperation
|
||||
browserOperation = null
|
||||
departed = false
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
if (opId != null) vm.onBrowserReturn(opId)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
if (browserOperation != null) ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
}
|
||||
}
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.account_connectors_title)) },
|
||||
navigationIcon = NavIcon.Menu(onOpenDrawer),
|
||||
onRefresh = vm::refresh,
|
||||
) {
|
||||
when {
|
||||
state.loading -> {
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
state.error != null && state.catalog.isEmpty() && state.accounts.isEmpty() -> {
|
||||
ErrorState(message = state.error.orEmpty(), onRetry = vm::refresh)
|
||||
}
|
||||
|
||||
state.catalog.isEmpty() && state.accounts.isEmpty() -> {
|
||||
EmptyState(title = stringResource(R.string.account_connectors_no_accounts))
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.md),
|
||||
) {
|
||||
state.error?.let { error -> item { Text(error, color = MaterialTheme.colorScheme.error) } }
|
||||
items(state.catalog, key = { "catalog-${it.slug}" }) { connector ->
|
||||
val current = state.connectorStates.firstOrNull { it.connector == connector.slug }
|
||||
val policy = state.policy
|
||||
val enabled = policy?.connectorEnabled(connector.slug) ?: current?.enabled
|
||||
val locked = policy?.inherited?.any { !it.connectorEnabled(connector.slug) } == true
|
||||
val editable = policy?.member != null && !state.busy && !locked
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text(connector.name, style = MaterialTheme.typography.titleMedium)
|
||||
if (connector.description.isNotBlank()) Text(connector.description)
|
||||
Text(
|
||||
current?.connectionStatus
|
||||
?: stringResource(R.string.account_connectors_not_connected),
|
||||
)
|
||||
// Stack actions so large fonts and narrow phones never hide a control.
|
||||
Button(
|
||||
onClick = { vm.connect(connector.slug, reconnect = current?.isConnected == true) },
|
||||
enabled = !state.busy && connection.operation == null,
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (current?.isConnected ==
|
||||
true
|
||||
) {
|
||||
R.string.account_connectors_reconnect
|
||||
} else {
|
||||
R.string.account_connectors_connect
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = enabled == true,
|
||||
enabled = editable,
|
||||
onCheckedChange = { vm.setEnabled(connector.slug, it) },
|
||||
)
|
||||
Text(stringResource(R.string.account_connectors_enabled))
|
||||
}
|
||||
if (locked ||
|
||||
policy?.member == null
|
||||
) {
|
||||
Text(stringResource(R.string.account_connectors_policy_locked))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { vm.loadTools(connector.slug) },
|
||||
enabled = connector.slug !in state.toolsLoading,
|
||||
) { Text(stringResource(R.string.account_connectors_tools)) }
|
||||
state.tools[connector.slug]?.let { tools ->
|
||||
if (tools.isEmpty()) Text(stringResource(R.string.account_connectors_no_tools))
|
||||
tools.forEach { tool ->
|
||||
val disabled =
|
||||
tool.slug in
|
||||
policy
|
||||
?.member
|
||||
?.tools
|
||||
?.get(connector.slug)
|
||||
.orEmpty()
|
||||
val toolLocked =
|
||||
policy?.inherited?.any { !it.toolEnabled(connector.slug, tool) } == true
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = enabled == true && !disabled && !toolLocked,
|
||||
enabled = editable && enabled == true && !toolLocked,
|
||||
onCheckedChange = { checked ->
|
||||
val updated =
|
||||
state.policy
|
||||
?.member
|
||||
?.tools
|
||||
?.get(
|
||||
connector.slug,
|
||||
).orEmpty()
|
||||
.toMutableSet()
|
||||
if (checked) updated.remove(tool.slug) else updated.add(tool.slug)
|
||||
vm.setDisabledTools(connector.slug, updated.toList())
|
||||
},
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(tool.name)
|
||||
if (toolLocked) {
|
||||
Text(
|
||||
stringResource(R.string.account_connectors_policy_locked),
|
||||
)
|
||||
}
|
||||
Text(tool.description, style = MaterialTheme.typography.bodySmall)
|
||||
Text(tool.facet, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.account_connectors_accounts),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
items(state.accounts, key = { "account-${it.connectionId}" }) { account ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text(account.label, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
state.catalog.firstOrNull { it.slug == account.connector }?.name
|
||||
?: account.connector,
|
||||
)
|
||||
Text(account.status)
|
||||
account.statusReason?.let { Text(it) }
|
||||
account.alias?.let { Text(it) }
|
||||
OutlinedButton(onClick = { pendingRemoval = account }, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.account_connectors_remove))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.accounts.isEmpty()) item { Text(stringResource(R.string.account_connectors_no_accounts)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionSetupSheet(
|
||||
state = connection,
|
||||
onRespond = vm::respond,
|
||||
onContinue = vm::continueOperation,
|
||||
onDismiss = vm::continueOperation,
|
||||
onOpenBrowser = { opId, url ->
|
||||
if (ConnectorUrlValidator.isValidHttpsUrl(url)) {
|
||||
try {
|
||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
|
||||
prepareForBackground = { NotificationHelper.start(context) },
|
||||
cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
|
||||
) {
|
||||
browserOperation = opId
|
||||
departed = false
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
browserOperation = null
|
||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||
vm.browserLaunchFailed()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
pendingRemoval?.let { account ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingRemoval = null },
|
||||
title = { Text(stringResource(R.string.account_connectors_remove_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.account_connectors_remove_confirm_message, account.label)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.remove(account.connectionId)
|
||||
pendingRemoval = null
|
||||
}, enabled = !state.busy) { Text(stringResource(R.string.account_connectors_remove)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { pendingRemoval = null },
|
||||
) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.m57.hermescontrol.ui.connectors
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorAccount
|
||||
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.ConnectorItem
|
||||
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
||||
import com.m57.hermescontrol.data.ws.AccountConnectorRepository
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate
|
||||
import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AccountConnectorsState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val catalog: List<ConnectorCatalogEntry> = emptyList(),
|
||||
val connectorStates: List<ConnectorItem> = emptyList(),
|
||||
val accounts: List<ConnectorAccount> = emptyList(),
|
||||
val tools: Map<String, List<ConnectorTool>> = emptyMap(),
|
||||
val toolsLoading: Set<String> = emptySet(),
|
||||
val policy: ConnectorPolicy? = null,
|
||||
val busy: Boolean = false,
|
||||
val scope: DataScope? = null,
|
||||
)
|
||||
|
||||
class AccountConnectorsViewModel(
|
||||
private val repository: AccountConnectorRepository = AccountConnectorRepository,
|
||||
private val scopes: StateFlow<DataScope?> = AuthManager.dataScopeFlow,
|
||||
private val currentScope: () -> DataScope? = AuthManager::currentDataScope,
|
||||
events: Flow<WsEvent> = HermesWsClient.events,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AccountConnectorsState())
|
||||
val state: StateFlow<AccountConnectorsState> = _state.asStateFlow()
|
||||
private var generation = 0L
|
||||
private var loadJob: Job? = null
|
||||
private val scopedJobs = mutableListOf<Job>()
|
||||
private val operation =
|
||||
ChatConnectionOperationDelegate(
|
||||
requester = ConnectionOperationRequester(repository::operationRequest),
|
||||
accountOwned = true,
|
||||
)
|
||||
val connectionState = operation.state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
scopes.collect { scope ->
|
||||
generation++
|
||||
loadJob?.cancel()
|
||||
scopedJobs.forEach(Job::cancel)
|
||||
scopedJobs.clear()
|
||||
operation.reset()
|
||||
_state.value = AccountConnectorsState(scope = scope)
|
||||
if (scope != null) load() else _state.update { it.copy(loading = false) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
events.collect { event ->
|
||||
if (event is WsEvent.GatewayReady) refresh()
|
||||
val snapshot = (event as? WsEvent.ConnectionUpdate)?.snapshot ?: return@collect
|
||||
// Never adopt an unsolicited account event: only our acknowledged operation belongs here.
|
||||
if (isCurrent(generation) && snapshot.accountOwned &&
|
||||
snapshot.opId ==
|
||||
operation.state.value.operation
|
||||
?.opId && operation.acceptUpdate(snapshot)
|
||||
) {
|
||||
if (snapshot.settled) load()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrent(epoch: Long): Boolean = epoch == generation && _state.value.scope == currentScope()
|
||||
|
||||
fun refresh() {
|
||||
if (!_state.value.busy) {
|
||||
load()
|
||||
if (connectionState.value.operation != null) operationAction { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
loadJob?.cancel()
|
||||
val epoch = generation
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
loadJob =
|
||||
viewModelScope.launch {
|
||||
val list = async { repository.listConnectors() }
|
||||
val catalog = async { repository.catalog() }
|
||||
val accounts = async { repository.accounts() }
|
||||
val policy = async { repository.policy() }
|
||||
val listResult = list.await()
|
||||
val catalogResult = catalog.await()
|
||||
val accountsResult = accounts.await()
|
||||
val policyResult = policy.await()
|
||||
if (!isCurrent(epoch)) return@launch
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
error =
|
||||
listResult.errorOrNull()?.message ?: catalogResult.errorMessage()
|
||||
?: accountsResult.errorMessage() ?: policyResult.errorMessage(),
|
||||
catalog = catalogResult.valueOrNull().orEmpty(),
|
||||
connectorStates = listResult.getOrNull().orEmpty(),
|
||||
accounts = accountsResult.valueOrNull().orEmpty(),
|
||||
policy = policyResult.valueOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadTools(slug: String) {
|
||||
scopedJobs.removeAll { it.isCompleted }
|
||||
if (slug in _state.value.toolsLoading || !isCurrent(generation)) return
|
||||
val epoch = generation
|
||||
_state.update { it.copy(toolsLoading = it.toolsLoading + slug) }
|
||||
scopedJobs +=
|
||||
viewModelScope.launch {
|
||||
val result = repository.tools(slug)
|
||||
if (!isCurrent(epoch)) return@launch
|
||||
_state.update {
|
||||
it.copy(
|
||||
tools = result.valueOrNull()?.let { tools -> it.tools + (slug to tools) } ?: it.tools,
|
||||
toolsLoading = it.toolsLoading - slug,
|
||||
error = result.errorMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(
|
||||
slug: String,
|
||||
reconnect: Boolean = false,
|
||||
) = mutate {
|
||||
val epoch = generation
|
||||
val result = repository.connect(listOf(slug), reconnect)
|
||||
if (isCurrent(epoch)) {
|
||||
result.valueOrNull()?.let { snapshot ->
|
||||
operation.acceptRequest(snapshot)
|
||||
if (snapshot.settled) load()
|
||||
}
|
||||
}
|
||||
result.errorMessage()
|
||||
}
|
||||
|
||||
fun remove(connectionId: String) =
|
||||
mutate {
|
||||
val epoch = generation
|
||||
val result = repository.remove(connectionId)
|
||||
if (isCurrent(epoch) && result is AccountConnectorResult.Success) load()
|
||||
result.errorMessage()
|
||||
}
|
||||
|
||||
fun setEnabled(
|
||||
slug: String,
|
||||
enabled: Boolean,
|
||||
) = updatePolicy {
|
||||
repository.setConnectorEnabled(
|
||||
slug,
|
||||
enabled,
|
||||
_state.value.policy
|
||||
?.member
|
||||
?.revision
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun setDisabledTools(
|
||||
slug: String,
|
||||
disabled: List<String>,
|
||||
) = updatePolicy {
|
||||
repository.setDisabledTools(
|
||||
slug,
|
||||
disabled,
|
||||
_state.value.policy
|
||||
?.member
|
||||
?.revision
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun updatePolicy(action: suspend () -> AccountConnectorResult<ConnectorPolicy>) =
|
||||
mutate {
|
||||
val epoch = generation
|
||||
val result = action()
|
||||
if (isCurrent(epoch)) result.valueOrNull()?.let { policy -> _state.update { it.copy(policy = policy) } }
|
||||
result.errorMessage()
|
||||
}
|
||||
|
||||
// Reserve synchronously, not inside launch: repeated taps must not start concurrent writes.
|
||||
private fun mutate(action: suspend () -> String?) {
|
||||
scopedJobs.removeAll { it.isCompleted }
|
||||
if (_state.value.scope == null || _state.value.busy || _state.value.loading || !isCurrent(generation)) return
|
||||
val epoch = generation
|
||||
_state.update { it.copy(busy = true, error = null) }
|
||||
scopedJobs +=
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (!isCurrent(epoch)) return@launch
|
||||
val error = action()
|
||||
if (isCurrent(epoch)) _state.update { it.copy(busy = false, error = error) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} finally {
|
||||
if (isCurrent(epoch)) _state.update { it.copy(busy = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun respond(
|
||||
target: String,
|
||||
env: Map<String, String>,
|
||||
approved: Boolean,
|
||||
) = operationAction {
|
||||
operation.respond(target, env, approved)
|
||||
}
|
||||
|
||||
fun continueOperation() = operationAction { operation.continueOperation() }
|
||||
|
||||
fun onBrowserReturn(opId: String) = operationAction { operation.wake(opId) }
|
||||
|
||||
private fun operationAction(action: suspend () -> Unit) {
|
||||
scopedJobs.removeAll { it.isCompleted }
|
||||
if (!isCurrent(generation)) return
|
||||
val epoch = generation
|
||||
val opId = connectionState.value.operation?.opId ?: return
|
||||
scopedJobs +=
|
||||
viewModelScope.launch {
|
||||
action()
|
||||
if (!isCurrent(epoch)) return@launch
|
||||
val result = repository.operationStatus(opId)
|
||||
if (!isCurrent(epoch)) return@launch
|
||||
when (result) {
|
||||
is AccountConnectorResult.Success -> {
|
||||
operation.acceptUpdate(result.value)
|
||||
if (result.value.settled) load()
|
||||
}
|
||||
|
||||
is AccountConnectorResult.Failure -> {
|
||||
_state.update { it.copy(error = result.error.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun browserLaunchFailed() {
|
||||
_state.update { it.copy(error = "Unable to open the authorization browser.") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> AccountConnectorResult<T>.valueOrNull(): T? = (this as? AccountConnectorResult.Success)?.value
|
||||
|
||||
private fun AccountConnectorResult<*>.errorMessage(): String? =
|
||||
(this as? AccountConnectorResult.Failure)?.error?.message
|
||||
@@ -12,11 +12,13 @@ import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -66,8 +68,9 @@ data class McpServersUiState(
|
||||
val activeOAuthFlow: McpOAuthFlowResponse? = null,
|
||||
)
|
||||
|
||||
class McpServersViewModel :
|
||||
ViewModel(),
|
||||
class McpServersViewModel(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(McpServersUiState())
|
||||
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
|
||||
@@ -131,6 +134,7 @@ class McpServersViewModel :
|
||||
// ── Server toggle ────────────────────────────────────────
|
||||
|
||||
fun toggleServer(server: McpServer) {
|
||||
if (server.isPluginOwned || isPluginOwned(server.name)) return
|
||||
val originalEnabled = server.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
|
||||
@@ -145,7 +149,7 @@ class McpServersViewModel :
|
||||
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.toggleMcpServer(
|
||||
server.name,
|
||||
@@ -163,7 +167,7 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
|
||||
revertToggle(server.name, originalEnabled, mutationError(result.error, "Failed to toggle server"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +182,7 @@ class McpServersViewModel :
|
||||
)
|
||||
}
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -234,7 +238,7 @@ class McpServersViewModel :
|
||||
|
||||
val testJobs =
|
||||
enabledServers.map { server ->
|
||||
async(Dispatchers.IO) {
|
||||
async(ioDispatcher) {
|
||||
val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) }
|
||||
server.name to res
|
||||
}
|
||||
@@ -271,9 +275,10 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
fun deleteServer(name: String) {
|
||||
if (isPluginOwned(name)) return
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -283,7 +288,7 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") }
|
||||
_uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to delete server")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,7 +316,7 @@ class McpServersViewModel :
|
||||
viewModelScope.launch {
|
||||
val importJobs =
|
||||
requests.map { req ->
|
||||
async(Dispatchers.IO) {
|
||||
async(ioDispatcher) {
|
||||
val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) }
|
||||
req to result
|
||||
}
|
||||
@@ -387,7 +392,7 @@ class McpServersViewModel :
|
||||
viewModelScope.launch {
|
||||
val request = AddServerRequestBuilder.build(state)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.addMcpServer(request) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -412,7 +417,7 @@ class McpServersViewModel :
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
addingServer = false,
|
||||
toastMessage = "Failed to add server: ${result.error.message}",
|
||||
toastMessage = mutationError(result.error, "Failed to add server"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -423,6 +428,7 @@ class McpServersViewModel :
|
||||
// ── Env var editing ──────────────────────────────────────
|
||||
|
||||
fun startEditingEnv(server: McpServer) {
|
||||
if (server.isPluginOwned || isPluginOwned(server.name)) return
|
||||
_uiState.update { it.copy(editingEnvFor = server.name, envKeyInput = "", envValueInput = "") }
|
||||
}
|
||||
|
||||
@@ -439,6 +445,7 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
fun addEnvVar(serverName: String) {
|
||||
if (isPluginOwned(serverName)) return
|
||||
val state = _uiState.value
|
||||
val key = state.envKeyInput.trim()
|
||||
val value = state.envValueInput.trim()
|
||||
@@ -450,7 +457,7 @@ class McpServersViewModel :
|
||||
val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap()
|
||||
val updatedEnv = existingEnv + (key to value)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -466,7 +473,7 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") }
|
||||
_uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to add env var")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -476,12 +483,13 @@ class McpServersViewModel :
|
||||
serverName: String,
|
||||
key: String,
|
||||
) {
|
||||
if (isPluginOwned(serverName)) return
|
||||
viewModelScope.launch {
|
||||
val state = _uiState.value
|
||||
val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap()
|
||||
val updatedEnv = existingEnv - key
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -491,7 +499,7 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") }
|
||||
_uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to remove env var")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,7 +511,7 @@ class McpServersViewModel :
|
||||
_uiState.update { it.copy(catalogLoading = true, catalogError = null) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.getMcpCatalog() }
|
||||
}
|
||||
when (result) {
|
||||
@@ -542,7 +550,7 @@ class McpServersViewModel :
|
||||
env = state.catalogInstallEnv.ifEmpty { null },
|
||||
)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.installMcpCatalogEntry(request) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -584,11 +592,12 @@ class McpServersViewModel :
|
||||
server: McpServer,
|
||||
onOpenBrowser: (String) -> Unit,
|
||||
) {
|
||||
if (server.isPluginOwned || isPluginOwned(server.name)) return
|
||||
oauthPollJob?.cancel()
|
||||
_uiState.update { it.copy(toastMessage = "Starting OAuth authorization…") }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.authMcpServer(server.name) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -608,7 +617,7 @@ class McpServersViewModel :
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") }
|
||||
_uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to start OAuth")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +632,7 @@ class McpServersViewModel :
|
||||
while (polling) {
|
||||
kotlinx.coroutines.delay(2000)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.getMcpOAuthFlowStatus(flowId) }
|
||||
}
|
||||
when (result) {
|
||||
@@ -688,6 +697,15 @@ class McpServersViewModel :
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
// #1283: re-check current ownership so stale row callbacks cannot mutate a plugin server.
|
||||
private fun isPluginOwned(name: String): Boolean =
|
||||
_uiState.value.servers.any { it.name == name && it.isPluginOwned }
|
||||
|
||||
private fun mutationError(
|
||||
error: NetworkError,
|
||||
prefix: String,
|
||||
): String = if (error is NetworkError.Http && error.code == 409) error.message else "$prefix: ${error.message}"
|
||||
|
||||
private fun revertToggle(
|
||||
name: String,
|
||||
originalEnabled: Boolean,
|
||||
|
||||
@@ -139,6 +139,16 @@ fun ServerCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (server.isPluginOwned) {
|
||||
Text(
|
||||
text =
|
||||
server.plugin?.let { stringResource(R.string.mcp_servers_from_plugin, it) }
|
||||
?: stringResource(R.string.mcp_servers_plugin_managed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Tool count & schema token overhead badge (issue #1029)
|
||||
val toolInfos = testResult?.tools
|
||||
val toolCount = toolInfos?.size ?: server.tools?.size
|
||||
@@ -173,6 +183,7 @@ fun ServerCard(
|
||||
}
|
||||
Switch(
|
||||
checked = server.enabled,
|
||||
enabled = !server.isPluginOwned,
|
||||
onCheckedChange = { viewModel.toggleServer(server) },
|
||||
)
|
||||
}
|
||||
@@ -218,6 +229,7 @@ fun ServerCard(
|
||||
if (server.auth == "oauth") {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
|
||||
enabled = !server.isPluginOwned,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
@@ -245,7 +257,10 @@ fun ServerCard(
|
||||
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||
IconButton(
|
||||
onClick = { viewModel.deleteServer(server.name) },
|
||||
enabled = !server.isPluginOwned,
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
}
|
||||
@@ -275,7 +290,7 @@ fun EnvVarSection(
|
||||
spacing: Spacing,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isEditing = state.editingEnvFor == server.name
|
||||
val isEditing = !server.isPluginOwned && state.editingEnvFor == server.name
|
||||
val env = server.env ?: emptyMap()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
@@ -300,7 +315,10 @@ fun EnvVarSection(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
|
||||
IconButton(
|
||||
onClick = { viewModel.removeEnvVar(server.name, key) },
|
||||
enabled = !server.isPluginOwned,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -342,7 +360,10 @@ fun EnvVarSection(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
|
||||
TextButton(
|
||||
onClick = { viewModel.startEditingEnv(server) },
|
||||
enabled = !server.isPluginOwned,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_env_add))
|
||||
|
||||
@@ -13,6 +13,8 @@ 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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.InstallDesktop
|
||||
@@ -67,6 +69,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
|
||||
import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -120,6 +123,37 @@ fun PluginsScreen(
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
state.updateConsent?.let { consent ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::cancelPluginUpdate,
|
||||
title = { Text(stringResource(R.string.plugins_update_consent_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_update_consent_text, consent.name))
|
||||
val lines = consent.result.capabilityDelta
|
||||
if (lines.isEmpty()) {
|
||||
Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing))
|
||||
} else {
|
||||
lines.forEach { Text(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) {
|
||||
Text(stringResource(R.string.plugins_update_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::cancelPluginUpdate) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Remove confirmation dialog
|
||||
if (state.removeConfirmPlugin != null) {
|
||||
AlertDialog(
|
||||
@@ -240,7 +274,7 @@ fun PluginsScreen(
|
||||
plugin = plugin,
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onClick = { showDetail = plugin },
|
||||
onClick = { viewModel.openPluginDetail(plugin) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,7 +290,10 @@ fun PluginsScreen(
|
||||
)
|
||||
}
|
||||
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
||||
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
|
||||
OrphanPluginCard(
|
||||
plugin = plugin,
|
||||
onClick = { viewModel.openPluginDetail(plugin) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,11 +317,19 @@ fun PluginsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
showDetail?.let { plugin ->
|
||||
DetailDialog(
|
||||
title = plugin.name,
|
||||
rows = plugin.toDetailRows(),
|
||||
onDismiss = { showDetail = null },
|
||||
state.activeDetailPlugin?.let { plugin ->
|
||||
PluginDetailDialog(
|
||||
plugin = plugin,
|
||||
agentPluginRow = state.pluginDetailRow,
|
||||
isLoading = state.isPluginDetailLoading,
|
||||
isSaving = state.isPluginDetailSaving,
|
||||
edits = state.pluginDetailEdits,
|
||||
onFieldChange = { key, value -> viewModel.setPluginDetailEdit(key, value) },
|
||||
onSaveSettings = { viewModel.savePluginSettings() },
|
||||
onDismiss = {
|
||||
showDetail = null
|
||||
viewModel.closePluginDetail()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,22 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateResult
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.ProviderOption
|
||||
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.AgentPluginRow
|
||||
import com.m57.hermescontrol.data.ws.PluginManageRepository
|
||||
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingField
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
@@ -27,12 +34,24 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
enum class PluginsTab {
|
||||
INSTALLED,
|
||||
CATALOG,
|
||||
}
|
||||
|
||||
data class PluginUpdateConsent(
|
||||
val name: String,
|
||||
val result: PluginUpdateResult,
|
||||
val scope: DataScope?,
|
||||
)
|
||||
|
||||
data class PluginsUiState(
|
||||
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
|
||||
val isLoading: Boolean = false,
|
||||
@@ -55,6 +74,13 @@ data class PluginsUiState(
|
||||
val rowBusy: String? = null,
|
||||
// Confirm remove dialog
|
||||
val removeConfirmPlugin: String? = null,
|
||||
val updateConsent: PluginUpdateConsent? = null,
|
||||
// Active plugin detail with settings & diagnostics
|
||||
val activeDetailPlugin: PluginInfo? = null,
|
||||
val isPluginDetailLoading: Boolean = false,
|
||||
val pluginDetailRow: AgentPluginRow? = null,
|
||||
val pluginDetailEdits: Map<String, String> = emptyMap(),
|
||||
val isPluginDetailSaving: Boolean = false,
|
||||
// Rescan
|
||||
val rescanBusy: Boolean = false,
|
||||
// Catalog state
|
||||
@@ -76,6 +102,7 @@ data class PluginsUiState(
|
||||
|
||||
class PluginsViewModel(
|
||||
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
|
||||
private val pluginManage: PluginManageRepository = PluginManageRepository(),
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||
@@ -99,6 +126,10 @@ class PluginsViewModel(
|
||||
providerBusy = false,
|
||||
rowBusy = null,
|
||||
removeConfirmPlugin = null,
|
||||
updateConsent = null,
|
||||
activeDetailPlugin = null,
|
||||
pluginDetailRow = null,
|
||||
pluginDetailEdits = emptyMap(),
|
||||
rescanBusy = false,
|
||||
)
|
||||
}
|
||||
@@ -450,23 +481,83 @@ class PluginsViewModel(
|
||||
}
|
||||
|
||||
fun updatePlugin(name: String) {
|
||||
viewModelScope.launch {
|
||||
setRowBusy(name)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
if (_uiState.value.updateConsent != null) return
|
||||
performPluginUpdate(name, acceptCapabilities = false)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
|
||||
clearRowBusy(name)
|
||||
fun cancelPluginUpdate() {
|
||||
_uiState.update { it.copy(updateConsent = null) }
|
||||
}
|
||||
|
||||
fun confirmPluginUpdate() {
|
||||
val consent = _uiState.value.updateConsent ?: return
|
||||
if (_uiState.value.rowBusy != null) return
|
||||
cancelPluginUpdate()
|
||||
if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return
|
||||
performPluginUpdate(consent.name, acceptCapabilities = true)
|
||||
}
|
||||
|
||||
private fun performPluginUpdate(
|
||||
name: String,
|
||||
acceptCapabilities: Boolean,
|
||||
) {
|
||||
if (_uiState.value.rowBusy != null) return
|
||||
val generation = scopeGeneration
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val api = ApiClient.hermesApi
|
||||
setRowBusy(name)
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) }
|
||||
if (generation != scopeGeneration ||
|
||||
requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val update = result.data
|
||||
// #1282: HTTP success is not update success; consent must be explicit.
|
||||
when {
|
||||
update.consentRequired -> {
|
||||
_uiState.update {
|
||||
it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope))
|
||||
}
|
||||
}
|
||||
|
||||
!update.ok -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage =
|
||||
if (update.unchanged) {
|
||||
"Plugin is already up to date"
|
||||
} else {
|
||||
"Plugin updated successfully"
|
||||
},
|
||||
)
|
||||
}
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(toastMessage = "Failed to update plugin: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (generation == scopeGeneration) clearRowBusy(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,6 +705,198 @@ class PluginsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
fun openPluginDetail(plugin: PluginInfo) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeDetailPlugin = plugin,
|
||||
isPluginDetailLoading = true,
|
||||
pluginDetailRow = null,
|
||||
pluginDetailEdits = emptyMap(),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
val rows = pluginManage.listPlugins(profile)
|
||||
val matchingRow = rows.firstOrNull { it.key == plugin.name || it.name == plugin.name }
|
||||
val initialEdits = mutableMapOf<String, String>()
|
||||
matchingRow?.settingsSchema?.forEach { field ->
|
||||
if (field.type != PluginSettingFieldType.SECRET) {
|
||||
val strVal =
|
||||
when (val v = field.value ?: field.default) {
|
||||
null -> ""
|
||||
is JsonPrimitive -> v.content
|
||||
else -> v.toString()
|
||||
}
|
||||
initialEdits[field.key] = strVal
|
||||
}
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailLoading = false,
|
||||
pluginDetailRow = matchingRow,
|
||||
pluginDetailEdits = initialEdits,
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailLoading = false,
|
||||
pluginDetailRow = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closePluginDetail() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeDetailPlugin = null,
|
||||
pluginDetailRow = null,
|
||||
pluginDetailEdits = emptyMap(),
|
||||
isPluginDetailSaving = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPluginDetailEdit(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
_uiState.update {
|
||||
it.copy(pluginDetailEdits = it.pluginDetailEdits + (key to value))
|
||||
}
|
||||
}
|
||||
|
||||
fun savePluginSettings() {
|
||||
val plugin = _uiState.value.pluginDetailRow ?: return
|
||||
val schema = plugin.settingsSchema ?: return
|
||||
val canonicalKey = plugin.key ?: plugin.name
|
||||
val currentEdits = _uiState.value.pluginDetailEdits
|
||||
|
||||
_uiState.update { it.copy(isPluginDetailSaving = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
val valuesToSave = mutableMapOf<String, JsonElement>()
|
||||
|
||||
for (field in schema) {
|
||||
val raw = currentEdits[field.key] ?: ""
|
||||
when (field.type) {
|
||||
PluginSettingFieldType.SECRET -> {
|
||||
val envVar = field.env
|
||||
if (raw.isNotBlank() && !envVar.isNullOrBlank()) {
|
||||
val envResult =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key = envVar, value = raw))
|
||||
}
|
||||
if (envResult is NetworkResult.Failure) {
|
||||
throw IllegalStateException(
|
||||
"Failed to update secret $envVar: ${envResult.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PluginSettingFieldType.STRING -> {
|
||||
valuesToSave[field.key] = JsonPrimitive(raw)
|
||||
}
|
||||
|
||||
PluginSettingFieldType.NUMBER -> {
|
||||
val num =
|
||||
raw.trim().toLongOrNull()
|
||||
?: raw.trim().toDoubleOrNull()
|
||||
if (num != null) {
|
||||
valuesToSave[field.key] =
|
||||
if (num is Long) JsonPrimitive(num) else JsonPrimitive(num as Double)
|
||||
} else if (raw.trim().isNotEmpty()) {
|
||||
throw IllegalArgumentException("${field.label}: expected a number")
|
||||
}
|
||||
}
|
||||
|
||||
PluginSettingFieldType.BOOLEAN -> {
|
||||
valuesToSave[field.key] = JsonPrimitive(raw.trim().equals("true", ignoreCase = true))
|
||||
}
|
||||
|
||||
PluginSettingFieldType.ENUM -> {
|
||||
if (!field.choices.isNullOrEmpty() && raw.trim() !in field.choices) {
|
||||
throw IllegalArgumentException(
|
||||
"${field.label}: must be one of ${field.choices.joinToString()}",
|
||||
)
|
||||
}
|
||||
valuesToSave[field.key] = JsonPrimitive(raw.trim())
|
||||
}
|
||||
|
||||
PluginSettingFieldType.JSON -> {
|
||||
val parsed =
|
||||
if (raw.trim().isEmpty()) {
|
||||
JsonPrimitive("")
|
||||
} else {
|
||||
Json.parseToJsonElement(raw.trim())
|
||||
}
|
||||
valuesToSave[field.key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (valuesToSave.isNotEmpty()) {
|
||||
val result = pluginManage.saveSettings(canonicalKey, valuesToSave, profile)
|
||||
if (!result.ok) {
|
||||
throw IllegalStateException(result.error ?: "Failed to save settings")
|
||||
}
|
||||
if (result.plugin != null) {
|
||||
val refreshed = result.plugin
|
||||
val newEdits = mutableMapOf<String, String>()
|
||||
refreshed.settingsSchema?.forEach { f ->
|
||||
if (f.type != PluginSettingFieldType.SECRET) {
|
||||
val strVal =
|
||||
when (val v = f.value ?: f.default) {
|
||||
null -> ""
|
||||
is JsonPrimitive -> v.content
|
||||
else -> v.toString()
|
||||
}
|
||||
newEdits[f.key] = strVal
|
||||
}
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailSaving = false,
|
||||
pluginDetailRow = refreshed,
|
||||
pluginDetailEdits = newEdits,
|
||||
toastMessage = "Plugin settings saved",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// If only secrets were saved or result plugin is null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailSaving = false,
|
||||
pluginDetailEdits =
|
||||
it.pluginDetailEdits.filterKeys { k ->
|
||||
schema.none { f -> f.key == k && f.type == PluginSettingFieldType.SECRET }
|
||||
},
|
||||
toastMessage = "Plugin settings saved",
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailSaving = false,
|
||||
toastMessage = "Failed to save settings: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package com.m57.hermescontrol.ui.plugins.components
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
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.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.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.ws.AgentPluginRow
|
||||
import com.m57.hermescontrol.data.ws.PluginServerRow
|
||||
import com.m57.hermescontrol.data.ws.PluginServerState
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingField
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.DetailRow
|
||||
import com.m57.hermescontrol.ui.common.DetailRowTone
|
||||
import com.m57.hermescontrol.ui.common.ExposedDropdownField
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.common.toneForStatus
|
||||
|
||||
@Composable
|
||||
fun PluginDetailDialog(
|
||||
plugin: PluginInfo,
|
||||
agentPluginRow: AgentPluginRow?,
|
||||
isLoading: Boolean,
|
||||
isSaving: Boolean,
|
||||
edits: Map<String, String>,
|
||||
onFieldChange: (String, String) -> Unit,
|
||||
onSaveSettings: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
HermesScaffold(
|
||||
title = { Text(plugin.name, style = MaterialTheme.typography.titleLarge) },
|
||||
navigationIcon = NavIcon.Back(onDismiss),
|
||||
isRefreshing = false,
|
||||
) { _ ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Standard metadata rows
|
||||
val rows = plugin.toDetailRows().filter { !it.value.isNullOrBlank() }
|
||||
rows.forEachIndexed { index, (label, value, tone) ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = value ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
when (tone) {
|
||||
DetailRowTone.SUCCESS -> statusColors.success
|
||||
DetailRowTone.WARNING -> statusColors.warning
|
||||
DetailRowTone.ERROR -> statusColors.error
|
||||
DetailRowTone.INFO -> statusColors.info
|
||||
DetailRowTone.NEUTRAL -> statusColors.neutral
|
||||
DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_settings_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics section (only if servers present)
|
||||
val servers = agentPluginRow?.servers.orEmpty()
|
||||
if (servers.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_diagnostics_heading),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
servers.forEach { server ->
|
||||
PluginServerDiagnosticRow(server = server)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings section (only if schema present)
|
||||
val schema = agentPluginRow?.settingsSchema.orEmpty()
|
||||
if (schema.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_settings_heading),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
schema.forEach { field ->
|
||||
PluginSettingFieldRow(
|
||||
field = field,
|
||||
value = edits[field.key] ?: "",
|
||||
enabled = !isSaving,
|
||||
onChange = { onFieldChange(field.key, it) },
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSaveSettings,
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.plugins_settings_save))
|
||||
}
|
||||
}
|
||||
|
||||
// Close action
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginServerDiagnosticRow(server: PluginServerRow) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (statusLabel, statusColor, badgeType) =
|
||||
when (server.state) {
|
||||
PluginServerState.CONNECTED -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_connected),
|
||||
statusColors.success,
|
||||
StatusBadgeType.SUCCESS,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.APP_NOT_RUNNING -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_app_not_running),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.ENDPOINT_UNAVAILABLE -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_endpoint_unavailable),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.NO_INTERACTIVE_SESSION -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_no_interactive_session),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.VERSION_TOO_OLD -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_version_too_old),
|
||||
statusColors.error,
|
||||
StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.MISSING_APP -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_missing_app),
|
||||
statusColors.error,
|
||||
StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.UNKNOWN -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_unknown),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = server.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusBadge(text = statusLabel, status = badgeType)
|
||||
}
|
||||
if (server.sentence.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = server.sentence,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginSettingFieldRow(
|
||||
field: PluginSettingField,
|
||||
value: String,
|
||||
enabled: Boolean,
|
||||
onChange: (String) -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = field.label.ifEmpty { field.key },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (field.type == PluginSettingFieldType.SECRET) {
|
||||
val isSet = field.hasValue == true
|
||||
val badgeRes =
|
||||
if (isSet) {
|
||||
R.string.plugins_settings_secret_set
|
||||
} else {
|
||||
R.string.plugins_settings_secret_not_set
|
||||
}
|
||||
StatusBadge(
|
||||
text = stringResource(badgeRes),
|
||||
status = if (isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (field.description.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = field.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (field.type == PluginSettingFieldType.SECRET && !field.env.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_settings_secret_hint, field.env),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
when (field.type) {
|
||||
PluginSettingFieldType.ENUM -> {
|
||||
ExposedDropdownField(
|
||||
label = field.label.ifEmpty { field.key },
|
||||
options = field.choices.orEmpty().ifEmpty { listOf(value) },
|
||||
selectedValue = value,
|
||||
onOptionSelected = onChange,
|
||||
)
|
||||
}
|
||||
|
||||
PluginSettingFieldType.BOOLEAN -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Switch(
|
||||
checked = value.equals("true", ignoreCase = true),
|
||||
onCheckedChange = { onChange(it.toString()) },
|
||||
enabled = enabled,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text =
|
||||
if (value.equals("true", ignoreCase = true)) {
|
||||
stringResource(R.string.memory_provider_bool_on)
|
||||
} else {
|
||||
stringResource(R.string.memory_provider_bool_off)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = {
|
||||
Text(
|
||||
if (field.type == PluginSettingFieldType.SECRET) {
|
||||
stringResource(R.string.memory_provider_secret_label)
|
||||
} else {
|
||||
field.label.ifEmpty { field.key }
|
||||
},
|
||||
)
|
||||
},
|
||||
singleLine = field.type != PluginSettingFieldType.JSON,
|
||||
visualTransformation =
|
||||
if (field.type == PluginSettingFieldType.SECRET) {
|
||||
PasswordVisualTransformation()
|
||||
} else {
|
||||
androidx.compose.ui.text.input.VisualTransformation.None
|
||||
},
|
||||
keyboardOptions =
|
||||
if (field.type == PluginSettingFieldType.NUMBER) {
|
||||
KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
} else {
|
||||
KeyboardOptions.Default
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -711,6 +711,17 @@ fun SessionsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
state.sessions.isEmpty() && state.corruptStorageProfiles.isNotEmpty() -> {
|
||||
ErrorState(
|
||||
message =
|
||||
stringResource(
|
||||
R.string.sessions_storage_corrupt,
|
||||
state.corruptStorageProfiles.sorted().joinToString(", "),
|
||||
),
|
||||
onRetry = { viewModel.loadSessions(forceRefresh = true) },
|
||||
)
|
||||
}
|
||||
|
||||
state.sessions.isEmpty() -> {
|
||||
EmptyState(
|
||||
title =
|
||||
@@ -752,6 +763,18 @@ fun SessionsScreen(
|
||||
|
||||
else -> {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.corruptStorageProfiles.isNotEmpty()) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.sessions_storage_corrupt,
|
||||
state.corruptStorageProfiles.sorted().joinToString(", "),
|
||||
),
|
||||
color = statusColors.warning,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
)
|
||||
}
|
||||
// ── Stats row ───────────────────────────────────────
|
||||
SessionsStatsRow(
|
||||
total = state.total,
|
||||
|
||||
@@ -81,6 +81,7 @@ data class SessionsUiState(
|
||||
val total: Int = 0,
|
||||
val hasMore: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val corruptStorageProfiles: Set<String> = emptySet(),
|
||||
val stats: SessionStats = SessionStats(),
|
||||
val isLoadingStats: Boolean = false,
|
||||
val statsError: String? = null,
|
||||
@@ -197,6 +198,7 @@ class SessionsViewModel(
|
||||
val (requestScope, capturedSection) = scopeAndSection
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration == generation && currentScope == requestScope) {
|
||||
if (handleStorageHealth(data)) return@refreshOnChange
|
||||
val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
@@ -244,6 +246,7 @@ class SessionsViewModel(
|
||||
selectedIds = emptySet(),
|
||||
isSelecting = false,
|
||||
errorMessage = null,
|
||||
corruptStorageProfiles = emptySet(),
|
||||
searchResults = emptyList(),
|
||||
searchError = null,
|
||||
searchLoadMoreError = null,
|
||||
@@ -342,6 +345,7 @@ class SessionsViewModel(
|
||||
total = 0,
|
||||
hasMore = false,
|
||||
errorMessage = null,
|
||||
corruptStorageProfiles = emptySet(),
|
||||
isSelecting = false,
|
||||
selectedIds = emptySet(),
|
||||
isSearching = false,
|
||||
@@ -352,6 +356,26 @@ class SessionsViewModel(
|
||||
if (query.isBlank()) loadSessions() else setSearchQuery(query)
|
||||
}
|
||||
|
||||
// #1286: a partial/empty damaged-store page is not an authoritative replacement.
|
||||
private fun handleStorageHealth(data: SessionListResponse): Boolean {
|
||||
val corruptProfiles = data.storage.filterValues { it == "corrupt" }.keys
|
||||
_uiState.update { state ->
|
||||
if (corruptProfiles.isEmpty()) {
|
||||
state.copy(corruptStorageProfiles = emptySet())
|
||||
} else {
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
errorMessage = null,
|
||||
corruptStorageProfiles = corruptProfiles,
|
||||
sessions = (state.sessions + data.sessions).distinctBy { it.id },
|
||||
hasMore = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
return corruptProfiles.isNotEmpty()
|
||||
}
|
||||
|
||||
/** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */
|
||||
fun loadSessions(forceRefresh: Boolean = false) {
|
||||
val requestGeneration = generation
|
||||
@@ -360,10 +384,7 @@ class SessionsViewModel(
|
||||
val localKey = "${section.name}:${section.source}:${section.excludeSources}"
|
||||
val inMemoryKey = requestScope?.inMemoryKey(localKey)
|
||||
val persistentKey = requestScope?.persistentKey(localKey)
|
||||
if (forceRefresh) {
|
||||
if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey)
|
||||
if (persistentKey != null) SessionListCacheStore.remove(persistentKey)
|
||||
}
|
||||
// #1286: keep the last good cache until a healthy refresh replaces it.
|
||||
val cached =
|
||||
if (!forceRefresh && inMemoryKey != null && persistentKey != null) {
|
||||
sessionsPageCache.get(inMemoryKey)
|
||||
@@ -418,6 +439,7 @@ class SessionsViewModel(
|
||||
onSuccess = { data ->
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad
|
||||
if (handleStorageHealth(data)) return@safeLaunchLoad
|
||||
if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data)
|
||||
if (persistentKey != null) SessionListCacheStore.put(persistentKey, data)
|
||||
rawPaginationOffset = data.nextOffset(0)
|
||||
@@ -495,6 +517,7 @@ class SessionsViewModel(
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val data = result.data
|
||||
if (handleStorageHealth(data)) return@launch
|
||||
rawPaginationOffset = data.nextOffset(offset)
|
||||
_uiState.update {
|
||||
val newSessions =
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.ListAlt
|
||||
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
@@ -34,6 +35,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.SettingsAbout
|
||||
@@ -87,6 +89,12 @@ fun SettingsScreen(
|
||||
},
|
||||
onClick = { NavigationController.navigateTo(SettingsConnection) },
|
||||
),
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Extension,
|
||||
label = stringResource(R.string.screen_account_connectors),
|
||||
summary = stringResource(R.string.account_connectors_summary),
|
||||
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
||||
),
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Palette,
|
||||
label = stringResource(R.string.settings_sec_appearance),
|
||||
|
||||
@@ -265,6 +265,15 @@ class SettingsViewModel(
|
||||
ApiClient.rebuild()
|
||||
}
|
||||
|
||||
fun onCustomHeadersSaved() {
|
||||
val editedEndpoint = ServerEndpoint.parseForBuild(_uiState.value.dialogProfileBaseUrl)
|
||||
if (editedEndpoint.baseUrl == AuthManager.endpointForBuild().baseUrl) {
|
||||
ApiClient.rebuild()
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete confirmation ──────────────────────────────────────────────
|
||||
|
||||
fun requestDeleteProfile(profileId: String) {
|
||||
|
||||
@@ -175,6 +175,7 @@ internal fun ConnectionSection(
|
||||
onTokenChange = viewModel::onDialogProfileTokenChange,
|
||||
onSave = viewModel::saveProfileFromDialog,
|
||||
onDismiss = viewModel::closeProfileDialog,
|
||||
onHeadersSaved = viewModel::onCustomHeadersSaved,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.common.CustomHeadersButton
|
||||
|
||||
@Composable
|
||||
internal fun ProfileEditorDialog(
|
||||
@@ -33,6 +34,7 @@ internal fun ProfileEditorDialog(
|
||||
onTokenChange: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onHeadersSaved: () -> Unit,
|
||||
) {
|
||||
val title =
|
||||
if (isEditing) {
|
||||
@@ -63,6 +65,7 @@ internal fun ProfileEditorDialog(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
CustomHeadersButton(baseUrl = baseUrl, onSaved = onHeadersSaved)
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
|
||||
@@ -1557,6 +1557,24 @@
|
||||
<string name="connection_setup_required">%1$s مطلوب</string>
|
||||
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
|
||||
<string name="connection_setup_pending">بانتظار Hermes…</string>
|
||||
<string name="connection_catalog_title">تثبيت %1$s</string>
|
||||
<string name="connection_catalog_kind_plugin">إضافة</string>
|
||||
<string name="connection_catalog_kind_skill">مهارة</string>
|
||||
<string name="connection_catalog_tier_official">رسمي</string>
|
||||
<string name="connection_catalog_tier_community">مجتمعي</string>
|
||||
<string name="connection_catalog_source">المصدر: %1$s</string>
|
||||
<string name="connection_catalog_pin">الإيداع المثبّت: %1$s</string>
|
||||
<string name="connection_catalog_platforms">المنصات: %1$s</string>
|
||||
<string name="connection_catalog_requirements">المتطلبات: %1$s</string>
|
||||
<string name="connection_catalog_target_profile">يُثبَّت في الملف الشخصي: %1$s</string>
|
||||
<string name="connection_catalog_scan_passed">اجتاز الفحص الأمني</string>
|
||||
<string name="connection_catalog_scan_warnings">وجد الفحص الأمني تحذيرات</string>
|
||||
<string name="connection_catalog_scan_failed">فشل الفحص الأمني</string>
|
||||
<string name="connection_catalog_scan_unreported">لم يُبلَّغ عن فحص أمني</string>
|
||||
<string name="connection_catalog_install">تثبيت</string>
|
||||
<string name="connection_catalog_retry">حاول مجددًا</string>
|
||||
<string name="connection_catalog_installing">جارٍ التثبيت…</string>
|
||||
<string name="connection_catalog_skill_ready">المهارة متاحة: %1$s</string>
|
||||
|
||||
<string name="chat_busy_mode_title">أثناء عمل الوكيل</string>
|
||||
<string name="chat_busy_mode_desc">اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول.</string>
|
||||
|
||||
@@ -1213,6 +1213,24 @@
|
||||
<string name="connection_setup_required">%1$s은(는) 필수입니다</string>
|
||||
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
|
||||
<string name="connection_setup_pending">Hermes를 기다리는 중…</string>
|
||||
<string name="connection_catalog_title">%1$s 설치</string>
|
||||
<string name="connection_catalog_kind_plugin">플러그인</string>
|
||||
<string name="connection_catalog_kind_skill">스킬</string>
|
||||
<string name="connection_catalog_tier_official">공식</string>
|
||||
<string name="connection_catalog_tier_community">커뮤니티</string>
|
||||
<string name="connection_catalog_source">소스: %1$s</string>
|
||||
<string name="connection_catalog_pin">고정 커밋: %1$s</string>
|
||||
<string name="connection_catalog_platforms">플랫폼: %1$s</string>
|
||||
<string name="connection_catalog_requirements">요구 사항: %1$s</string>
|
||||
<string name="connection_catalog_target_profile">설치 대상 프로필: %1$s</string>
|
||||
<string name="connection_catalog_scan_passed">보안 검사 통과</string>
|
||||
<string name="connection_catalog_scan_warnings">보안 검사에서 경고 발견</string>
|
||||
<string name="connection_catalog_scan_failed">보안 검사 실패</string>
|
||||
<string name="connection_catalog_scan_unreported">보고된 보안 검사 없음</string>
|
||||
<string name="connection_catalog_install">설치</string>
|
||||
<string name="connection_catalog_retry">다시 시도</string>
|
||||
<string name="connection_catalog_installing">설치 중…</string>
|
||||
<string name="connection_catalog_skill_ready">사용 가능한 스킬: %1$s</string>
|
||||
|
||||
<!-- Kanban task detail -->
|
||||
<string name="kanban_specify_task">작업 지정</string>
|
||||
|
||||
@@ -1509,6 +1509,24 @@
|
||||
<string name="connection_setup_required">%1$s为必填项</string>
|
||||
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
|
||||
<string name="connection_setup_pending">正在等待 Hermes…</string>
|
||||
<string name="connection_catalog_title">安装 %1$s</string>
|
||||
<string name="connection_catalog_kind_plugin">插件</string>
|
||||
<string name="connection_catalog_kind_skill">技能</string>
|
||||
<string name="connection_catalog_tier_official">官方</string>
|
||||
<string name="connection_catalog_tier_community">社区</string>
|
||||
<string name="connection_catalog_source">来源:%1$s</string>
|
||||
<string name="connection_catalog_pin">固定提交:%1$s</string>
|
||||
<string name="connection_catalog_platforms">平台:%1$s</string>
|
||||
<string name="connection_catalog_requirements">要求:%1$s</string>
|
||||
<string name="connection_catalog_target_profile">安装到配置文件:%1$s</string>
|
||||
<string name="connection_catalog_scan_passed">安全扫描已通过</string>
|
||||
<string name="connection_catalog_scan_warnings">安全扫描发现警告</string>
|
||||
<string name="connection_catalog_scan_failed">安全扫描失败</string>
|
||||
<string name="connection_catalog_scan_unreported">未报告安全扫描</string>
|
||||
<string name="connection_catalog_install">安装</string>
|
||||
<string name="connection_catalog_retry">重试</string>
|
||||
<string name="connection_catalog_installing">正在安装…</string>
|
||||
<string name="connection_catalog_skill_ready">可用技能:%1$s</string>
|
||||
<string name="chat_busy_mode_title">智能体工作时</string>
|
||||
<string name="chat_busy_mode_desc">选择新消息如何送达当前聊天。空闲时正常发送。</string>
|
||||
<string name="chat_busy_correct">修正当前轮次</string>
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
<!-- Chat -->
|
||||
<string name="chat_send_desc">Send</string>
|
||||
<string name="chat_mic_listening">Listening…</string>
|
||||
<string name="chat_voice_cancel">Cancel</string>
|
||||
<string name="chat_voice_lock_hint">Slide up to lock recording</string>
|
||||
<string name="chat_voice_record_failed">Could not start voice recording</string>
|
||||
<string name="chat_voice_slide_to_cancel">Slide to cancel</string>
|
||||
<string name="chat_voice_send_desc">Send voice note</string>
|
||||
<string name="chat_voice_slide_to_cancel_action">cancel</string>
|
||||
<string name="chat_voice_slide_to_cancel_prefix">Slide to</string>
|
||||
<string name="chat_voice_stop_generating">Stop generating</string>
|
||||
<string name="stt_permission_denied">Microphone permission is required for voice input</string>
|
||||
<string name="stt_not_available">Speech recognition is not available on this device</string>
|
||||
@@ -83,6 +87,7 @@
|
||||
<string name="screen_cron">Cron</string>
|
||||
<string name="screen_system">System</string>
|
||||
<string name="screen_settings">Settings</string>
|
||||
<string name="screen_account_connectors">Connectors</string>
|
||||
<string name="screen_profiles">Profiles</string>
|
||||
<string name="screen_bots">Bots</string>
|
||||
<string name="bots_active_now">Active Now</string>
|
||||
@@ -232,6 +237,11 @@
|
||||
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
|
||||
<string name="content_desc_copy">Copy</string>
|
||||
<string name="content_desc_copied">Copied</string>
|
||||
<string name="content_desc_speak">Read aloud</string>
|
||||
<string name="content_desc_stop_speaking">Stop speaking</string>
|
||||
<string name="tts_error_network">Could not reach the Hermes server for speech</string>
|
||||
<string name="tts_error_synthesis">Could not synthesize the message audio</string>
|
||||
<string name="tts_error_playback">Could not play the message audio</string>
|
||||
<string name="content_desc_edit">Edit</string>
|
||||
<string name="chat_msg_tokens">%1$s tok</string>
|
||||
<string name="chat_msg_tps">%1$s t/s</string>
|
||||
@@ -244,6 +254,17 @@
|
||||
|
||||
<!-- Settings Screen -->
|
||||
<string name="settings_sec_connection">Connection</string>
|
||||
<string name="account_connectors_title">Account connectors</string>
|
||||
<string name="account_connectors_summary">Manage account authorizations and tool access without a chat session.</string>
|
||||
<string name="account_connectors_accounts">Authorized accounts</string>
|
||||
<string name="account_connectors_connect">Connect</string>
|
||||
<string name="account_connectors_tools">Tools</string>
|
||||
<string name="account_connectors_enabled">Enabled</string>
|
||||
<string name="account_connectors_not_connected">Not connected</string>
|
||||
<string name="account_connectors_no_accounts">No authorized connector accounts.</string>
|
||||
<string name="account_connectors_remove">Remove</string>
|
||||
<string name="account_connectors_remove_confirm_title">Remove connector account?</string>
|
||||
<string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string>
|
||||
<string name="settings_field_token">Auth Token</string>
|
||||
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
|
||||
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
|
||||
@@ -1047,6 +1068,7 @@
|
||||
<string name="plugins_no_desc">No description provided.</string>
|
||||
<string name="plugins_action_update">Update</string>
|
||||
<string name="plugins_action_uninstall">Uninstall</string>
|
||||
<string name="plugins_action_settings">Settings</string>
|
||||
<string name="plugins_action_enable">Enable</string>
|
||||
<string name="plugins_action_install">Install</string>
|
||||
<string name="plugins_action_remove">Remove</string>
|
||||
@@ -1062,6 +1084,25 @@
|
||||
<string name="plugins_remove_confirm_title">Remove Plugin</string>
|
||||
<string name="plugins_remove_confirm_text">This will remove the "%1$s" plugin from your agent.</string>
|
||||
<string name="plugins_auth_required">Auth Required</string>
|
||||
<string name="plugins_settings_loading">Loading plugin settings…</string>
|
||||
<string name="plugins_settings_title">Plugin Settings</string>
|
||||
<string name="plugins_settings_save">Save Settings</string>
|
||||
<string name="plugins_settings_saved">Plugin settings saved</string>
|
||||
<string name="plugins_settings_save_failed">Failed to save settings: %1$s</string>
|
||||
<string name="plugins_diagnostics_heading">Diagnostics</string>
|
||||
<string name="plugins_diagnostics_empty">No diagnostics reported</string>
|
||||
<string name="plugins_settings_heading">Settings</string>
|
||||
<string name="plugins_settings_secret_set">set</string>
|
||||
<string name="plugins_settings_secret_not_set">not set</string>
|
||||
<string name="plugins_settings_secret_hint">Stored in .env as %1$s (leave blank to keep)</string>
|
||||
<string name="plugins_settings_empty">No configurable settings</string>
|
||||
<string name="plugins_server_state_connected">Connected</string>
|
||||
<string name="plugins_server_state_app_not_running">App not running</string>
|
||||
<string name="plugins_server_state_endpoint_unavailable">Endpoint unavailable</string>
|
||||
<string name="plugins_server_state_no_interactive_session">No interactive session</string>
|
||||
<string name="plugins_server_state_version_too_old">Version too old</string>
|
||||
<string name="plugins_server_state_missing_app">App missing</string>
|
||||
<string name="plugins_server_state_unknown">Status unknown</string>
|
||||
<string name="plugins_auth_command_hint">Auth command: %1$s</string>
|
||||
<string name="plugins_dashboard_slots">Dashboard slots: %1$s</string>
|
||||
<string name="plugins_orphan_heading">Orphaned Dashboard Plugins</string>
|
||||
@@ -1115,6 +1156,8 @@
|
||||
|
||||
<!-- McpServers Screen -->
|
||||
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
||||
<string name="mcp_servers_from_plugin">From plugin %1$s · Read-only</string>
|
||||
<string name="mcp_servers_plugin_managed">Plugin-managed · Read-only</string>
|
||||
<string name="mcp_servers_label_command">Command:</string>
|
||||
<string name="mcp_servers_action_test">Test</string>
|
||||
<string name="mcp_servers_action_test_all">Test All</string>
|
||||
@@ -1736,6 +1779,24 @@
|
||||
<string name="connection_setup_required">%1$s is required</string>
|
||||
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
|
||||
<string name="connection_setup_pending">Waiting for Hermes…</string>
|
||||
<string name="connection_catalog_title">Install %1$s</string>
|
||||
<string name="connection_catalog_kind_plugin">Plugin</string>
|
||||
<string name="connection_catalog_kind_skill">Skill</string>
|
||||
<string name="connection_catalog_tier_official">Official</string>
|
||||
<string name="connection_catalog_tier_community">Community</string>
|
||||
<string name="connection_catalog_source">Source: %1$s</string>
|
||||
<string name="connection_catalog_pin">Pinned commit: %1$s</string>
|
||||
<string name="connection_catalog_platforms">Platforms: %1$s</string>
|
||||
<string name="connection_catalog_requirements">Requires: %1$s</string>
|
||||
<string name="connection_catalog_target_profile">Installs into profile: %1$s</string>
|
||||
<string name="connection_catalog_scan_passed">Security scan passed</string>
|
||||
<string name="connection_catalog_scan_warnings">Security scan found warnings</string>
|
||||
<string name="connection_catalog_scan_failed">Security scan failed</string>
|
||||
<string name="connection_catalog_scan_unreported">No security scan reported</string>
|
||||
<string name="connection_catalog_install">Install</string>
|
||||
<string name="connection_catalog_retry">Try again</string>
|
||||
<string name="connection_catalog_installing">Installing…</string>
|
||||
<string name="connection_catalog_skill_ready">Skill available: %1$s</string>
|
||||
|
||||
<!-- Kanban task detail -->
|
||||
<string name="kanban_specify_task">Specify task</string>
|
||||
@@ -1763,4 +1824,24 @@
|
||||
<string name="kanban_attachment_error">Attachment error: %1$s</string>
|
||||
<string name="kanban_active_workers">Active workers</string>
|
||||
<string name="kanban_no_active_workers">No active workers</string>
|
||||
<string name="custom_headers_title">Custom headers</string>
|
||||
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required.</string>
|
||||
<string name="custom_headers_name">Header name</string>
|
||||
<string name="custom_headers_value">Header value</string>
|
||||
<string name="custom_headers_add">Add header</string>
|
||||
<string name="custom_headers_cloudflare">Add Cloudflare headers</string>
|
||||
<string name="custom_headers_remove">Remove header</string>
|
||||
<string name="custom_headers_remove_confirm">Remove this header? Save your changes to apply the removal.</string>
|
||||
<string name="custom_headers_show">Show header values</string>
|
||||
<string name="custom_headers_hide">Hide header values</string>
|
||||
<string name="custom_headers_invalid">Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app.</string>
|
||||
<string name="custom_headers_save_failed">Could not save custom headers. Try again.</string>
|
||||
<string name="plugins_update_consent_title">Review new capabilities</string>
|
||||
<string name="plugins_update_consent_text">Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update?</string>
|
||||
<string name="plugins_update_consent_missing">The server did not provide capability details.</string>
|
||||
<string name="plugins_update_accept">Accept and update</string>
|
||||
<string name="sessions_storage_corrupt">Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh.</string>
|
||||
<string name="account_connectors_reconnect">Reconnect</string>
|
||||
<string name="account_connectors_no_tools">No tools available</string>
|
||||
<string name="account_connectors_policy_locked">Read-only policy restrictions</string>
|
||||
</resources>
|
||||
@@ -35,6 +35,7 @@ class AuthManagerTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockPrefs = mockk(relaxed = true)
|
||||
every { mockPrefs.getString("server_custom_headers", null) } returns null
|
||||
mockEditor = mockk(relaxed = true)
|
||||
mockContext = mockk(relaxed = true)
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class Issue647ProfileUrlTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockPrefs = mockk(relaxed = true)
|
||||
every { mockPrefs.getString("server_custom_headers", null) } returns null
|
||||
mockEditor = mockk(relaxed = true)
|
||||
mockContext = mockk(relaxed = true)
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TtsTest {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serialize TtsSpeakRequest with literal text`() {
|
||||
val request = TtsSpeakRequest(text = "Hello, world!")
|
||||
val encoded = json.encodeToString(request)
|
||||
|
||||
assertEquals("""{"text":"Hello, world!"}""", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with all fields present`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
|
||||
"mime_type": "audio/wav",
|
||||
"provider": "piper"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals(
|
||||
"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
|
||||
response.data_url,
|
||||
)
|
||||
assertEquals("audio/wav", response.mime_type)
|
||||
assertEquals("piper", response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with optional provider missing`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
|
||||
"mime_type": "audio/mp3"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals(
|
||||
"data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
|
||||
response.data_url,
|
||||
)
|
||||
assertEquals("audio/mp3", response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with provider explicitly null`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=",
|
||||
"mime_type": "audio/ogg",
|
||||
"provider": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url)
|
||||
assertEquals("audio/ogg", response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with missing ok defaults to false`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"data_url": null,
|
||||
"mime_type": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() {
|
||||
val rawJson = "{}"
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Provider not reachable",
|
||||
"status_code": 500
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ServerHeadersTest {
|
||||
private val preferences = mockk<SharedPreferences>()
|
||||
private val editor = mockk<SharedPreferences.Editor>()
|
||||
private var saved: String? = null
|
||||
private lateinit var server: MockWebServer
|
||||
private val headers =
|
||||
CustomHeaders.parse(
|
||||
listOf(
|
||||
"CF-Access-Client-Id" to "test-id",
|
||||
"CF-Access-Client-Secret" to "secret",
|
||||
),
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { preferences.getString(any(), null) } answers { saved }
|
||||
every { preferences.edit() } returns editor
|
||||
every { editor.putString(any(), any()) } answers {
|
||||
saved = secondArg()
|
||||
editor
|
||||
}
|
||||
every { editor.commit() } returns true
|
||||
ServerHeaders.initialize(preferences)
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
saved = null
|
||||
ServerHeaders.initialize(preferences)
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `protected probe login ticket API and media requests require both headers`() {
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
if (request.getHeader("CF-Access-Client-Id") == "test-id" &&
|
||||
request.getHeader("CF-Access-Client-Secret") == "secret"
|
||||
) {
|
||||
MockResponse().setBody("{}")
|
||||
} else {
|
||||
MockResponse().setResponseCode(403)
|
||||
}
|
||||
}
|
||||
val url = server.url("/hermes/")
|
||||
val probe = Request.Builder().url(server.url("/hermes/api/status")).build()
|
||||
OkHttpProvider.probe
|
||||
.newCall(probe)
|
||||
.execute()
|
||||
.use { assertEquals(403, it.code) }
|
||||
ServerHeaders.save(url, headers)
|
||||
val paths = listOf("api/status", "auth/password-login", "api/auth/ws-ticket", "api/sessions", "files/image.png")
|
||||
paths.forEach { path ->
|
||||
val builder = Request.Builder().url(server.url("/hermes/$path"))
|
||||
if (path.contains("login") || path.contains("ticket")) builder.post("{}".toRequestBody())
|
||||
listOf(OkHttpProvider.probe, OkHttpProvider.base).forEach { client ->
|
||||
client.newCall(builder.build()).execute().use { assertEquals(200, it.code) }
|
||||
}
|
||||
}
|
||||
ServerHeaders.save(url, CustomHeaders.EMPTY)
|
||||
OkHttpProvider.probe
|
||||
.newCall(probe)
|
||||
.execute()
|
||||
.use { assertEquals(403, it.code) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom headers coexist with Hermes bearer and cookie authentication`() {
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
server.enqueue(MockResponse())
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(server.url("/api/status"))
|
||||
.header("Authorization", "Bearer hermes-token")
|
||||
.header("Cookie", "hermes_session_at=session")
|
||||
.build()
|
||||
OkHttpProvider.base
|
||||
.newBuilder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.build()
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.close()
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id"))
|
||||
assertEquals("Bearer hermes-token", recorded.getHeader("Authorization"))
|
||||
assertEquals("hermes_session_at=session", recorded.getHeader("Cookie"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WebSocket upgrade carries custom headers and receives a message`() {
|
||||
ServerHeaders.save(server.url("/hermes/"), headers)
|
||||
val message = CountDownLatch(1)
|
||||
server.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
webSocket.send("connected")
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
val socket =
|
||||
OkHttpProvider.websocket.newWebSocket(
|
||||
Request.Builder().url(server.url("/hermes/api/ws?ticket=test")).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (text == "connected") message.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
assertTrue(message.await(5, TimeUnit.SECONDS))
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id"))
|
||||
assertEquals("secret", recorded.getHeader("CF-Access-Client-Secret"))
|
||||
assertEquals("websocket", recorded.getHeader("Upgrade"))
|
||||
} finally {
|
||||
socket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP redirect to another configured server sends neither servers credentials`() {
|
||||
MockWebServer().use { other ->
|
||||
other.start()
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
ServerHeaders.save(other.url("/"), CustomHeaders.parse(listOf("X-Other-Secret" to "other-secret")))
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/status")))
|
||||
other.enqueue(MockResponse())
|
||||
OkHttpProvider.base
|
||||
.newCall(Request.Builder().url(server.url("/api/status")).build())
|
||||
.execute()
|
||||
.close()
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
val redirected = other.takeRequest()
|
||||
assertNull(redirected.getHeader("CF-Access-Client-Secret"))
|
||||
assertNull(redirected.getHeader("CF-Access-Client-Id"))
|
||||
assertNull(redirected.getHeader("X-Other-Secret"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-server redirect preserves headers but escaping path prefix removes them`() {
|
||||
ServerHeaders.save(server.url("/hermes/"), headers)
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/hermes/next")))
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/outside")))
|
||||
server.enqueue(MockResponse())
|
||||
OkHttpProvider.base
|
||||
.newCall(Request.Builder().url(server.url("/hermes/start")).build())
|
||||
.execute()
|
||||
.close()
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `redirect into a separately configured path does not inherit parent credentials`() {
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY)
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/public/api/status")))
|
||||
server.enqueue(MockResponse())
|
||||
OkHttpProvider.base
|
||||
.newCall(Request.Builder().url(server.url("/start")).build())
|
||||
.execute()
|
||||
.close()
|
||||
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WebSocket redirect is not followed`() {
|
||||
MockWebServer().use { other ->
|
||||
other.start()
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/ws")))
|
||||
val failed = CountDownLatch(1)
|
||||
val socket =
|
||||
OkHttpProvider.websocket.newWebSocket(
|
||||
Request.Builder().url(server.url("/api/ws")).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
if (response?.code == 302) failed.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(0, other.requestCount)
|
||||
} finally {
|
||||
socket.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scope matches scheme host port and complete path segment`() {
|
||||
val scope = ScopedServerHeaders("https://hermes.example/app/".toHttpUrl(), headers)
|
||||
assertTrue(scope.contains("https://hermes.example/app/api/status".toHttpUrl()))
|
||||
assertTrue(scope.contains("https://hermes.example/app".toHttpUrl()))
|
||||
listOf(
|
||||
"http://hermes.example/app/api/status",
|
||||
"https://other.example/app/api/status",
|
||||
"https://hermes.example:8443/app/api/status",
|
||||
"https://hermes.example/application/api/status",
|
||||
).forEach { assertFalse(scope.contains(it.toHttpUrl())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved credentials survive reload and an empty child scope blocks parent credentials`() {
|
||||
ServerHeaders.save(server.url("/"), headers)
|
||||
ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY)
|
||||
ServerHeaders.initialize(preferences)
|
||||
assertEquals("secret", ServerHeaders.get(server.url("/")).entries["CF-Access-Client-Secret"])
|
||||
assertTrue(
|
||||
ServerHeaders
|
||||
.forRequest(server.url("/public/api/status"))!!
|
||||
.headers.entries
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed storage write does not publish new credentials`() {
|
||||
every { editor.commit() } returns false
|
||||
assertThrows(IOException::class.java) { ServerHeaders.save(server.url("/"), headers) }
|
||||
assertTrue(ServerHeaders.get(server.url("/")).entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed stored JSON does not expose credentials in errors`() {
|
||||
saved = "{\"secret\": broken"
|
||||
val error = assertThrows(IOException::class.java) { ServerHeaders.initialize(preferences) }
|
||||
assertFalse(error.stackTraceToString().contains("secret"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reject invalid duplicate and managed headers without revealing values`() {
|
||||
listOf(
|
||||
listOf("Bad Name" to "secret"),
|
||||
listOf("X-Test" to "secret\r\nInjected: bad"),
|
||||
listOf("X-Test" to "secret", "x-test" to "secret"),
|
||||
listOf("Authorization" to "secret"),
|
||||
listOf("Cookie" to "secret"),
|
||||
listOf("Host" to "secret"),
|
||||
listOf("Sec-WebSocket-Key" to "secret"),
|
||||
).forEach { rows ->
|
||||
val error = assertThrows(IllegalArgumentException::class.java) { CustomHeaders.parse(rows) }
|
||||
assertFalse(error.message.orEmpty().contains("secret"))
|
||||
}
|
||||
assertFalse(headers.toString().contains("secret"))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class VoiceNoteRepositoryTest {
|
||||
private val api = mockk<HermesApiService>()
|
||||
@@ -73,6 +75,40 @@ class VoiceNoteRepositoryTest {
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_retriesOnceWhenTheConnectNeverEstablished() =
|
||||
runTest {
|
||||
// A connect failure means the request never reached the server, so
|
||||
// one re-attempt is safe and rescues Wi-Fi/cellular handoffs that
|
||||
// surface as "Network is unreachable" (device follow-up, #1247).
|
||||
val file = tempVoiceFile()
|
||||
var attempts = 0
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers {
|
||||
attempts++
|
||||
if (attempts == 1) {
|
||||
throw ConnectException("failed to connect to /192.168.1.10:8000")
|
||||
}
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = " hi "))
|
||||
}
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertEquals("hi", (result as NetworkResult.Success).data)
|
||||
coVerify(exactly = 2) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_givesUpAfterOneConnectRetry() =
|
||||
runTest {
|
||||
val file = tempVoiceFile()
|
||||
coEvery { api.transcribeAudio(any()) } throws UnknownHostException("no dns")
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 2) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
|
||||
runTest {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class AccountConnectorRepositoryTest {
|
||||
@Before fun setup() {
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow("work")
|
||||
every { AuthManager.currentDataScope() } returns null
|
||||
}
|
||||
|
||||
@After fun teardown() = unmockkAll()
|
||||
|
||||
@Test fun `connect decodes operation snapshot and preserves authorization link without a session`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
operationFixture()
|
||||
}
|
||||
val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success
|
||||
assertTrue(result.value.accountOwned)
|
||||
assertEquals("op-a", result.value.opId)
|
||||
assertEquals(
|
||||
"https://example.com/authorize",
|
||||
result.value.targets
|
||||
.single()
|
||||
.safeConnectUrl,
|
||||
)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"profile" to "work",
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"connectors" to listOf("drive"),
|
||||
"reconnect" to true,
|
||||
),
|
||||
calls.single().second,
|
||||
)
|
||||
assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first)
|
||||
}
|
||||
|
||||
@Test fun `account operation respond status and wake retain profile and owner`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
operationFixture()
|
||||
}
|
||||
repo.operationStatus("op-a")
|
||||
repo.operationRequest(
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
mapOf(
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
),
|
||||
)
|
||||
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
|
||||
assertEquals(
|
||||
listOf(
|
||||
WsMethods.CONNECTORS_OPERATION_STATUS,
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
WsMethods.CONNECTORS_OPERATION_WAKE,
|
||||
),
|
||||
calls.map {
|
||||
it.first
|
||||
},
|
||||
)
|
||||
calls.forEach { (_, params) ->
|
||||
assertEquals("work", params["profile"])
|
||||
assertEquals(mapOf("type" to "account"), params["owner"])
|
||||
assertEquals("op-a", params["op_id"])
|
||||
assertFalse("session_id" in params)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `catalog accounts tools and removal use native account endpoints`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
when (method) {
|
||||
WsMethods.CONNECTORS_CATALOG -> {
|
||||
mapOf(
|
||||
"connectors" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"slug" to "drive",
|
||||
"name" to "Google Drive",
|
||||
"description" to "Files",
|
||||
"category" to "storage",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
WsMethods.CONNECTORS_ACCOUNTS -> {
|
||||
mapOf(
|
||||
"accounts" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"connection_id" to "conn-1",
|
||||
"connector" to "drive",
|
||||
"label" to "Work account",
|
||||
"status" to "active",
|
||||
"active" to true,
|
||||
"alias" to null,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
WsMethods.CONNECTORS_TOOLS -> {
|
||||
mapOf(
|
||||
"tools" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"slug" to "delete",
|
||||
"name" to "Delete file",
|
||||
"hints" to listOf("destructive"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> {
|
||||
mapOf(
|
||||
"connection_id" to "conn-1",
|
||||
"connector" to "drive",
|
||||
"status" to "removed",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
error(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name)
|
||||
assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label)
|
||||
assertEquals(
|
||||
"Delete file",
|
||||
(repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name,
|
||||
)
|
||||
assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success)
|
||||
assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" })
|
||||
assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second)
|
||||
assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second)
|
||||
}
|
||||
|
||||
@Test fun `policy keeps member revision separate from effective and respects inherited rules`() =
|
||||
runTest {
|
||||
val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() }
|
||||
val policy = (repo.policy() as AccountConnectorResult.Success).value
|
||||
assertEquals("effective-revision", policy.revision)
|
||||
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision)
|
||||
assertEquals(listOf("mine"), policy.member?.tools?.get("drive"))
|
||||
val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive"))
|
||||
assertFalse(policy.inherited.single().toolEnabled("drive", destructive))
|
||||
assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive"))
|
||||
assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive"))
|
||||
}
|
||||
|
||||
@Test fun `writes use observed member revision not effective revision or fresh overwritten version`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
calls += method to params
|
||||
if (method == WsMethods.CONNECTORS_POLICY_GET) {
|
||||
policyFixture()
|
||||
} else {
|
||||
mapOf(
|
||||
"revision" to "new-member-revision",
|
||||
"effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"),
|
||||
)
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
repo.setDisabledTools(
|
||||
"drive",
|
||||
listOf("delete"),
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
) is AccountConnectorResult.Success,
|
||||
)
|
||||
assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first)
|
||||
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"])
|
||||
assertEquals(
|
||||
mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")),
|
||||
calls.first().second["change"],
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `malformed authorization does not look like a successful connect`() =
|
||||
runTest {
|
||||
val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") }
|
||||
assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure)
|
||||
}
|
||||
|
||||
private fun operationFixture() =
|
||||
mapOf(
|
||||
"op_id" to "op-a",
|
||||
"seq" to 1,
|
||||
"deadline_at" to 2000000000.0,
|
||||
"settled" to false,
|
||||
"targets" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"name" to "drive",
|
||||
"kind" to "connector",
|
||||
"action" to "authorize",
|
||||
"state" to "initiated",
|
||||
"connect_url" to "https://example.com/authorize",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun policyFixture() =
|
||||
mapOf(
|
||||
"effective" to
|
||||
mapOf(
|
||||
"mode" to "deny",
|
||||
"revision" to "effective-revision",
|
||||
"disabled_connectors" to emptyList<String>(),
|
||||
),
|
||||
"layers" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"kind" to "member",
|
||||
"revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
"body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))),
|
||||
),
|
||||
mapOf(
|
||||
"kind" to "org",
|
||||
"revision" to "org-revision",
|
||||
"body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -63,6 +64,39 @@ class ConnectionOperationEventParserTest {
|
||||
assertEquals("", (event as WsEvent.Unknown).raw)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionUpdate_bindsToSessionOwner() {
|
||||
val event =
|
||||
EventParser.parseParams(
|
||||
mapOf(
|
||||
"type" to "connection.update",
|
||||
"payload" to
|
||||
operationPayload(seq = 2L) +
|
||||
mapOf("owner" to mapOf("type" to "session", "session_id" to "owner-session")),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(event is WsEvent.ConnectionUpdate)
|
||||
assertEquals("owner-session", (event as WsEvent.ConnectionUpdate).snapshot.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionUpdate_accountOwnerNeverBindsToSession() {
|
||||
val event =
|
||||
EventParser.parseParams(
|
||||
mapOf(
|
||||
"type" to "connection.update",
|
||||
"session_id" to "",
|
||||
"payload" to
|
||||
operationPayload(seq = 2L) +
|
||||
mapOf("owner" to mapOf("type" to "account"), "session_id" to "stale-session"),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(event is WsEvent.ConnectionUpdate)
|
||||
assertNull((event as WsEvent.ConnectionUpdate).snapshot.sessionId)
|
||||
}
|
||||
|
||||
private fun operationPayload(seq: Long): Map<String, Any?> =
|
||||
mapOf(
|
||||
"op_id" to "op-1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.CatalogScanStatus
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetAction
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetKind
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetState
|
||||
@@ -60,6 +61,73 @@ class ConnectionOperationParserTest {
|
||||
assertEquals(ConnectionTargetState.UNKNOWN, target.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pluginAndSkillTargets_parseKindAndCatalogFacts() {
|
||||
val payload =
|
||||
completePayload() +
|
||||
(
|
||||
"targets" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"name" to "linear",
|
||||
"kind" to "plugin",
|
||||
"action" to "install",
|
||||
"state" to "pending",
|
||||
"display" to " Linear ",
|
||||
"description" to "Linear issues.",
|
||||
"tier" to "official",
|
||||
"repo" to "NousResearch/hermes-plugins",
|
||||
"sha" to "0123456789abcdef0123456789abcdef01234567",
|
||||
"subdir" to "linear",
|
||||
"scan" to mapOf("status" to "warnings", "summary" to "1 advisory"),
|
||||
"requirements" to listOf("Hermes >=0.21", "LINEAR_API_KEY environment variable"),
|
||||
"platforms" to listOf("linux", ""),
|
||||
"target_profile" to "work",
|
||||
"required_env" to
|
||||
listOf(mapOf("name" to "LINEAR_API_KEY", "required" to false, "secret" to true)),
|
||||
),
|
||||
mapOf(
|
||||
"name" to "owner/skills/review",
|
||||
"kind" to "skill",
|
||||
"action" to "install",
|
||||
"state" to "connected",
|
||||
"skill" to "review",
|
||||
"scan" to mapOf("status" to "future-status"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val (plugin, skill) = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets
|
||||
|
||||
assertEquals(ConnectionTargetKind.PLUGIN, plugin.kind)
|
||||
val catalog = plugin.catalog!!
|
||||
assertEquals("Linear", catalog.display)
|
||||
assertEquals("official", catalog.tier)
|
||||
assertEquals("NousResearch/hermes-plugins", catalog.repo)
|
||||
assertEquals("0123456789abcdef0123456789abcdef01234567", catalog.sha)
|
||||
assertEquals("linear", catalog.subdir)
|
||||
assertEquals(CatalogScanStatus.WARNINGS, catalog.scan?.status)
|
||||
assertEquals("1 advisory", catalog.scan?.summary)
|
||||
assertEquals(2, catalog.requirements.size)
|
||||
assertEquals(listOf("linux"), catalog.platforms)
|
||||
assertEquals("work", catalog.targetProfile)
|
||||
assertEquals(listOf("LINEAR_API_KEY"), plugin.requiredEnv.map { it.name })
|
||||
|
||||
assertEquals(ConnectionTargetKind.SKILL, skill.kind)
|
||||
assertEquals("owner/skills/review", skill.catalog?.display)
|
||||
assertEquals("default", skill.catalog?.targetProfile)
|
||||
assertEquals("review", skill.catalog?.skill)
|
||||
assertEquals(CatalogScanStatus.UNKNOWN, skill.catalog?.scan?.status)
|
||||
assertNull(skill.catalog?.repo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonCatalogTargets_carryNoCatalogFacts() {
|
||||
val targets = ConnectionOperationParser.parse(completePayload(), "runtime-session")!!.targets
|
||||
|
||||
assertTrue(targets.all { it.catalog == null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedIdentityOrDeadline_isRejected() {
|
||||
assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session"))
|
||||
|
||||
@@ -44,7 +44,10 @@ class ConnectorRepositoryTest {
|
||||
val result = repo.listConnectors("sess-abc-123")
|
||||
assertTrue(result is ConnectorListResult.Success)
|
||||
assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod)
|
||||
assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams)
|
||||
assertEquals(
|
||||
mapOf("owner" to mapOf("type" to "session", "session_id" to "sess-abc-123")),
|
||||
recordedParams,
|
||||
)
|
||||
// Verify no profile or singular slug mutations
|
||||
assertFalse(recordedParams?.containsKey("profile") == true)
|
||||
assertFalse(recordedParams?.containsKey("connector") == true)
|
||||
@@ -80,9 +83,14 @@ class ConnectorRepositoryTest {
|
||||
|
||||
assertTrue(result is ConnectorConnectResult.Success)
|
||||
assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod)
|
||||
assertEquals("sess-456", recordedParams?.get("session_id"))
|
||||
assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors"))
|
||||
assertEquals(true, recordedParams?.get("reconnect"))
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "sess-456"),
|
||||
"connectors" to listOf("linear", "github_app-1"),
|
||||
"reconnect" to true,
|
||||
),
|
||||
recordedParams,
|
||||
)
|
||||
|
||||
// Verify no singular slug key mutation
|
||||
assertFalse(recordedParams?.containsKey("connector") == true)
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class PluginManageModelsTest {
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes string type correctly`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "api_host",
|
||||
"type": "string",
|
||||
"label": "API Host",
|
||||
"description": "Hostname for API",
|
||||
"required": true,
|
||||
"value": "https://api.example.com",
|
||||
"default": "https://localhost",
|
||||
"choices": null,
|
||||
"env": "API_HOST",
|
||||
"has_value": true
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("api_host", field.key)
|
||||
assertEquals(PluginSettingFieldType.STRING, field.type)
|
||||
assertEquals("API Host", field.label)
|
||||
assertEquals("Hostname for API", field.description)
|
||||
assertTrue(field.required)
|
||||
assertEquals(JsonPrimitive("https://api.example.com"), field.value)
|
||||
assertEquals(JsonPrimitive("https://localhost"), field.default)
|
||||
assertNull(field.choices)
|
||||
assertEquals("API_HOST", field.env)
|
||||
assertEquals(true, field.hasValue)
|
||||
|
||||
val encoded = OkHttpProvider.json.encodeToString(field)
|
||||
val roundTripped = OkHttpProvider.json.decodeFromString<PluginSettingField>(encoded)
|
||||
assertEquals(field, roundTripped)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes number type with numeric value and default`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "port",
|
||||
"type": "number",
|
||||
"label": "Port Number",
|
||||
"value": 8080,
|
||||
"default": 3000
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("port", field.key)
|
||||
assertEquals(PluginSettingFieldType.NUMBER, field.type)
|
||||
assertEquals(JsonPrimitive(8080), field.value)
|
||||
assertEquals(JsonPrimitive(3000), field.default)
|
||||
assertEquals("", field.description)
|
||||
assertFalse(field.required)
|
||||
assertNull(field.choices)
|
||||
assertNull(field.env)
|
||||
assertNull(field.hasValue)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes boolean type with default false`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "enabled",
|
||||
"type": "boolean",
|
||||
"label": "Enabled",
|
||||
"value": true,
|
||||
"default": false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("enabled", field.key)
|
||||
assertEquals(PluginSettingFieldType.BOOLEAN, field.type)
|
||||
assertEquals(JsonPrimitive(true), field.value)
|
||||
assertEquals(JsonPrimitive(false), field.default)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes enum type with choices`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "mode",
|
||||
"type": "enum",
|
||||
"label": "Execution Mode",
|
||||
"choices": ["fast", "balanced", "thorough"],
|
||||
"value": "fast",
|
||||
"default": "balanced"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("mode", field.key)
|
||||
assertEquals(PluginSettingFieldType.ENUM, field.type)
|
||||
assertEquals(listOf("fast", "balanced", "thorough"), field.choices)
|
||||
assertEquals(JsonPrimitive("fast"), field.value)
|
||||
assertEquals(JsonPrimitive("balanced"), field.default)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes secret type with masked or null value and has_value flag`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "api_token",
|
||||
"type": "secret",
|
||||
"label": "API Token",
|
||||
"value": null,
|
||||
"env": "SECRET_TOKEN",
|
||||
"has_value": true
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("api_token", field.key)
|
||||
assertEquals(PluginSettingFieldType.SECRET, field.type)
|
||||
assertEquals(null, field.value)
|
||||
assertEquals("SECRET_TOKEN", field.env)
|
||||
assertEquals(true, field.hasValue)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes json type with object or array values`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "extra_config",
|
||||
"type": "json",
|
||||
"label": "Extra Config",
|
||||
"value": {"timeout": 30, "retries": 3},
|
||||
"default": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("extra_config", field.key)
|
||||
assertEquals(PluginSettingFieldType.JSON, field.type)
|
||||
assertTrue(field.value is JsonObject)
|
||||
val valueObj = field.value as JsonObject
|
||||
assertEquals(JsonPrimitive(30), valueObj["timeout"])
|
||||
assertEquals(JsonPrimitive(3), valueObj["retries"])
|
||||
assertTrue(field.default is JsonObject)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types (error case).
|
||||
@Test
|
||||
fun `PluginSettingField decoding fails on unknown type value`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "invalid_field",
|
||||
"type": "unknown_future_type",
|
||||
"label": "Invalid"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
fail("Expected SerializationException for unknown type")
|
||||
} catch (_: SerializationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN).
|
||||
@Test
|
||||
fun `PluginServerState decodes all enum variants from snake_case strings`() {
|
||||
val expectedStates =
|
||||
mapOf(
|
||||
"connected" to PluginServerState.CONNECTED,
|
||||
"app_not_running" to PluginServerState.APP_NOT_RUNNING,
|
||||
"endpoint_unavailable" to PluginServerState.ENDPOINT_UNAVAILABLE,
|
||||
"no_interactive_session" to PluginServerState.NO_INTERACTIVE_SESSION,
|
||||
"version_too_old" to PluginServerState.VERSION_TOO_OLD,
|
||||
"missing_app" to PluginServerState.MISSING_APP,
|
||||
"unknown" to PluginServerState.UNKNOWN,
|
||||
)
|
||||
|
||||
for ((serialized, expectedEnum) in expectedStates) {
|
||||
val decoded = OkHttpProvider.json.decodeFromString<PluginServerState>("\"$serialized\"")
|
||||
assertEquals("Mismatch for $serialized", expectedEnum, decoded)
|
||||
val reEncoded = OkHttpProvider.json.encodeToString(expectedEnum)
|
||||
assertEquals("\"$serialized\"", reEncoded)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN) via PluginServerRow.
|
||||
@Test
|
||||
fun `PluginServerRow decodes name state and sentence correctly`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "camofox_service",
|
||||
"state": "app_not_running",
|
||||
"sentence": "Camofox browser is not running on port 9333."
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<PluginServerRow>(jsonString)
|
||||
assertEquals("camofox_service", row.name)
|
||||
assertEquals(PluginServerState.APP_NOT_RUNNING, row.state)
|
||||
assertEquals("Camofox browser is not running on port 9333.", row.sentence)
|
||||
}
|
||||
|
||||
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape.
|
||||
@Test
|
||||
fun `AgentPluginRow decodes full payload shape with servers and settings schema`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "camofox",
|
||||
"key": "camofox-browser",
|
||||
"version": "1.2.0",
|
||||
"description": "Anti-detect browser automation",
|
||||
"source": "installed",
|
||||
"status": "active",
|
||||
"portable": true,
|
||||
"install_dir": "/home/user/.hermes/plugins/camofox",
|
||||
"has_desktop_half": true,
|
||||
"servers": [
|
||||
{
|
||||
"name": "cdp",
|
||||
"state": "connected",
|
||||
"sentence": "CDP connected"
|
||||
}
|
||||
],
|
||||
"settings_schema": [
|
||||
{
|
||||
"key": "port",
|
||||
"type": "number",
|
||||
"label": "CDP Port",
|
||||
"value": 9222
|
||||
}
|
||||
],
|
||||
"catalog_name": "camofox",
|
||||
"catalog_tier": "community",
|
||||
"installed_sha": "abc1234",
|
||||
"catalog_sha": "def5678",
|
||||
"catalog_version": "1.3.0",
|
||||
"update_available": true,
|
||||
"pinned_sha": "abc1234"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
|
||||
assertEquals("camofox", row.name)
|
||||
assertEquals("camofox-browser", row.key)
|
||||
assertEquals("1.2.0", row.version)
|
||||
assertEquals("Anti-detect browser automation", row.description)
|
||||
assertEquals("installed", row.source)
|
||||
assertEquals("status" to "active", "status" to row.status)
|
||||
assertTrue(row.portable)
|
||||
assertEquals("/home/user/.hermes/plugins/camofox", row.installDir)
|
||||
assertTrue(row.hasDesktopHalf)
|
||||
assertEquals(1, row.servers.size)
|
||||
assertEquals("cdp", row.servers[0].name)
|
||||
assertEquals(PluginServerState.CONNECTED, row.servers[0].state)
|
||||
assertEquals(1, row.settingsSchema?.size)
|
||||
assertEquals("port", row.settingsSchema?.get(0)?.key)
|
||||
assertEquals(PluginSettingFieldType.NUMBER, row.settingsSchema?.get(0)?.type)
|
||||
assertEquals("camofox", row.catalogName)
|
||||
assertEquals("community", row.catalogTier)
|
||||
assertEquals("abc1234", row.installedSha)
|
||||
assertEquals("def5678", row.catalogSha)
|
||||
assertEquals("1.3.0", row.catalogVersion)
|
||||
assertEquals(true, row.updateAvailable)
|
||||
assertEquals("abc1234", row.pinnedSha)
|
||||
}
|
||||
|
||||
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape (defaults/nulls).
|
||||
@Test
|
||||
fun `AgentPluginRow decodes minimal required payload using defaults`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "minimal_plugin"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
|
||||
assertEquals("minimal_plugin", row.name)
|
||||
assertNull(row.key)
|
||||
assertEquals("", row.version)
|
||||
assertEquals("", row.description)
|
||||
assertEquals("", row.source)
|
||||
assertEquals("", row.status)
|
||||
assertFalse(row.portable)
|
||||
assertEquals("", row.installDir)
|
||||
assertFalse(row.hasDesktopHalf)
|
||||
assertTrue(row.servers.isEmpty())
|
||||
assertNull(row.settingsSchema)
|
||||
assertNull(row.catalogName)
|
||||
assertNull(row.catalogTier)
|
||||
assertNull(row.installedSha)
|
||||
assertNull(row.catalogSha)
|
||||
assertNull(row.catalogVersion)
|
||||
assertNull(row.updateAvailable)
|
||||
assertNull(row.pinnedSha)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
|
||||
@Test
|
||||
fun `PluginsManageSettingsResult decodes success with updated plugin and written keys`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"name": "camofox",
|
||||
"written": ["port", "headless"],
|
||||
"plugin": {
|
||||
"name": "camofox",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
|
||||
assertTrue(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals(listOf("port", "headless"), result.written)
|
||||
assertNotNull(result.plugin)
|
||||
assertEquals("camofox", result.plugin?.name)
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
|
||||
@Test
|
||||
fun `PluginsManageSettingsResult decodes failure result with error message`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"name": "camofox",
|
||||
"error": "Validation failed: port must be an integer"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
|
||||
assertFalse(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals("Validation failed: port must be an integer", result.error)
|
||||
assertNull(result.written)
|
||||
assertNull(result.plugin)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class PluginManageRepositoryTest {
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockPayload =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "plugin-alpha",
|
||||
"version": "1.0.0",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "plugin-beta",
|
||||
"version": "2.1.0",
|
||||
"status": "disabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
}
|
||||
|
||||
val plugins = repository.listPlugins()
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(mapOf("action" to "list"), capturedParams)
|
||||
assertEquals(2, plugins.size)
|
||||
assertEquals("plugin-alpha", plugins[0].name)
|
||||
assertEquals("1.0.0", plugins[0].version)
|
||||
assertEquals("active", plugins[0].status)
|
||||
assertEquals("plugin-beta", plugins[1].name)
|
||||
assertEquals("2.1.0", plugins[1].version)
|
||||
assertEquals("disabled", plugins[1].status)
|
||||
}
|
||||
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins with non-blank profile includes profile parameter in request`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockPayload =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"plugins": []
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
}
|
||||
|
||||
val plugins = repository.listPlugins(profile = "work")
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(mapOf("action" to "list", "profile" to "work"), capturedParams)
|
||||
assertTrue(plugins.isEmpty())
|
||||
}
|
||||
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() =
|
||||
runTest {
|
||||
val emptyObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{}")
|
||||
}
|
||||
assertTrue(emptyObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nonObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("[]")
|
||||
}
|
||||
assertTrue(nonObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nullRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
null
|
||||
}
|
||||
assertTrue(nullRepo.listPlugins().isEmpty())
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
|
||||
@Test
|
||||
fun `saveSettings without profile calls PLUGINS_MANAGE with action settings key and JsonObject values`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockResponse =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"name": "camofox",
|
||||
"written": ["port", "headless"],
|
||||
"plugin": {
|
||||
"name": "camofox",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
}
|
||||
|
||||
val valuesToSave: Map<String, JsonElement> =
|
||||
mapOf(
|
||||
"port" to JsonPrimitive(9222),
|
||||
"headless" to JsonPrimitive(true),
|
||||
)
|
||||
|
||||
val result = repository.saveSettings(key = "camofox", values = valuesToSave)
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals("settings", capturedParams["action"])
|
||||
assertEquals("camofox", capturedParams["key"])
|
||||
assertEquals(JsonObject(valuesToSave), capturedParams["values"])
|
||||
assertFalse(capturedParams.containsKey("profile"))
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals(listOf("port", "headless"), result.written)
|
||||
assertNotNull(result.plugin)
|
||||
assertEquals("camofox", result.plugin?.name)
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
|
||||
@Test
|
||||
fun `saveSettings with profile includes profile in request and handles error result`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockResponse =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"name": "camofox",
|
||||
"error": "Port already in use"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
}
|
||||
|
||||
val valuesToSave: Map<String, JsonElement> =
|
||||
mapOf(
|
||||
"port" to JsonPrimitive(8080),
|
||||
)
|
||||
|
||||
val result = repository.saveSettings(key = "camofox", values = valuesToSave, profile = "staging")
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"action" to "settings",
|
||||
"key" to "camofox",
|
||||
"values" to JsonObject(valuesToSave),
|
||||
"profile" to "staging",
|
||||
),
|
||||
capturedParams,
|
||||
)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals("Port already in use", result.error)
|
||||
assertNull(result.written)
|
||||
assertNull(result.plugin)
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result (error case).
|
||||
@Test
|
||||
fun `saveSettings throws SerializationException when response is malformed`() =
|
||||
runTest {
|
||||
val repository =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}")
|
||||
}
|
||||
|
||||
try {
|
||||
repository.saveSettings("camofox", emptyMap())
|
||||
fail("Expected SerializationException when response JSON is malformed")
|
||||
} catch (_: SerializationException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +289,7 @@ class WsProfileParamsTest {
|
||||
* home) without being listed here, this test fails — the set cannot drift.
|
||||
*
|
||||
* Methods that operate on an ALREADY-BOUND session (session.usage,
|
||||
* session.interrupt, session.branch, session.redirect,
|
||||
* session.interrupt, session.branch, session.branch_whole, session.redirect,
|
||||
* session.context_breakdown, prompt.submit, slash/command dispatch,
|
||||
* approval/clarify/secret/sudo responses, file attach, process.*,
|
||||
* subscription.*, commands_catalog, usage.bars) resolve via the
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.CompressionSummary
|
||||
import com.m57.hermescontrol.data.model.SessionCompressResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatCompressRpcTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
||||
private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||
private lateinit var app: Application
|
||||
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
||||
private var reqCount = 0
|
||||
private var sessionCreateRequestId: String? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
reqCount = 0
|
||||
sessionCreateRequestId = null
|
||||
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.d(any(), any()) } returns 0
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
every { android.util.Log.w(any(), any<String>()) } returns 0
|
||||
every { android.util.Log.e(any(), any(), any()) } returns 0
|
||||
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.getPinnedModels() } returns emptyList()
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
|
||||
every { AuthManager.currentDataScope() } returns null
|
||||
every { AuthManager.getToken() } returns "test-token"
|
||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||
every { AuthManager.getSelectedProfileId() } returns null
|
||||
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
|
||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||
every { AuthManager.isMessageStatsEnabled() } returns false
|
||||
every { AuthManager.isUserMessageTokensEnabled() } returns true
|
||||
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
|
||||
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||
every { AuthManager.isModelProviderShown() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.isRestoreLastSession() } returns false
|
||||
every { AuthManager.getLastOpenedSessionId() } returns null
|
||||
every { AuthManager.setLastOpenedSessionId(any()) } returns Unit
|
||||
every { AuthManager.clearLastOpenedSessionId() } returns Unit
|
||||
|
||||
mockkObject(HermesWsClient)
|
||||
every { HermesWsClient.events } returns mockEventsFlow
|
||||
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
||||
every { HermesWsClient.connect() } answers {
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTING
|
||||
}
|
||||
every { HermesWsClient.disconnect() } returns Unit
|
||||
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-id-$reqCount"
|
||||
if (arg<String>(0) == WsMethods.SESSION_CREATE) {
|
||||
sessionCreateRequestId = id
|
||||
}
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
every { HermesWsClient.request(any(), any(), any()) } answers {
|
||||
HermesWsClient.send(arg(0), arg(1)) {}
|
||||
CompletableDeferred<Any?>(Unit)
|
||||
}
|
||||
|
||||
mockkObject(ApiClient)
|
||||
coEvery { ApiClient.hermesApi.getModelInfo() } returns retrofit2.Response.success(mockk(relaxed = true))
|
||||
coEvery { ApiClient.hermesApi.getProfiles() } returns
|
||||
retrofit2.Response.success(
|
||||
com.m57.hermescontrol.data.model
|
||||
.ProfilesResponse(emptyList()),
|
||||
)
|
||||
|
||||
mockkObject(HermesDatabase)
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
|
||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||
|
||||
app = mockk(relaxed = true)
|
||||
fakeRepo = FakeChatPersistenceRepository()
|
||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
|
||||
val vm =
|
||||
ChatViewModel(
|
||||
app,
|
||||
startCleanup = false,
|
||||
repo = fakeRepo,
|
||||
slashUsageStore = FakeSlashUsageStore(),
|
||||
searchDispatcher = testDispatcher,
|
||||
ioDispatcher = testDispatcher,
|
||||
historyDispatcher = testDispatcher,
|
||||
sendStore = ChatSendStore(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
advanceUntilIdle()
|
||||
val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" }
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz")))
|
||||
advanceUntilIdle()
|
||||
return Pair(vm, "session-xyz")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress calls SESSION_COMPRESS not command dispatch or slash exec`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), any(), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", WsMethods.SESSION_COMPRESS in methodCalls)
|
||||
assertTrue("expected NO command.dispatch", WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
assertTrue("expected NO slash.exec", "slash.exec" !in methodCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress request parameters contain session_id and omit focus_topic when blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress request parameters forward focus_topic when non-blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress auth decisions")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("auth decisions", params["focus_topic"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact behaves identically to compress omitting focus_topic when blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compact")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
|
||||
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact forwards focus_topic when non-blank`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(null)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compact architecture details")
|
||||
advanceUntilIdle()
|
||||
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("architecture details", params["focus_topic"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final response summary headline token_line note added to messages`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val responseJson =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
put(
|
||||
"summary",
|
||||
buildJsonObject {
|
||||
put("headline", JsonPrimitive("Context compressed"))
|
||||
put("token_line", JsonPrimitive("Tokens: 120k -> 35k (70% saved)"))
|
||||
put("note", JsonPrimitive("Trimmed 14 earlier turns"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(responseJson)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
val lastAssistantMessage =
|
||||
vm.uiState.value.messages
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
val expectedContent = "Context compressed\nTokens: 120k -> 35k (70% saved)\nTrimmed 14 earlier turns"
|
||||
assertEquals(expectedContent, lastAssistantMessage?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returned messages replace and reconcile visible live transcript`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val serverMessages =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 101,
|
||||
role = "user",
|
||||
content = JsonPrimitive("Original prompt"),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 102,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Compressed response"),
|
||||
),
|
||||
)
|
||||
|
||||
val responseJson =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
put(
|
||||
"summary",
|
||||
buildJsonObject {
|
||||
put("headline", JsonPrimitive("Summary headline"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"messages",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(101))
|
||||
put("role", JsonPrimitive("user"))
|
||||
put("content", JsonPrimitive("Original prompt"))
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(102))
|
||||
put("role", JsonPrimitive("assistant"))
|
||||
put("content", JsonPrimitive("Compressed response"))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(responseJson)
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
val messages = vm.uiState.value.messages
|
||||
// The transcript contains serverMessages, plus the assistant summary feedback
|
||||
val userMsg = messages.firstOrNull { it.role == MessageRole.USER && it.content == "Original prompt" }
|
||||
assertEquals("Original prompt", userMsg?.content)
|
||||
val serverAssistantMsg =
|
||||
messages.firstOrNull {
|
||||
it.role == MessageRole.ASSISTANT && it.content == "Compressed response"
|
||||
}
|
||||
assertTrue("expected replacement assistant message", serverAssistantMsg != null)
|
||||
val summaryMsg = messages.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals("Summary headline", summaryMsg?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchContextUsage is triggered after successful compression`() =
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), any(), any())
|
||||
} answers {
|
||||
val method = arg<String>(0)
|
||||
if (method == WsMethods.SESSION_COMPRESS) {
|
||||
val responseJson =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
put("message", JsonPrimitive("Context compressed."))
|
||||
}
|
||||
CompletableDeferred<Any?>(responseJson)
|
||||
} else {
|
||||
CompletableDeferred<Any?>(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
methodCalls.clear()
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
advanceUntilIdle()
|
||||
|
||||
// fetchContextUsage calls SESSION_CONTEXT_BREAKDOWN and SESSION_USAGE with sessionId
|
||||
assertTrue(
|
||||
"expected SESSION_CONTEXT_BREAKDOWN in $methodCalls",
|
||||
WsMethods.SESSION_CONTEXT_BREAKDOWN in methodCalls,
|
||||
)
|
||||
assertTrue(
|
||||
"expected SESSION_USAGE in $methodCalls",
|
||||
WsMethods.SESSION_USAGE in methodCalls,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCompressing is reset to false on success`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} returns deferred
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
// Progress before completion
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue("isCompressing should be true while in flight", vm.uiState.value.isCompressing)
|
||||
assertEquals("⏳ Compressing context...", vm.uiState.value.compressionStatus)
|
||||
|
||||
deferred.complete(
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive("ok"))
|
||||
},
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse("isCompressing should be reset to false", vm.uiState.value.isCompressing)
|
||||
assertEquals(null, vm.uiState.value.compressionStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCompressing is reset to false on RPC error`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} returns deferred
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(vm.uiState.value.isCompressing)
|
||||
|
||||
deferred.completeExceptionally(
|
||||
HermesWsClient.HermesRpcException("compression failed on backend", 4018),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse("isCompressing should be false on RPC error", vm.uiState.value.isCompressing)
|
||||
assertEquals(null, vm.uiState.value.compressionStatus)
|
||||
val errorMsg =
|
||||
vm.uiState.value.messages
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
assertTrue(errorMsg?.content?.contains("compression failed on backend") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCompressing is reset to false on generic exception`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} returns deferred
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(vm.uiState.value.isCompressing)
|
||||
|
||||
deferred.completeExceptionally(RuntimeException("network disconnected"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse("isCompressing should be false on generic exception", vm.uiState.value.isCompressing)
|
||||
assertEquals(null, vm.uiState.value.compressionStatus)
|
||||
val errorMsg =
|
||||
vm.uiState.value.messages
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
assertTrue(errorMsg?.content?.contains("network disconnected") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate compress while isCompressing is true is a no-op`() =
|
||||
runTest {
|
||||
val (vm, _) = createViewModelWithSession()
|
||||
|
||||
var compressCalls = 0
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
|
||||
} answers {
|
||||
compressCalls++
|
||||
deferred
|
||||
}
|
||||
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertTrue(vm.uiState.value.isCompressing)
|
||||
assertEquals(1, compressCalls)
|
||||
|
||||
// Second invocation while in flight
|
||||
vm.sendMessage("/compress")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("duplicate call should not trigger another RPC", 1, compressCalls)
|
||||
|
||||
// Also check /compact is blocked while in flight
|
||||
vm.sendMessage("/compact")
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
assertEquals("compact while compressing should not trigger another RPC", 1, compressCalls)
|
||||
|
||||
deferred.complete(null)
|
||||
advanceUntilIdle()
|
||||
assertFalse(vm.uiState.value.isCompressing)
|
||||
}
|
||||
}
|
||||
+58
-3
@@ -13,6 +13,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -109,7 +111,8 @@ class ChatConnectionOperationDelegateTest {
|
||||
assertEquals(1, requester.calls.size)
|
||||
val call = requester.calls.single()
|
||||
assertEquals(WsMethods.CONNECTION_RESPOND, call.first)
|
||||
assertEquals("session-a", call.second["session_id"])
|
||||
assertEquals(setOf("owner", "op_id", "result"), call.second.keys)
|
||||
assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"])
|
||||
assertEquals("op-a", call.second["op_id"])
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val result = call.second["result"] as Map<String, Any>
|
||||
@@ -177,11 +180,63 @@ class ChatConnectionOperationDelegateTest {
|
||||
continueDelegate.continueOperation()
|
||||
|
||||
assertEquals(
|
||||
mapOf("settled_by" to "continue"),
|
||||
continueRequester.calls.single().second["result"],
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
),
|
||||
continueRequester.calls.single().second,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wakePayload_sendsSessionOwnerNotTopLevelSessionId() =
|
||||
runTest {
|
||||
val requester = RecordingRequester()
|
||||
val delegate = delegate(requester)
|
||||
delegate.reset("session-a")
|
||||
delegate.acceptRequest(snapshot())
|
||||
|
||||
delegate.wake("op-a")
|
||||
|
||||
val call = requester.calls.single()
|
||||
assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
|
||||
"op_id" to "op-a",
|
||||
),
|
||||
call.second,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ownershipAndRuntimeFailures_mapToTypedNonRetryableErrors() =
|
||||
runTest {
|
||||
val cases =
|
||||
listOf(
|
||||
HermesWsClient.HermesRpcException("x", code = 4001) to
|
||||
ChatConnectionOperationDelegate.NOT_OWNER,
|
||||
HermesWsClient.HermesRpcException(
|
||||
"x",
|
||||
code = 5033,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
|
||||
) to ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME,
|
||||
)
|
||||
for ((failure, expected) in cases) {
|
||||
val delegate = delegate(RecordingRequester(failure = failure))
|
||||
delegate.reset("session-a")
|
||||
delegate.acceptRequest(snapshot())
|
||||
|
||||
delegate.continueOperation()
|
||||
|
||||
val error = delegate.state.value.error
|
||||
assertEquals(expected, error?.message)
|
||||
assertFalse(error?.retryable ?: true)
|
||||
assertNull(delegate.state.value.pendingAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcFailureUnlocksWithSanitizedError() =
|
||||
runTest {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
/** Issue #1284: history rows carry display_commentary / display_reasoning projections. */
|
||||
class ChatHistoryCommentaryTest {
|
||||
private fun row(
|
||||
content: String,
|
||||
reasoning: String? = null,
|
||||
commentary: List<String>? = null,
|
||||
displayReasoning: String? = null,
|
||||
id: Int = 7,
|
||||
) = SessionMessage(
|
||||
id = id,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(content),
|
||||
reasoning = reasoning?.let { JsonPrimitive(it) },
|
||||
display_commentary = commentary?.let { list -> JsonArray(list.map { JsonPrimitive(it) }) },
|
||||
display_reasoning = displayReasoning?.let { JsonPrimitive(it) as JsonElement },
|
||||
)
|
||||
|
||||
private fun map(vararg rows: SessionMessage) = mapServerMessages("s", rows.toList(), 0, true, emptyList())
|
||||
|
||||
private fun countOf(
|
||||
haystack: String,
|
||||
needle: String,
|
||||
) = haystack.windowed(needle.length).count { it == needle }
|
||||
|
||||
@Test
|
||||
fun commentaryRendersOnceBeforeReplyAndLeavesReasoning() {
|
||||
val commentary = "I will inspect the files."
|
||||
val message =
|
||||
map(
|
||||
row(
|
||||
content = "Done: two files changed.",
|
||||
reasoning = "Private plan.\n\n$commentary",
|
||||
commentary = listOf(commentary),
|
||||
displayReasoning = "Private plan.",
|
||||
),
|
||||
).single()
|
||||
|
||||
assertEquals("$commentary\n\nDone: two files changed.", message.content)
|
||||
assertEquals("Private plan.", message.reasoningText)
|
||||
assertEquals(1, countOf(message.content + message.reasoningText, commentary))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDisplayReasoningWinsOverRawReasoning() {
|
||||
val commentary = "Checking."
|
||||
val message =
|
||||
map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = ""))
|
||||
.single()
|
||||
|
||||
assertEquals(commentary, message.content)
|
||||
assertEquals("", message.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commentaryEqualToReplyIsNotDuplicated() {
|
||||
val text = "Looking at it now."
|
||||
val message = map(row(content = text, commentary = listOf(text), displayReasoning = "")).single()
|
||||
|
||||
assertEquals(text, message.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledCommentaryKeepsItOutOfTranscriptAndReasoning() {
|
||||
// Backend policy off: empty list, display_reasoning still strips the raw copy.
|
||||
val message =
|
||||
map(
|
||||
row(
|
||||
content = "Answer",
|
||||
reasoning = "Plan.\nSecret interim",
|
||||
commentary = emptyList(),
|
||||
displayReasoning = "Plan.",
|
||||
),
|
||||
).single()
|
||||
|
||||
assertEquals("Answer", message.content)
|
||||
assertEquals("Plan.", message.reasoningText)
|
||||
assertFalse(message.content.contains("Secret interim"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun olderGatewayFallsBackToRawReasoning() {
|
||||
val message = map(row(content = "Answer", reasoning = "Raw trace")).single()
|
||||
|
||||
assertEquals("Answer", message.content)
|
||||
assertEquals("Raw trace", message.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveSealedCommentaryBubbleIsCoveredByRestRowNotDuplicated() {
|
||||
val commentary = "I will inspect the files."
|
||||
val liveOrphan = ChatMessage(id = "live-orphan", role = MessageRole.ASSISTANT, content = commentary)
|
||||
val rest =
|
||||
map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = ""))
|
||||
|
||||
val merged = mergeTranscriptWithLive(rest, listOf(liveOrphan), preserveLiveIds = true)
|
||||
|
||||
assertEquals(1, merged.count { it.content.contains(commentary) })
|
||||
assertEquals("", merged.single().reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleCommentaryItemsKeepOrderAndSkipBlanks() {
|
||||
val message =
|
||||
map(row(content = "Final", commentary = listOf("First.", " ", "Second."), displayReasoning = "")).single()
|
||||
|
||||
assertEquals("First.\n\nSecond.\n\nFinal", message.content)
|
||||
}
|
||||
}
|
||||
@@ -316,7 +316,7 @@ class ChatPagingMergeTest {
|
||||
|
||||
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
|
||||
|
||||
assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped)
|
||||
assertEquals(listOf(live.copy(restId = "rest-s-42", serverRowId = 42L)), mapped)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -328,7 +328,10 @@ class ChatPagingMergeTest {
|
||||
val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache)
|
||||
|
||||
assertEquals(
|
||||
listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")),
|
||||
listOf(
|
||||
first.copy(restId = "rest-s-42", serverRowId = 42L),
|
||||
second.copy(restId = "rest-s-43", serverRowId = 43L),
|
||||
),
|
||||
mapped,
|
||||
)
|
||||
assertEquals(2, mapped.map { it.id }.toSet().size)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #1285: persisted-turn receipts are addresses, never negative acknowledgements. */
|
||||
class ChatPersistedReceiptsTest {
|
||||
@Test
|
||||
fun parsesCompleteReceiptWithMixedNumberTypes() {
|
||||
val turn =
|
||||
parsePersistedTurn(
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf(
|
||||
"row_ids" to listOf(41, 42L, 43.0),
|
||||
"complete" to true,
|
||||
"user_row_id" to 41,
|
||||
"final_assistant_row_id" to 43L,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(PersistedTurn(true, 41L, 43L), turn)
|
||||
assertEquals(
|
||||
43L,
|
||||
parsePersistedTurn(mapOf("persisted_turn" to mapOf("final_assistant_row_id" to 43.0)))?.finalAssistantRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingIdsStayUnprovenAndMalformedValuesAreIgnored() {
|
||||
val turn =
|
||||
parsePersistedTurn(
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf("complete" to "true", "user_row_id" to true, "final_assistant_row_id" to 1.5),
|
||||
),
|
||||
)!!
|
||||
|
||||
assertFalse(turn.complete)
|
||||
assertNull(positiveRowId("8"))
|
||||
assertNull(positiveRowId(0))
|
||||
assertNull(positiveRowId(-1L))
|
||||
assertNull(turn.userRowId)
|
||||
assertNull(turn.finalAssistantRowId)
|
||||
assertNull(parsePersistedTurn(mapOf("text" to "done")))
|
||||
assertNull(parsePersistedTurn(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowIdMatchesUserEchoEvenWhenServerTextDiffers() {
|
||||
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hi", serverRowId = 42)
|
||||
val rest =
|
||||
ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "hi\n\n--- rewritten", serverRowId = 42)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
|
||||
|
||||
assertEquals(listOf("local"), merged.map { it.id })
|
||||
assertEquals("rest-s-42", merged.single().restId)
|
||||
assertEquals(42L, merged.single().serverRowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentRowIdsKeepRepeatedTextAsSeparateTurns() {
|
||||
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "again", serverRowId = 43)
|
||||
val older = ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "again", serverRowId = 42)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(older), listOf(local), preserveLiveIds = true)
|
||||
|
||||
assertEquals(setOf("local", "rest-s-42"), merged.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingRowIdFallsBackToContentMatching() {
|
||||
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hello")
|
||||
val rest = ChatMessage(id = "rest-s-9", role = MessageRole.USER, content = "hello", serverRowId = 9)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
|
||||
|
||||
assertEquals(listOf("local"), merged.map { it.id })
|
||||
assertEquals(9L, merged.single().serverRowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completeReceiptBindsFinalAssistantRow() {
|
||||
val result =
|
||||
complete(
|
||||
"answer",
|
||||
mapOf(
|
||||
"row_ids" to listOf(1, 2),
|
||||
"complete" to true,
|
||||
"final_assistant_row_id" to 2,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2L,
|
||||
result.state.messages
|
||||
.single()
|
||||
.serverRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialReceiptStillBindsProvenFinalRowButKeepsLocalReply() {
|
||||
val result =
|
||||
complete("answer", mapOf("row_ids" to listOf(2), "complete" to false, "final_assistant_row_id" to 2))
|
||||
|
||||
assertEquals(
|
||||
"answer",
|
||||
result.state.messages
|
||||
.single()
|
||||
.content,
|
||||
)
|
||||
assertEquals(
|
||||
2L,
|
||||
result.state.messages
|
||||
.single()
|
||||
.serverRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiptWithoutFinalIdLeavesReplyUnbound() {
|
||||
val result = complete("answer", mapOf("row_ids" to listOf(1), "complete" to false, "user_row_id" to 1))
|
||||
|
||||
assertEquals(
|
||||
"answer",
|
||||
result.state.messages
|
||||
.single()
|
||||
.content,
|
||||
)
|
||||
assertNull(
|
||||
result.state.messages
|
||||
.single()
|
||||
.serverRowId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strippedCommentaryPrefixDoesNotClaimFinalRow() {
|
||||
val sealed = ChatMessage(id = "sealed", role = MessageRole.ASSISTANT, content = "Looking. ")
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state = ChatUiState(currentSessionId = "session-1", messages = listOf(sealed)),
|
||||
streamingState = StreamingState(sealedOrphanIds = listOf("sealed")),
|
||||
event =
|
||||
WsEvent.MessageComplete(
|
||||
"Looking. Done",
|
||||
"session-1",
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf("row_ids" to listOf(5), "complete" to true, "final_assistant_row_id" to 5),
|
||||
),
|
||||
),
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
|
||||
val reply = result.state.messages.single { it.id != "sealed" }
|
||||
assertEquals("Done", reply.content)
|
||||
assertNull(reply.serverRowId)
|
||||
assertTrue(result.state.messages.none { it.serverRowId == 5L })
|
||||
}
|
||||
|
||||
private fun complete(
|
||||
text: String,
|
||||
receipt: Map<String, Any?>,
|
||||
) = ChatWsEventReducer.reduce(
|
||||
state = ChatUiState(currentSessionId = "session-1"),
|
||||
streamingState = StreamingState(),
|
||||
event = WsEvent.MessageComplete(text, "session-1", rawPayload = mapOf("persisted_turn" to receipt)),
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
}
|
||||
@@ -64,6 +64,28 @@ class ChatPromptProtocolTest {
|
||||
assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyClarifyBatchReplay_skipsLockedAnswers_withoutEmptyOverwrite() {
|
||||
val responses =
|
||||
buildLegacyClarifyBatchResponses(
|
||||
questions =
|
||||
listOf(
|
||||
ClarifyQuestionUi(qid = "q0", question = "Already answered?"),
|
||||
ClarifyQuestionUi(qid = "q1", question = "Still waiting?"),
|
||||
ClarifyQuestionUi(qid = "q2", question = "Optional skip?"),
|
||||
),
|
||||
answers =
|
||||
mapOf(
|
||||
"q0" to "",
|
||||
"q1" to " new answer ",
|
||||
"q2" to "",
|
||||
),
|
||||
lockedAnswers = mapOf("q0" to "yes"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("q1" to "new answer", "q2" to ""), responses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun credentialPrompt_serverRequestId_controls_response_and_cancellation() {
|
||||
val scope = TestScope(dispatcher)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatQueuePresentationTest {
|
||||
private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
|
||||
|
||||
private fun pending(state: PendingSendState) =
|
||||
PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
|
||||
|
||||
@Test
|
||||
fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
|
||||
val messages = listOf(message)
|
||||
for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
|
||||
val receipts = listOf(pending(state))
|
||||
assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
|
||||
assertEquals(listOf(message), messages)
|
||||
assertEquals(state, receipts.single().state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedMessageAppearsOnceWhenSendingStarts() {
|
||||
val messages = listOf(message)
|
||||
assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
|
||||
for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
|
||||
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ordinarySendAndOtherMessagesAreUnchanged() {
|
||||
val other = ChatMessage("other", MessageRole.USER, "Send now")
|
||||
val messages = listOf(other, message)
|
||||
assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
|
||||
assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
|
||||
val messages = listOf(message.copy(restId = "rest-session-12"))
|
||||
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
|
||||
}
|
||||
}
|
||||
@@ -761,12 +761,12 @@ class ChatViewModelTest {
|
||||
id
|
||||
}
|
||||
|
||||
// /fork with an optional branch title.
|
||||
// /fork with an optional branch title sends session.branch_whole.
|
||||
viewModel.sendMessage("/fork my-fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
|
||||
assertNotNull("session.branch should be dispatched for /fork", branchSent)
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
|
||||
assertEquals(sessionId, branchSent!!.second["session_id"])
|
||||
assertEquals("my-fork", branchSent.second["name"])
|
||||
}
|
||||
@@ -811,12 +811,147 @@ class ChatViewModelTest {
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
|
||||
assertNotNull("session.branch should be dispatched for /fork", branchSent)
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
|
||||
assertEquals(sessionId, branchSent!!.second["session_id"])
|
||||
assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_fork_branchWholeSuccess_handledLikeSessionBranch() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole dispatched", branchWholeSent)
|
||||
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
"req-1",
|
||||
mapOf(
|
||||
"session_id" to "runtime-branch-whole",
|
||||
"stored_session_id" to "stored-branch-whole",
|
||||
"title" to "Whole Branch",
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("runtime-branch-whole", ActiveSessionHolder.activeSessionId.value)
|
||||
assertEquals("stored-branch-whole", viewModel.uiState.value.currentSessionId)
|
||||
assertTrue(
|
||||
"System message 'Session branched' should be added",
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.content.contains("Session branched") },
|
||||
)
|
||||
// No fallback was sent
|
||||
assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH })
|
||||
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH_WHOLE })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_fork_branchWholeError32601_retriesWithSessionBranch() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("/fork my-retry-branch")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be sent", branchWholeSent)
|
||||
assertEquals("my-retry-branch", branchWholeSent!!.second["name"])
|
||||
assertEquals(sessionId, branchWholeSent.second["session_id"])
|
||||
|
||||
// Backend answers -32601 unknown method
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcError(
|
||||
"req-1",
|
||||
JsonRpcError(code = -32601, message = "unknown method: session.branch_whole"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Retries ONCE with session.branch using same params
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
|
||||
assertNotNull("session.branch fallback should be sent", branchSent)
|
||||
assertEquals("my-retry-branch", branchSent!!.second["name"])
|
||||
assertEquals(sessionId, branchSent.second["session_id"])
|
||||
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH })
|
||||
|
||||
// session.branch result is then handled
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
"req-2",
|
||||
mapOf(
|
||||
"session_id" to "runtime-branch-fallback",
|
||||
"stored_session_id" to "stored-branch-fallback",
|
||||
"title" to "my-retry-branch",
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("runtime-branch-fallback", ActiveSessionHolder.activeSessionId.value)
|
||||
assertEquals("stored-branch-fallback", viewModel.uiState.value.currentSessionId)
|
||||
assertTrue(
|
||||
"System message 'Session branched' should be added",
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.content.contains("Session branched") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_fork_branchWholeOtherError_noFallback() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be sent", branchWholeSent)
|
||||
|
||||
// Backend answers with code other than -32601
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcError(
|
||||
"req-1",
|
||||
JsonRpcError(code = -32600, message = "invalid request"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No retry/fallback sent
|
||||
assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH })
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("session.branch_whole") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_stop_sendsInterrupt() =
|
||||
runTest {
|
||||
@@ -3516,6 +3651,112 @@ class ChatViewModelTest {
|
||||
assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completePersistedTurnRetiresOnlyTheReceiptBoundToItsUserRow() =
|
||||
runTest {
|
||||
val sendStore = ChatSendStore()
|
||||
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
|
||||
every { HermesWsClient.sendMessage(sessionId, "Durable", any(), any()) } answers {
|
||||
arg<((String) -> Unit)?>(2)?.invoke("durable-submit")
|
||||
"durable-submit"
|
||||
}
|
||||
viewModel.sendMessage("Durable")
|
||||
advanceUntilIdle()
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult("durable-submit", mapOf("status" to "streaming", "user_row_id" to 77)),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val receipt = sendStore.all().single()
|
||||
assertEquals(77L, receipt.userRowId)
|
||||
assertEquals(
|
||||
77L,
|
||||
viewModel.uiState.value.messages
|
||||
.single { it.id == receipt.id }
|
||||
.serverRowId,
|
||||
)
|
||||
|
||||
// Partial and foreign receipts never retire the local turn.
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.MessageComplete(
|
||||
"Reply",
|
||||
sessionId,
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf(
|
||||
"row_ids" to listOf(77),
|
||||
"complete" to false,
|
||||
"user_row_id" to 77,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.MessageComplete(
|
||||
"Reply",
|
||||
sessionId,
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to mapOf("row_ids" to listOf(78), "complete" to true, "user_row_id" to 78),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
|
||||
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.MessageComplete(
|
||||
"Reply",
|
||||
sessionId,
|
||||
rawPayload =
|
||||
mapOf(
|
||||
"persisted_turn" to
|
||||
mapOf(
|
||||
"row_ids" to listOf(77, 79),
|
||||
"complete" to true,
|
||||
"user_row_id" to 77,
|
||||
"final_assistant_row_id" to 79,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
assertTrue(sendStore.all().isEmpty())
|
||||
assertTrue(
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.id == receipt.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitAckWithoutUserRowIdLeavesReceiptUnproven() =
|
||||
runTest {
|
||||
val sendStore = ChatSendStore()
|
||||
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
|
||||
every { HermesWsClient.sendMessage(sessionId, "Legacy", any(), any()) } answers {
|
||||
arg<((String) -> Unit)?>(2)?.invoke("legacy-submit")
|
||||
"legacy-submit"
|
||||
}
|
||||
viewModel.sendMessage("Legacy")
|
||||
advanceUntilIdle()
|
||||
mockEventsFlow.emit(WsEvent.RpcResult("legacy-submit", mapOf("status" to "streaming")))
|
||||
advanceUntilIdle()
|
||||
|
||||
val receipt = sendStore.all().single()
|
||||
assertNull(receipt.userRowId)
|
||||
assertEquals(PendingSendState.ACCEPTED, receipt.state)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.id == receipt.id && it.serverRowId == null },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() =
|
||||
runTest {
|
||||
@@ -5128,7 +5369,7 @@ class ChatViewModelTest {
|
||||
|
||||
viewModel.sendMessage("/fork")
|
||||
runCurrent()
|
||||
val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH }.second
|
||||
val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
branchRequest,
|
||||
@@ -5962,11 +6203,11 @@ class ChatViewModelTest {
|
||||
.SessionMessagesResponse(messages = emptyList()),
|
||||
)
|
||||
|
||||
// /fork sends session.branch keyed on the runtime id.
|
||||
// /fork sends session.branch_whole keyed on the runtime id.
|
||||
viewModel.sendMessage("/fork")
|
||||
advanceUntilIdle()
|
||||
|
||||
val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH }.second
|
||||
val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second
|
||||
val branchStorage = "branch-storage-1"
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user