This commit is contained in:
19 files changed
+1557
-14
No files matched your search
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot(
|
||||
val settled: Boolean,
|
||||
val settledBy: String?,
|
||||
val targets: List<ConnectionOperationTarget>,
|
||||
val accountOwned: Boolean = false,
|
||||
)
|
||||
|
||||
data class ConnectionOperationTarget(
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ object ConnectionOperationParser {
|
||||
settled = payload["settled"] as? Boolean ?: false,
|
||||
settledBy = payload["settled_by"] as? String,
|
||||
targets = targets,
|
||||
accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,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 +172,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
|
||||
|
||||
@@ -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