This commit is contained in:
19 files changed
+1557
-14
No files matched your search
@@ -60,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
|
||||
@@ -170,7 +173,7 @@ class ChatConnectionOperationDelegate(
|
||||
method = WsMethods.CONNECTION_RESPOND,
|
||||
params =
|
||||
mapOf(
|
||||
"owner" to ConnectorRepository.sessionOwner(requireSessionId()),
|
||||
"owner" to ownerParams(),
|
||||
"op_id" to snapshot.opId,
|
||||
"result" to mapOf("targets" to listOf(answer)),
|
||||
),
|
||||
@@ -183,7 +186,7 @@ class ChatConnectionOperationDelegate(
|
||||
method = WsMethods.CONNECTION_RESPOND,
|
||||
params =
|
||||
mapOf(
|
||||
"owner" to ConnectorRepository.sessionOwner(requireSessionId()),
|
||||
"owner" to ownerParams(),
|
||||
"op_id" to snapshot.opId,
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
),
|
||||
@@ -196,7 +199,7 @@ class ChatConnectionOperationDelegate(
|
||||
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
|
||||
dispatch(
|
||||
method = WsMethods.CONNECTORS_OPERATION_WAKE,
|
||||
params = mapOf("owner" to ConnectorRepository.sessionOwner(requireSessionId()), "op_id" to snapshot.opId),
|
||||
params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId),
|
||||
clearOnSuccess = true,
|
||||
unknownOperationSettles = true,
|
||||
)
|
||||
@@ -237,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
|
||||
@@ -259,9 +262,15 @@ class ChatConnectionOperationDelegate(
|
||||
}
|
||||
|
||||
private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean =
|
||||
!sessionId.isNullOrBlank() && snapshot.sessionId == sessionId
|
||||
if (accountOwned) {
|
||||
snapshot.accountOwned
|
||||
} else {
|
||||
!snapshot.accountOwned && !sessionId.isNullOrBlank() &&
|
||||
snapshot.sessionId == sessionId
|
||||
}
|
||||
|
||||
private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() })
|
||||
private fun ownerParams(): Map<String, String> =
|
||||
if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId))
|
||||
|
||||
private fun currentOp(): String =
|
||||
_state.value.operation
|
||||
|
||||
@@ -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,7 +43,15 @@ 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 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 result = element["result"] as? JsonObject ?: element
|
||||
|
||||
ToolViewBuilder.build(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user