Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3512b9fa1 | ||
|
|
40eff9c5c6 | ||
|
|
b12712a79a | ||
|
|
27705d8291 |
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Added
|
||||
|
||||
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
|
||||
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -6,6 +6,21 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Bot Mode follow-ups after multi-gateway aggregation
|
||||
|
||||
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
|
||||
ownership, install-identity collapse, source-qualified handles, offline cache,
|
||||
route-pooled Gateway clients, and dedicated owner-routed Bot Chats without a
|
||||
foreground connection switch. Keep autonomous cross-gateway delivery on
|
||||
upstream peer/server authority rather than making Android an unreliable
|
||||
background courier. Writable group rooms stay blocked until upstream publishes
|
||||
one canonical room read/write/control contract; do not reproduce Desktop's
|
||||
local orchestrator in the phone. Route-scoped outbound attachments, Relay media,
|
||||
voice, and proactive completion notifications can be added independently when
|
||||
their credential and lifecycle ownership is explicit.
|
||||
|
||||
---
|
||||
|
||||
## Certify Android assistant screen context on physical firmware
|
||||
|
||||
Host-side coverage and one Android 15 automotive device prove the primary flow.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
data class BotGatewayRouteKey(
|
||||
val connectionId: String,
|
||||
val profileName: String,
|
||||
) {
|
||||
init {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
require(profileName.isNotBlank()) { "profileName must not be blank" }
|
||||
}
|
||||
}
|
||||
|
||||
class BotGatewayRoute(
|
||||
val key: BotGatewayRouteKey,
|
||||
val connectionLabel: String,
|
||||
val installId: String? = null,
|
||||
) {
|
||||
val connectionId: String get() = key.connectionId
|
||||
val profileName: String get() = key.profileName
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is BotGatewayRoute && key == other.key
|
||||
override fun hashCode(): Int = key.hashCode()
|
||||
override fun toString(): String = "BotGatewayRoute(key=$key, label=$connectionLabel)"
|
||||
}
|
||||
|
||||
/** Bounded session summary published by upstream `profiles.list`. */
|
||||
data class BotSessionSummary(
|
||||
val id: String,
|
||||
val resolvedId: String = id,
|
||||
val title: String = "",
|
||||
val rootTitle: String = "",
|
||||
val preview: String = "",
|
||||
val startedAtMs: Long = 0L,
|
||||
val lastActiveAtMs: Long = 0L,
|
||||
val messageCount: Int = 0,
|
||||
)
|
||||
|
||||
data class BotRosterEntry(
|
||||
val profile: Profile,
|
||||
val displayName: String,
|
||||
val route: BotGatewayRoute? = null,
|
||||
val handle: String = profile.name,
|
||||
val stale: Boolean = false,
|
||||
val botTitle: String = "",
|
||||
val hidden: Boolean = false,
|
||||
val lastSession: BotSessionSummary? = null,
|
||||
val workerSession: BotSessionSummary? = null,
|
||||
val canonicalSession: BotSessionSummary? = null,
|
||||
) {
|
||||
val latestActivityAtMs: Long
|
||||
get() = maxOf(
|
||||
canonicalSession?.lastActiveAtMs ?: 0L,
|
||||
lastSession?.lastActiveAtMs ?: 0L,
|
||||
)
|
||||
|
||||
val presenceActivityAtMs: Long
|
||||
get() = maxOf(latestActivityAtMs, workerSession?.lastActiveAtMs ?: 0L)
|
||||
|
||||
val latestPreview: String
|
||||
get() = canonicalSession?.preview?.takeIf(String::isNotBlank)
|
||||
?: lastSession?.preview.orEmpty()
|
||||
}
|
||||
|
||||
data class BotGroupMember(
|
||||
val name: String,
|
||||
val handle: String? = null,
|
||||
val connectionId: String? = null,
|
||||
val connectionLabel: String? = null,
|
||||
)
|
||||
|
||||
data class BotGroupMessage(
|
||||
val id: String? = null,
|
||||
val senderName: String,
|
||||
val senderKind: String,
|
||||
val senderSource: String? = null,
|
||||
val text: String,
|
||||
val atMs: Long,
|
||||
)
|
||||
|
||||
data class BotGroupRoom(
|
||||
val key: String,
|
||||
val roomId: String? = null,
|
||||
val name: String,
|
||||
val revision: Long = 0L,
|
||||
val members: List<BotGroupMember> = emptyList(),
|
||||
val messages: List<BotGroupMessage> = emptyList(),
|
||||
val sourceConnectionIds: Set<String> = emptySet(),
|
||||
val stale: Boolean = false,
|
||||
) {
|
||||
val latestMessage: BotGroupMessage? get() = messages.maxByOrNull(BotGroupMessage::atMs)
|
||||
val latestActivityAtMs: Long get() = latestMessage?.atMs ?: 0L
|
||||
}
|
||||
|
||||
data class BotModeRoster(
|
||||
val bots: List<BotRosterEntry> = emptyList(),
|
||||
val groups: List<BotGroupRoom> = emptyList(),
|
||||
val botModeProtocolSupported: Boolean = false,
|
||||
)
|
||||
|
||||
data class BotGatewayRosterStatus(
|
||||
val connectionId: String,
|
||||
val label: String,
|
||||
val installId: String? = null,
|
||||
val loading: Boolean = false,
|
||||
val stale: Boolean = false,
|
||||
val error: String? = null,
|
||||
val botCount: Int = 0,
|
||||
)
|
||||
|
||||
data class BotChatTarget(
|
||||
/** Durable registry-row identity. */
|
||||
val storedSessionId: String,
|
||||
/** Compression-lineage tip that should be resumed. */
|
||||
val resolvedSessionId: String = storedSessionId,
|
||||
)
|
||||
|
||||
data class BotModeState(
|
||||
val loading: Boolean = false,
|
||||
val roster: BotModeRoster = BotModeRoster(),
|
||||
val gateways: List<BotGatewayRosterStatus> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -56,6 +56,7 @@ import okio.BufferedSink
|
||||
@Serializable
|
||||
data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
@SerialName("install_id") val installId: String? = null,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||
@@ -1464,8 +1465,16 @@ class DashboardApiClient(
|
||||
fun authLoginUrl(provider: String, next: String = "/"): String =
|
||||
authLoginUrl(baseUrl = baseUrl, provider = provider, next = next)
|
||||
|
||||
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
|
||||
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
|
||||
fun gatewayWebSocketUrl(
|
||||
ticket: String,
|
||||
path: String = "/api/ws",
|
||||
profile: String? = null,
|
||||
): String? = gatewayWebSocketUrl(
|
||||
baseUrl = baseUrl,
|
||||
ticket = ticket,
|
||||
path = path,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
fun shutdown() = shutdownOffMainThread("DashboardApiClient-shutdown") {
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
@@ -1722,6 +1731,7 @@ class DashboardApiClient(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
?: false,
|
||||
installId = root.stringField("install_id")?.trim()?.takeIf(String::isNotEmpty)?.take(256),
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
|
||||
@@ -14,6 +14,13 @@ import com.hermesandroid.relay.data.GatewayProfilePatch
|
||||
import com.hermesandroid.relay.data.GatewayProfileSection
|
||||
import com.hermesandroid.relay.data.GatewayProfileSkill
|
||||
import com.hermesandroid.relay.data.GatewayProfileToolset
|
||||
import com.hermesandroid.relay.data.BotChatTarget
|
||||
import com.hermesandroid.relay.data.BotGroupMember
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.BotSessionSummary
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
@@ -91,6 +98,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
*/
|
||||
class GatewayChatClient(
|
||||
initialDashboardClient: DashboardApiClient,
|
||||
private val fixedSessionProfile: String? = null,
|
||||
okHttpClient: OkHttpClient? = null,
|
||||
private val callbackDispatcher: (block: () -> Unit) -> Unit = MainThreadDispatcher,
|
||||
/** Surface for "this server has no usable /api/ws" — flips availability to Unsupported. */
|
||||
@@ -123,6 +131,7 @@ class GatewayChatClient(
|
||||
private var profileSetAssetSupported: Boolean? = null
|
||||
companion object {
|
||||
private const val TAG = "GatewayChatClient"
|
||||
private const val BOT_CHAT_TITLE = "Bot Chat"
|
||||
|
||||
/**
|
||||
* Idle-progress turn watchdog — reset on EVERY received gateway event
|
||||
@@ -389,7 +398,8 @@ class GatewayChatClient(
|
||||
var sessionProfileProvider: () -> String? = { null }
|
||||
|
||||
private fun currentSessionProfile(): String? =
|
||||
sessionProfileProvider().takeIf { !it.isNullOrBlank() }
|
||||
fixedSessionProfile?.trim()?.takeIf(String::isNotBlank)
|
||||
?: sessionProfileProvider().takeIf { !it.isNullOrBlank() }
|
||||
|
||||
/**
|
||||
* Supplies non-model overrides for each fresh `session.create`. Model and
|
||||
@@ -1480,6 +1490,102 @@ class GatewayChatClient(
|
||||
}.onSuccess { profileListSupported = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich Bot Mode roster from the upstream Gateway. Kept separate from
|
||||
* [listProfiles] because session previews and room projections are useful
|
||||
* to the messenger surface but needlessly expensive for ordinary profile
|
||||
* selectors.
|
||||
*/
|
||||
suspend fun listBotModeRoster(): Result<BotModeRoster> {
|
||||
if (profileListSupported == false) {
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
val response = rpc(
|
||||
"profiles.list",
|
||||
buildJsonObject { put("include_sessions", true) },
|
||||
)
|
||||
if (response.exceptionOrNull().isMethodNotFound()) {
|
||||
profileListSupported = false
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
|
||||
}
|
||||
return response.mapCatching(::parseBotModeRoster)
|
||||
.onSuccess { profileListSupported = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the profile's one canonical hidden `Bot Chat`, creating it only
|
||||
* after an authoritative exact-title lookup returned no row. Lookup errors
|
||||
* fail closed so a transient connection problem can never fork the bot's
|
||||
* durable conversation.
|
||||
*/
|
||||
suspend fun ensureCanonicalBotChat(profileName: String): Result<BotChatTarget> = runCatching {
|
||||
val profile = profileName.trim().takeIf(String::isNotEmpty)
|
||||
?: throw IllegalArgumentException("profile name required")
|
||||
connectMutex.withLock {
|
||||
ensureConnected()
|
||||
val existing = rpc(
|
||||
"session.list",
|
||||
buildJsonObject {
|
||||
put("profile", profile)
|
||||
put("title", BOT_CHAT_TITLE)
|
||||
put("include_hidden", true)
|
||||
put("limit", 200)
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException(
|
||||
"Could not check $profile's Bot Chat registry: ${error.message}",
|
||||
)
|
||||
}
|
||||
val row = (existing["sessions"] as? JsonArray)
|
||||
?.firstOrNull() as? JsonObject
|
||||
if (row != null) {
|
||||
val stored = row.stringField("id")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayPreflightException("Bot Chat registry returned no session id")
|
||||
val resolved = row.stringField("resolved_id")?.takeIf(String::isNotBlank) ?: stored
|
||||
return@withLock BotChatTarget(storedSessionId = stored, resolvedSessionId = resolved)
|
||||
}
|
||||
|
||||
if (hasActiveTurn()) {
|
||||
throw GatewayPreflightException("Wait for the current Hermes turn to finish before creating Bot Chat")
|
||||
}
|
||||
val created = rpc(
|
||||
"session.create",
|
||||
buildJsonObject {
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
put("profile", profile)
|
||||
put("title", BOT_CHAT_TITLE)
|
||||
put("hidden", true)
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException("Bot Chat creation failed: ${error.message}")
|
||||
}
|
||||
requireConfirmedSessionProfile(created, profile)
|
||||
val live = created.stringField("session_id")
|
||||
?: throw GatewayPreflightException("Bot Chat creation returned no session id")
|
||||
val stored = created.stringField("stored_session_id") ?: live
|
||||
|
||||
// `session.create` is lazy. Title the live runtime immediately so
|
||||
// the durable exact-title registry exists before navigation or a
|
||||
// second tap; newer upstream materializes the row here.
|
||||
rpc(
|
||||
"session.title",
|
||||
buildJsonObject {
|
||||
put("session_id", live)
|
||||
put("title", BOT_CHAT_TITLE)
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException("Bot Chat could not be materialized: ${error.message}")
|
||||
}
|
||||
BotChatTarget(storedSessionId = stored, resolvedSessionId = stored)
|
||||
}
|
||||
}
|
||||
|
||||
/** Create through the Gateway so auth behavior is explicit and server-owned. */
|
||||
suspend fun createProfile(request: GatewayProfileCreateRequest): Result<GatewayProfileCreateResult> {
|
||||
if (profileCreateSupported == false) {
|
||||
@@ -2383,7 +2489,10 @@ class GatewayChatClient(
|
||||
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
|
||||
}
|
||||
val ticketMs = (System.nanoTime() - connectStart) / 1_000_000
|
||||
val url = dashboardClient.gatewayWebSocketUrl(ticket.ticket)
|
||||
val url = dashboardClient.gatewayWebSocketUrl(
|
||||
ticket = ticket.ticket,
|
||||
profile = currentSessionProfile(),
|
||||
)
|
||||
?: throw GatewayConnectAttemptException("could not build /api/ws URL")
|
||||
|
||||
_connectionState.value = GatewayConnectionState.Connecting
|
||||
@@ -4149,6 +4258,122 @@ data class GatewayCompressResult(
|
||||
get() = messages.isNotEmpty()
|
||||
}
|
||||
|
||||
internal fun parseBotModeRoster(payload: JsonObject): BotModeRoster {
|
||||
val rawRows = (payload["profiles"] as? JsonArray).orEmpty()
|
||||
val rows = rawRows.mapNotNull { it as? JsonObject }
|
||||
val bots = rows.mapNotNull(::parseBotRosterEntry)
|
||||
val defaultRow = rows.firstOrNull {
|
||||
(it["is_default"] as? JsonPrimitive)?.booleanOrNull == true
|
||||
} ?: rows.firstOrNull { it.stringField("name") == "default" }
|
||||
return BotModeRoster(
|
||||
bots = bots,
|
||||
groups = parseBotGroupRooms(defaultRow),
|
||||
botModeProtocolSupported =
|
||||
(payload["bot_mode_protocol"] as? JsonPrimitive)?.booleanOrNull == true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseBotRosterEntry(row: JsonObject): BotRosterEntry? {
|
||||
val name = row.stringField("name")?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
val uiMeta = (row["ui_meta"] as? JsonObject)
|
||||
?.takeIf { it.toString().toByteArray(Charsets.UTF_8).size <= 65_536 }
|
||||
?: JsonObject(emptyMap())
|
||||
val botMeta = uiMeta["hermes-bots"] as? JsonObject
|
||||
val title = botMeta?.stringField("title")?.trim()?.take(128).orEmpty()
|
||||
val displayName = title.takeIf(String::isNotBlank)
|
||||
?: row.stringField("display_name")?.trim()?.takeIf(String::isNotBlank)?.take(128)
|
||||
?: name
|
||||
return BotRosterEntry(
|
||||
profile = Profile(
|
||||
name = name,
|
||||
model = row.stringField("model").orEmpty(),
|
||||
provider = row.stringField("provider").orEmpty(),
|
||||
description = row.stringField("description")?.take(512).orEmpty(),
|
||||
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
),
|
||||
displayName = displayName,
|
||||
botTitle = title,
|
||||
hidden = (botMeta?.get("hidden") as? JsonPrimitive)?.booleanOrNull == true,
|
||||
lastSession = parseBotSessionSummary(row["last_session"] as? JsonObject),
|
||||
workerSession = parseBotSessionSummary(row["worker_session"] as? JsonObject),
|
||||
canonicalSession = parseBotSessionSummary(row["canonical_session"] as? JsonObject),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseBotSessionSummary(row: JsonObject?): BotSessionSummary? {
|
||||
row ?: return null
|
||||
val id = row.stringField("id")?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
return BotSessionSummary(
|
||||
id = id,
|
||||
resolvedId = row.stringField("resolved_id")?.trim()?.takeIf(String::isNotEmpty) ?: id,
|
||||
title = row.stringField("title")?.take(256).orEmpty(),
|
||||
rootTitle = row.stringField("root_title")?.take(256).orEmpty(),
|
||||
preview = row.stringField("preview")?.take(512).orEmpty(),
|
||||
startedAtMs = normalizeHermesEpoch(row.longField("started_at")),
|
||||
lastActiveAtMs = normalizeHermesEpoch(row.longField("last_active")),
|
||||
messageCount = (row["message_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseBotGroupRooms(defaultRow: JsonObject?): List<BotGroupRoom> {
|
||||
val uiMeta = defaultRow?.get("ui_meta") as? JsonObject ?: return emptyList()
|
||||
if (uiMeta.toString().toByteArray(Charsets.UTF_8).size > 65_536) return emptyList()
|
||||
val snapshot = uiMeta["hermes-bots-groups"] as? JsonObject ?: return emptyList()
|
||||
val rooms = snapshot["rooms"] as? JsonObject ?: return emptyList()
|
||||
return rooms.entries.take(64).mapNotNull { (key, raw) ->
|
||||
val room = raw as? JsonObject ?: return@mapNotNull null
|
||||
val name = room.stringField("name")?.trim()?.takeIf(String::isNotEmpty)?.take(128)
|
||||
?: key.substringAfter(':').take(128)
|
||||
val members = (room["members"] as? JsonArray).orEmpty().take(6).mapNotNull { memberRaw ->
|
||||
val member = memberRaw as? JsonObject ?: return@mapNotNull null
|
||||
val memberName = member.stringField("name")?.trim()?.takeIf(String::isNotEmpty)
|
||||
?: return@mapNotNull null
|
||||
BotGroupMember(
|
||||
name = memberName.take(128),
|
||||
handle = member.stringField("handle")?.take(128),
|
||||
connectionId = member.stringField("connectionId")?.take(128),
|
||||
connectionLabel = member.stringField("connectionLabel")?.take(128),
|
||||
)
|
||||
}
|
||||
val messages = (room["log"] as? JsonArray).orEmpty().takeLast(16).mapNotNull { messageRaw ->
|
||||
val message = messageRaw as? JsonObject ?: return@mapNotNull null
|
||||
val from = message["from"] as? JsonObject ?: JsonObject(emptyMap())
|
||||
val text = message.stringField("text")?.trim()?.takeIf(String::isNotEmpty)?.take(1_200)
|
||||
?: return@mapNotNull null
|
||||
BotGroupMessage(
|
||||
id = message.stringField("id")?.take(160),
|
||||
senderName = from.stringField("name")?.trim()?.takeIf(String::isNotEmpty)?.take(128)
|
||||
?: "Bot",
|
||||
senderKind = from.stringField("kind")?.take(32) ?: "member",
|
||||
senderSource = from.stringField("source")?.take(128),
|
||||
text = text,
|
||||
atMs = normalizeHermesEpoch(message.longField("at")),
|
||||
)
|
||||
}
|
||||
BotGroupRoom(
|
||||
key = key.take(256),
|
||||
roomId = room.stringField("roomId")?.take(128),
|
||||
name = name,
|
||||
revision = room.longField("revision"),
|
||||
members = members,
|
||||
messages = messages,
|
||||
)
|
||||
}.sortedByDescending(BotGroupRoom::latestActivityAtMs)
|
||||
}
|
||||
|
||||
private fun JsonObject.longField(key: String): Long =
|
||||
(get(key) as? JsonPrimitive)?.longOrNull
|
||||
?: (get(key) as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.toLong()
|
||||
?: 0L
|
||||
|
||||
private fun normalizeHermesEpoch(value: Long): Long = when {
|
||||
value <= 0L -> 0L
|
||||
value < 10_000_000_000L -> value * 1_000L
|
||||
else -> value
|
||||
}
|
||||
|
||||
private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -153,6 +154,9 @@ import com.hermesandroid.relay.ui.screens.DiagnosticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.BridgeScreen
|
||||
// === PHASE3-safety-rails: bridge safety route ===
|
||||
import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.BotGroupDetailScreen
|
||||
import com.hermesandroid.relay.ui.screens.BotChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.BotModeScreen
|
||||
// === END PHASE3-safety-rails ===
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||
@@ -397,6 +401,28 @@ sealed class Screen(
|
||||
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
|
||||
}
|
||||
}
|
||||
data object BotMode : Screen("bot_mode", "Bot Mode", Icons.Filled.Groups)
|
||||
data object BotGroup : Screen(
|
||||
"bot_mode/groups/{roomKey}",
|
||||
"Bot group",
|
||||
Icons.Filled.Groups,
|
||||
) {
|
||||
const val ARG_ROOM_KEY: String = "roomKey"
|
||||
fun route(roomKey: String): String =
|
||||
"bot_mode/groups/${android.net.Uri.encode(roomKey)}"
|
||||
}
|
||||
data object BotChat : Screen(
|
||||
"bot_mode/chat/{connectionId}/{profileName}/{sessionId}",
|
||||
"Bot Chat",
|
||||
Icons.AutoMirrored.Filled.Chat,
|
||||
) {
|
||||
const val ARG_CONNECTION_ID: String = "connectionId"
|
||||
const val ARG_PROFILE_NAME: String = "profileName"
|
||||
const val ARG_SESSION_ID: String = "sessionId"
|
||||
fun route(connectionId: String, profileName: String, sessionId: String): String =
|
||||
"bot_mode/chat/${android.net.Uri.encode(connectionId)}/" +
|
||||
"${android.net.Uri.encode(profileName)}/${android.net.Uri.encode(sessionId)}"
|
||||
}
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
|
||||
@@ -672,6 +698,7 @@ fun RelayApp() {
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
|
||||
@@ -2027,8 +2054,108 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.BotMode.route) {
|
||||
BotModeScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenBotChat = { route, sessionId ->
|
||||
navController.navigate(
|
||||
Screen.BotChat.route(
|
||||
connectionId = route.connectionId,
|
||||
profileName = route.profileName,
|
||||
sessionId = sessionId,
|
||||
),
|
||||
)
|
||||
},
|
||||
onOpenGroup = { roomKey ->
|
||||
navController.navigate(Screen.BotGroup.route(roomKey))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.BotGroup.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.BotGroup.ARG_ROOM_KEY) { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val roomKey = entry.arguments?.getString(Screen.BotGroup.ARG_ROOM_KEY)
|
||||
val botModeState by connectionViewModel.botModeState.collectAsState()
|
||||
BotGroupDetailScreen(
|
||||
room = botModeState.roster.groups.firstOrNull { it.key == roomKey },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.BotChat.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.BotChat.ARG_CONNECTION_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.BotChat.ARG_PROFILE_NAME) { type = NavType.StringType },
|
||||
navArgument(Screen.BotChat.ARG_SESSION_ID) { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val connectionId = entry.arguments?.getString(Screen.BotChat.ARG_CONNECTION_ID).orEmpty()
|
||||
val profileName = entry.arguments?.getString(Screen.BotChat.ARG_PROFILE_NAME).orEmpty()
|
||||
val sessionId = entry.arguments?.getString(Screen.BotChat.ARG_SESSION_ID).orEmpty()
|
||||
val botModeState by connectionViewModel.botModeState.collectAsState()
|
||||
val connection = connections.firstOrNull { it.id == connectionId }
|
||||
val bot = botModeState.roster.bots.firstOrNull {
|
||||
it.route?.connectionId == connectionId && it.profile.name == profileName
|
||||
}
|
||||
val route = bot?.route ?: connection?.let {
|
||||
com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey(connectionId, profileName),
|
||||
connectionLabel = it.label,
|
||||
)
|
||||
}
|
||||
val currentRouteUrl = connection?.let {
|
||||
if (it.id == activeConnectionId) effectiveDashboardUrl else it.resolvedDashboardUrl
|
||||
}.orEmpty()
|
||||
val lease = remember(route?.key, currentRouteUrl) {
|
||||
route?.let(connectionViewModel::acquireBotGateway)?.getOrNull()
|
||||
}
|
||||
val botDashboardClient = remember(route?.key, currentRouteUrl) {
|
||||
route?.let(connectionViewModel::botDashboardClient)?.getOrNull()
|
||||
}
|
||||
DisposableEffect(lease, botDashboardClient) {
|
||||
onDispose {
|
||||
lease?.close()
|
||||
botDashboardClient?.shutdown()
|
||||
}
|
||||
}
|
||||
if (
|
||||
route == null || bot == null || lease == null ||
|
||||
botDashboardClient == null || sessionId.isBlank()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_chat_open_failed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val botChatViewModel: ChatViewModel = viewModel(
|
||||
key = "bot-chat:${route.connectionId}:${route.profileName}:$sessionId",
|
||||
)
|
||||
BotChatScreen(
|
||||
route = route,
|
||||
bot = bot,
|
||||
sessionId = sessionId,
|
||||
gatewayClient = lease.client,
|
||||
dashboardClient = botDashboardClient,
|
||||
chatViewModel = botChatViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.Manage.route) {
|
||||
if (isDemoMode) {
|
||||
// Demo is offline — Manage talks to the live dashboard,
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -203,6 +204,8 @@ fun SessionDrawerContent(
|
||||
autoTitlesSupported: Boolean = true,
|
||||
archiveSupported: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
onNewChat: () -> Unit,
|
||||
onNewDefaultChat: (() -> Unit)? = null,
|
||||
onSelectSession: (String) -> Unit,
|
||||
@@ -526,6 +529,28 @@ fun SessionDrawerContent(
|
||||
Text(stringResource(R.string.drawer_new_chat))
|
||||
}
|
||||
|
||||
onOpenBotMode?.let { openBotMode ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = openBotMode,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Groups, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(stringResource(R.string.bot_mode_title))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_drawer_summary),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchExpanded || query.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BotChatScreen(
|
||||
route: BotGatewayRoute,
|
||||
bot: BotRosterEntry,
|
||||
sessionId: String,
|
||||
gatewayClient: GatewayChatClient,
|
||||
dashboardClient: DashboardApiClient,
|
||||
chatViewModel: ChatViewModel,
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val handler = remember(route.key) { ChatHandler() }
|
||||
val context = LocalContext.current
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
val isLoading by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
val iconPath by connectionViewModel
|
||||
.profileIconFlow(route.connectionId, route.profileName)
|
||||
.collectAsState(initial = null)
|
||||
val listState = rememberLazyListState()
|
||||
var composer by remember(route.key, sessionId) { mutableStateOf("") }
|
||||
|
||||
DisposableEffect(chatViewModel, gatewayClient, dashboardClient, route.key) {
|
||||
chatViewModel.initialize(apiClient = null, chatHandler = handler)
|
||||
chatViewModel.initializeGatewayOnly(context)
|
||||
chatViewModel.streamingEndpoint = "gateway"
|
||||
chatViewModel.sseFallbackEndpoint = "sessions"
|
||||
chatViewModel.setSelectedProfileProvider { bot.profile }
|
||||
chatViewModel.setSessionProfileNameProvider { route.profileName }
|
||||
chatViewModel.setEffectiveProfileProvider { bot.profile }
|
||||
chatViewModel.setDisplayProfileProvider { bot.profile }
|
||||
chatViewModel.setDisplayAliasProvider { bot.displayName }
|
||||
chatViewModel.setIsolatedProfileApiProvider { false }
|
||||
chatViewModel.setProfileSelectionHandler { selected ->
|
||||
selected?.name == route.profileName
|
||||
}
|
||||
chatViewModel.setProfileMessageLoaderWithMode { _, storedSessionId, mode ->
|
||||
dashboardClient.getSessionMessages(
|
||||
sessionId = storedSessionId,
|
||||
profile = route.profileName,
|
||||
mode = mode,
|
||||
)
|
||||
}
|
||||
chatViewModel.updateApiClient(null)
|
||||
chatViewModel.updateGatewayClient(gatewayClient)
|
||||
chatViewModel.setCanonicalBotChatMode(true)
|
||||
chatViewModel.setChatVisible(true)
|
||||
chatViewModel.openProfileSession(
|
||||
profileName = route.profileName,
|
||||
profile = bot.profile,
|
||||
contextKey = AgentDisplay.profileContextKey(route.connectionId, route.profileName),
|
||||
sessionId = sessionId,
|
||||
)
|
||||
onDispose {
|
||||
chatViewModel.setChatVisible(false)
|
||||
chatViewModel.setCanonicalBotChatMode(false)
|
||||
chatViewModel.updateGatewayClient(null)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = RelayRefresh.Background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.bot_mode_back_to_bots),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
color = RelayRefresh.Navy3,
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
if (!iconPath.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(iconPath.orEmpty()),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
bot.displayName.firstOrNull()?.uppercase() ?: "H",
|
||||
color = RelayRefresh.Relay,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(
|
||||
bot.displayName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
"${route.connectionLabel} · @${bot.handle}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (bot.stale) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = RelayRefresh.Background,
|
||||
),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
modifier = Modifier.imePadding(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = composer,
|
||||
onValueChange = { composer = it },
|
||||
placeholder = { Text(stringResource(R.string.chat_placeholder_message)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
minLines = 1,
|
||||
maxLines = 6,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isStreaming) {
|
||||
chatViewModel.cancelStream()
|
||||
} else {
|
||||
val text = composer.trim()
|
||||
if (text.isNotEmpty()) {
|
||||
composer = ""
|
||||
chatViewModel.sendMessage(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = isStreaming || composer.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
if (isStreaming) Icons.Filled.Stop else Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(
|
||||
if (isStreaming) R.string.chat_input_stop_streaming
|
||||
else R.string.chat_input_send_message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
when {
|
||||
isLoading && messages.isEmpty() -> CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(28.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
messages.isEmpty() -> Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_no_messages),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
bot.profile.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 14.dp,
|
||||
vertical = 10.dp,
|
||||
),
|
||||
) {
|
||||
itemsIndexed(messages, key = { _, message -> message.uiKey }) { index, message ->
|
||||
val first = index == 0 || messages[index - 1].role != message.role
|
||||
val last = index == messages.lastIndex || messages[index + 1].role != message.role
|
||||
MessageBubble(
|
||||
message = message,
|
||||
modifier = Modifier.padding(top = if (first) 6.dp else 1.dp),
|
||||
maxBubbleWidth = 344.dp,
|
||||
isFirstInGroup = first,
|
||||
isLastInGroup = last,
|
||||
onAttachmentRetry = chatViewModel::manualFetchAttachment,
|
||||
onAttachmentManualFetch = chatViewModel::manualFetchAttachment,
|
||||
onCardAction = chatViewModel::dispatchCardAction,
|
||||
onCardInput = chatViewModel::answerAsk,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
error?.takeIf(String::isNotBlank)?.let { message ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
message,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal enum class BotModeFilter { All, Bots, Groups }
|
||||
|
||||
private sealed interface BotModeRow {
|
||||
val activityAtMs: Long
|
||||
|
||||
data class Bot(val value: BotRosterEntry) : BotModeRow {
|
||||
override val activityAtMs: Long = value.latestActivityAtMs
|
||||
}
|
||||
|
||||
data class Group(val value: BotGroupRoom) : BotModeRow {
|
||||
override val activityAtMs: Long = value.latestActivityAtMs
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BotModeScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenBotChat: (route: BotGatewayRoute, sessionId: String) -> Unit,
|
||||
onOpenGroup: (roomKey: String) -> Unit,
|
||||
) {
|
||||
val state by connectionViewModel.botModeState.collectAsState()
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val resources = LocalResources.current
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var openingProfile by remember { mutableStateOf<String?>(null) }
|
||||
var showCreateBot by remember { mutableStateOf(false) }
|
||||
var creatingBot by remember { mutableStateOf(false) }
|
||||
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val chatOpenFailed = stringResource(R.string.bot_mode_chat_open_failed)
|
||||
val botCreateFailed = stringResource(R.string.bot_mode_create_failed)
|
||||
|
||||
LaunchedEffect(activeConnection?.id) {
|
||||
while (true) {
|
||||
connectionViewModel.refreshBotMode()
|
||||
delay(BOT_MODE_REFRESH_MS)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(connections, selectedGatewayId) {
|
||||
if (selectedGatewayId != null && connections.none { it.id == selectedGatewayId }) {
|
||||
selectedGatewayId = null
|
||||
}
|
||||
}
|
||||
|
||||
BotModeContent(
|
||||
state = state,
|
||||
connections = connections,
|
||||
activeConnection = activeConnection,
|
||||
selectedGatewayId = selectedGatewayId,
|
||||
onBack = onBack,
|
||||
onRefresh = connectionViewModel::refreshBotMode,
|
||||
onSelectGateway = { selectedGatewayId = it },
|
||||
openingProfile = openingProfile,
|
||||
onOpenBot = { bot ->
|
||||
val route = bot.route ?: return@BotModeContent
|
||||
openingProfile = bot.profile.name
|
||||
scope.launch {
|
||||
val result = connectionViewModel.ensureCanonicalBotChat(route)
|
||||
.map { it.resolvedSessionId }
|
||||
result.fold(
|
||||
onSuccess = { onOpenBotChat(route, it) },
|
||||
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
|
||||
)
|
||||
openingProfile = null
|
||||
}
|
||||
},
|
||||
onOpenGroup = { onOpenGroup(it.key) },
|
||||
onNewBot = { showCreateBot = true },
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
botAvatar = { bot, size ->
|
||||
BotProfileAvatar(
|
||||
connectionViewModel = connectionViewModel,
|
||||
bot = bot,
|
||||
size = size,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (showCreateBot) {
|
||||
CreateBotDialog(
|
||||
saving = creatingBot,
|
||||
onDismiss = { showCreateBot = false },
|
||||
onCreate = { name, title, description ->
|
||||
scope.launch {
|
||||
creatingBot = true
|
||||
val targetConnectionId = selectedGatewayId ?: activeConnection?.id
|
||||
val createResult = if (targetConnectionId == null) {
|
||||
Result.failure(IllegalStateException(botCreateFailed))
|
||||
} else {
|
||||
connectionViewModel.createBot(
|
||||
targetConnectionId,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
)
|
||||
}
|
||||
createResult.fold(
|
||||
onSuccess = {
|
||||
showCreateBot = false
|
||||
snackbar.showSnackbar(
|
||||
resources.getString(R.string.bot_mode_created, title),
|
||||
)
|
||||
},
|
||||
onFailure = { snackbar.showSnackbar(it.message ?: botCreateFailed) },
|
||||
)
|
||||
creatingBot = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun BotModeContent(
|
||||
state: BotModeState,
|
||||
connections: List<Connection>,
|
||||
activeConnection: Connection?,
|
||||
selectedGatewayId: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSelectGateway: (String?) -> Unit,
|
||||
openingProfile: String? = null,
|
||||
onOpenBot: (BotRosterEntry) -> Unit,
|
||||
onOpenGroup: (BotGroupRoom) -> Unit,
|
||||
onNewBot: () -> Unit,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
botAvatar: @Composable (BotRosterEntry, Dp) -> Unit = { bot, size ->
|
||||
BotFallbackAvatar(bot.displayName, size)
|
||||
},
|
||||
) {
|
||||
var filter by remember { mutableStateOf(BotModeFilter.All) }
|
||||
var searchOpen by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var gatewayMenuOpen by remember { mutableStateOf(false) }
|
||||
val visibleBots = state.roster.bots
|
||||
.filterNot(BotRosterEntry::hidden)
|
||||
.filter { selectedGatewayId == null || it.route?.connectionId == selectedGatewayId }
|
||||
val visibleGroups = state.roster.groups.filter { group ->
|
||||
selectedGatewayId == null || selectedGatewayId in group.sourceConnectionIds
|
||||
}
|
||||
val activeBots = visibleBots.filter { bot ->
|
||||
!bot.stale && bot.presenceActivityAtMs >= nowMs - ACTIVE_WINDOW_MS
|
||||
}.sortedByDescending(BotRosterEntry::presenceActivityAtMs).take(6)
|
||||
val needle = query.trim()
|
||||
val rows = buildList<BotModeRow> {
|
||||
if (filter != BotModeFilter.Groups) {
|
||||
addAll(visibleBots.filter { bot ->
|
||||
needle.isBlank() || listOf(
|
||||
bot.displayName,
|
||||
bot.profile.name,
|
||||
bot.profile.description,
|
||||
bot.latestPreview,
|
||||
).any { it.contains(needle, ignoreCase = true) }
|
||||
}.map(BotModeRow::Bot))
|
||||
}
|
||||
if (filter != BotModeFilter.Bots) {
|
||||
addAll(visibleGroups.filter { room ->
|
||||
needle.isBlank() || room.name.contains(needle, ignoreCase = true) ||
|
||||
room.latestMessage?.text.orEmpty().contains(needle, ignoreCase = true)
|
||||
}.map(BotModeRow::Group))
|
||||
}
|
||||
}.sortedByDescending(BotModeRow::activityAtMs)
|
||||
|
||||
Scaffold(
|
||||
containerColor = RelayRefresh.Background,
|
||||
snackbarHost = snackbarHost,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { searchOpen = !searchOpen }) {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.bot_mode_search),
|
||||
tint = if (searchOpen || query.isNotBlank()) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background),
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = onNewBot,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.bot_mode_new_bot))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
if (searchOpen || query.isNotBlank()) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.bot_mode_search_hint)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) {
|
||||
Surface(
|
||||
onClick = { gatewayMenuOpen = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
activeConnection?.label?.trim()?.firstOrNull()?.uppercase() ?: "H",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
selectedGatewayId
|
||||
?.let { id -> connections.firstOrNull { it.id == id }?.label }
|
||||
?: stringResource(R.string.bot_mode_all_gateways),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(Icons.Filled.ExpandMore, contentDescription = null)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = gatewayMenuOpen,
|
||||
onDismissRequest = { gatewayMenuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.bot_mode_all_gateways)) },
|
||||
leadingIcon = if (selectedGatewayId == null) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
} else null,
|
||||
onClick = {
|
||||
gatewayMenuOpen = false
|
||||
onSelectGateway(null)
|
||||
},
|
||||
)
|
||||
connections.forEach { connection ->
|
||||
val selected = connection.id == selectedGatewayId
|
||||
val gatewayStatus = state.gateways.firstOrNull { it.connectionId == connection.id }
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(connection.label)
|
||||
if (gatewayStatus?.stale == true || gatewayStatus?.error != null) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_offline),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingIcon = if (selected) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
} else null,
|
||||
onClick = {
|
||||
gatewayMenuOpen = false
|
||||
if (!selected) onSelectGateway(connection.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BotModeFilterBar(filter = filter, onFilter = { filter = it })
|
||||
|
||||
if (activeBots.isNotEmpty() && filter != BotModeFilter.Groups && needle.isBlank()) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_active_now),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 8.dp),
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
items(activeBots, key = { it.profile.name }) { bot ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(78.dp)
|
||||
.clickable(enabled = openingProfile == null) { onOpenBot(bot) },
|
||||
) {
|
||||
Box {
|
||||
botAvatar(bot, 56.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(14.dp),
|
||||
shape = CircleShape,
|
||||
color = ACTIVE_GREEN,
|
||||
border = BorderStroke(2.dp, RelayRefresh.Background),
|
||||
) {}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
bot.displayName,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
bot.route?.connectionLabel.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading && rows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
rows.isEmpty() -> BotModeEmptyState(error = state.error, onRefresh = onRefresh)
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 96.dp, top = 4.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = rows,
|
||||
key = { _, row -> when (row) {
|
||||
is BotModeRow.Bot -> "bot:${row.value.profile.name}"
|
||||
is BotModeRow.Group -> "group:${row.value.key}"
|
||||
} },
|
||||
) { index, row ->
|
||||
when (row) {
|
||||
is BotModeRow.Bot -> BotConversationRow(
|
||||
bot = row.value,
|
||||
connectionLabel = row.value.route?.connectionLabel,
|
||||
opening = openingProfile == row.value.profile.name,
|
||||
onClick = { onOpenBot(row.value) },
|
||||
avatar = { botAvatar(row.value, 56.dp) },
|
||||
nowMs = nowMs,
|
||||
)
|
||||
is BotModeRow.Group -> BotGroupRow(
|
||||
room = row.value,
|
||||
onClick = { onOpenGroup(row.value) },
|
||||
nowMs = nowMs,
|
||||
)
|
||||
}
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 88.dp, end = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotModeFilterBar(filter: BotModeFilter, onFilter: (BotModeFilter) -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)),
|
||||
) {
|
||||
Row(modifier = Modifier.padding(4.dp)) {
|
||||
BotModeFilter.entries.forEach { item ->
|
||||
val selected = item == filter
|
||||
Surface(
|
||||
onClick = { onFilter(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
color = if (selected) {
|
||||
RelayRefresh.ElectricMuted.copy(alpha = 0.56f)
|
||||
} else Color.Transparent,
|
||||
) {
|
||||
Text(
|
||||
text = when (item) {
|
||||
BotModeFilter.All -> stringResource(R.string.bot_mode_filter_all)
|
||||
BotModeFilter.Bots -> stringResource(R.string.bot_mode_filter_bots)
|
||||
BotModeFilter.Groups -> stringResource(R.string.bot_mode_filter_groups)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
color = if (selected) {
|
||||
RelayRefresh.Ink
|
||||
} else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotConversationRow(
|
||||
bot: BotRosterEntry,
|
||||
connectionLabel: String?,
|
||||
opening: Boolean,
|
||||
onClick: () -> Unit,
|
||||
avatar: @Composable () -> Unit,
|
||||
nowMs: Long,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = !opening, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
avatar()
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
bot.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
bot.latestActivityAtMs.toBotModeTime(nowMs),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (!connectionLabel.isNullOrBlank()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"$connectionLabel · @${bot.handle}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (bot.stale) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_offline),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
when {
|
||||
opening -> stringResource(R.string.bot_mode_opening_chat)
|
||||
bot.latestPreview.isNotBlank() -> bot.latestPreview
|
||||
bot.profile.description.isNotBlank() -> bot.profile.description
|
||||
else -> stringResource(R.string.bot_mode_no_messages)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotGroupRow(room: BotGroupRoom, onClick: () -> Unit, nowMs: Long) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GroupAvatar(56.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
room.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
room.latestActivityAtMs.toBotModeTime(nowMs),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_read_only),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (room.stale) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_offline),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
val latest = room.latestMessage
|
||||
Text(
|
||||
if (latest == null) {
|
||||
stringResource(R.string.bot_mode_group_no_messages)
|
||||
} else {
|
||||
"${latest.senderName}: ${latest.text}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotModeEmptyState(
|
||||
error: String?,
|
||||
onRefresh: () -> Unit,
|
||||
actionLabel: String? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
error ?: stringResource(R.string.bot_mode_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onRefresh) {
|
||||
Text(actionLabel ?: stringResource(R.string.chat_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotProfileAvatar(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
bot: BotRosterEntry,
|
||||
size: Dp,
|
||||
) {
|
||||
val pathFlow = bot.route?.let { route ->
|
||||
connectionViewModel.profileIconFlow(route.connectionId, route.profileName)
|
||||
} ?: connectionViewModel.profileIconFlow(bot.profile.name)
|
||||
val path by pathFlow.collectAsState(initial = null)
|
||||
if (path.isNullOrBlank()) {
|
||||
BotFallbackAvatar(bot.displayName, size)
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier.size(size),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = File(path.orEmpty()),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BotFallbackAvatar(label: String, size: Dp) {
|
||||
Surface(
|
||||
modifier = Modifier.size(size),
|
||||
shape = CircleShape,
|
||||
color = RelayRefresh.Navy3,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
label.trim().firstOrNull()?.uppercase() ?: "H",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = RelayRefresh.Relay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupAvatar(size: Dp) {
|
||||
Surface(
|
||||
modifier = Modifier.size(size),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(size * 0.52f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BotGroupDetailScreen(room: BotGroupRoom?, onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
containerColor = RelayRefresh.Background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(room?.name ?: stringResource(R.string.bot_mode_group_title))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_read_only),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BotModeEmptyState(
|
||||
error = stringResource(R.string.bot_mode_group_missing),
|
||||
onRefresh = onBack,
|
||||
actionLabel = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_group_read_only_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
items(room.messages, key = { it.id ?: "${it.atMs}:${it.senderName}:${it.text.hashCode()}" }) { message ->
|
||||
BotGroupMessageBubble(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotGroupMessageBubble(message: BotGroupMessage) {
|
||||
val user = message.senderKind == "user"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (user) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (user) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.86f),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
message.senderName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (user) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(message.text, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
message.atMs.toBotModeTime(System.currentTimeMillis()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateBotDialog(
|
||||
saving: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (name: String, title: String, description: String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(stringResource(R.string.bot_mode_new_bot)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it.lowercase().replace(' ', '-').take(64) },
|
||||
label = { Text(stringResource(R.string.bot_mode_bot_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it.take(128) },
|
||||
label = { Text(stringResource(R.string.bot_mode_bot_title)) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it.take(512) },
|
||||
label = { Text(stringResource(R.string.bot_mode_bot_description)) },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_create_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCreate(name, title.ifBlank { name }, description) },
|
||||
enabled = name.isNotBlank() && !saving,
|
||||
) { Text(stringResource(R.string.bot_mode_create)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !saving) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun Long.toBotModeTime(nowMs: Long): String {
|
||||
if (this <= 0L) return ""
|
||||
return DateUtils.getRelativeTimeSpanString(
|
||||
this,
|
||||
nowMs,
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
).toString()
|
||||
}
|
||||
|
||||
private const val ACTIVE_WINDOW_MS = 90_000L
|
||||
private const val BOT_MODE_REFRESH_MS = 30_000L
|
||||
private val ACTIVE_GREEN = Color(0xFF4DD675)
|
||||
@@ -726,6 +726,7 @@ fun ChatScreen(
|
||||
// existing test/preview call sites keep compiling.
|
||||
onNavigateToVoiceSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
onNavigateToBotMode: () -> Unit = {},
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
|
||||
@@ -2344,6 +2345,10 @@ fun ChatScreen(
|
||||
autoTitlesSupported = serverAutoTitles,
|
||||
archiveSupported = sessionArchivingSupported,
|
||||
onRefresh = { chatViewModel.refreshSessions() },
|
||||
onOpenBotMode = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToBotMode()
|
||||
},
|
||||
onNewChat = {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
|
||||
@@ -2575,6 +2575,12 @@ class ChatViewModel : ViewModel() {
|
||||
/** Server slash-command catalog (`commands.catalog`) — 4th allCommands source. */
|
||||
private val _serverCommands = MutableStateFlow<List<SlashCommand>>(emptyList())
|
||||
val serverCommands: StateFlow<List<SlashCommand>> = _serverCommands.asStateFlow()
|
||||
@Volatile
|
||||
private var canonicalBotChatMode = false
|
||||
|
||||
fun setCanonicalBotChatMode(enabled: Boolean) {
|
||||
canonicalBotChatMode = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the in-flight turn is actually running on the gateway
|
||||
@@ -3530,6 +3536,15 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */
|
||||
fun initializeGatewayOnly(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
if (chatTurnCheckpointStore == null) {
|
||||
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
|
||||
}
|
||||
ensureCheckpointObservers()
|
||||
}
|
||||
|
||||
/** JVM-test seam; production is wired to the app-wide relay DataStore. */
|
||||
internal fun setChatTurnCheckpointStore(store: ChatTurnCheckpointStore?) {
|
||||
chatTurnCheckpointStore = store
|
||||
@@ -5303,6 +5318,14 @@ class ChatViewModel : ViewModel() {
|
||||
return true
|
||||
}
|
||||
|
||||
if (shouldCompactCanonicalBotChat(normalizedName, canonicalBotChatMode)) {
|
||||
handler.addSystemNotice("Bot Chat stays in one conversation — compacting its context instead.")
|
||||
viewModelScope.launch {
|
||||
runServerCompressCommand(gateway, handler, focusTopic = null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
mobileBlockedSlashNotice(normalizedName)?.let { notice ->
|
||||
handler.addSystemNotice(notice)
|
||||
return true
|
||||
@@ -9635,6 +9658,9 @@ private fun isUnsupportedMobileCommand(name: String, pair: JsonArray): Boolean {
|
||||
return cliOnly && gatewayGate.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun shouldCompactCanonicalBotChat(commandName: String, canonicalBotChatMode: Boolean): Boolean =
|
||||
canonicalBotChatMode && commandName.lowercase() in setOf("new", "reset")
|
||||
|
||||
private fun normalizeSlashCommandName(rawName: String): String? {
|
||||
val normalized = rawName
|
||||
.trim()
|
||||
|
||||
@@ -56,6 +56,8 @@ import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.computeConnectionSecurity
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.BotChatTarget
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProfilePresentation
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
@@ -119,6 +121,7 @@ import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.MediaCacheWriter
|
||||
import com.hermesandroid.relay.viewmodel.connection.PairingController
|
||||
import com.hermesandroid.relay.viewmodel.connection.BotModeController
|
||||
import com.hermesandroid.relay.viewmodel.connection.ProfileController
|
||||
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -800,6 +803,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
tokenStoreKeyProvider = { cid ->
|
||||
connectionStore.connections.value.firstOrNull { it.id == cid }?.tokenStoreKey
|
||||
},
|
||||
trustedDashboardUrlProvider = { cid ->
|
||||
if (connectionStore.activeConnectionId.value == cid) {
|
||||
activeDashboardUrl()
|
||||
} else {
|
||||
connectionStore.connections.value.firstOrNull { it.id == cid }
|
||||
?.resolvedDashboardUrl
|
||||
?.takeIf(String::isNotBlank)
|
||||
}
|
||||
},
|
||||
pinnedClientProvider = { url, base ->
|
||||
pluginProxyClientForUrl(url, base, includeRelaySessionHeader = false)
|
||||
},
|
||||
@@ -827,6 +839,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
gatewayClientProvider = { upstreamTransport.activeGatewayChatClient() },
|
||||
)
|
||||
|
||||
private val botModeController = BotModeController(
|
||||
scope = viewModelScope,
|
||||
connections = connectionStore.connections,
|
||||
activeConnectionId = connectionStore.activeConnectionId,
|
||||
dashboardUrlProvider = { connection ->
|
||||
if (connectionStore.activeConnectionId.value == connection.id) {
|
||||
activeDashboardUrl().orEmpty()
|
||||
} else {
|
||||
connection.resolvedDashboardUrl
|
||||
}
|
||||
},
|
||||
dashboardClientFactory = upstreamTransport::dashboardClientFor,
|
||||
gatewayLeaseFactory = upstreamTransport::acquireGatewayRoute,
|
||||
)
|
||||
|
||||
// --- Relay connection state ---
|
||||
val relayConnectionState: StateFlow<ConnectionState> = connectionManager.connectionState
|
||||
|
||||
@@ -1624,6 +1651,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// down, calling profileController.* in their original order.
|
||||
|
||||
val agentProfiles: StateFlow<List<Profile>> get() = profileController.agentProfiles
|
||||
val botModeState: StateFlow<BotModeState> get() = botModeController.state
|
||||
|
||||
fun refreshBotMode() = botModeController.refresh()
|
||||
|
||||
suspend fun ensureCanonicalBotChat(route: com.hermesandroid.relay.data.BotGatewayRoute): Result<BotChatTarget> =
|
||||
botModeController.ensureCanonicalBotChat(route)
|
||||
|
||||
suspend fun createBot(
|
||||
connectionId: String,
|
||||
name: String,
|
||||
title: String,
|
||||
description: String,
|
||||
): Result<String> = botModeController.createBot(connectionId, name, title, description)
|
||||
|
||||
fun acquireBotGateway(
|
||||
route: com.hermesandroid.relay.data.BotGatewayRoute,
|
||||
): Result<com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController.RouteGatewayLease> =
|
||||
botModeController.acquireGateway(route)
|
||||
|
||||
fun botDashboardClient(
|
||||
route: com.hermesandroid.relay.data.BotGatewayRoute,
|
||||
): Result<DashboardApiClient> = botModeController.dashboardClient(route)
|
||||
|
||||
/**
|
||||
* Session namespace after resolving the Server-default UI sentinel through
|
||||
@@ -1765,6 +1814,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
/** A local icon path for a specific profile identity on the active connection. */
|
||||
fun profileIconFlow(profileName: String?) = profileController.profileIconFlow(profileName)
|
||||
fun profileIconFlow(connectionId: String, profileName: String) =
|
||||
profileController.profileIconFlow(connectionId, profileName)
|
||||
|
||||
val hostProfileIconImportState: StateFlow<ProfileController.HostIconImportState>
|
||||
get() = profileController.hostIconImportState
|
||||
@@ -3529,7 +3580,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
scrubConnectionArtifacts(removed, removedDeviceId)
|
||||
upstreamTransport.disposeConnectionRouteClients(connectionId)
|
||||
connectionStore.removeConnection(connectionId)
|
||||
botModeController.connectionRemoved(connectionId)
|
||||
// Clear the persisted profile selection for the removed connection
|
||||
// AFTER the switch-away above has finished. Ordering matters: if
|
||||
// we cleared first, any in-flight hydration from the just-swapped
|
||||
@@ -7296,6 +7349,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionManager.shutdown()
|
||||
_apiClient.value?.shutdown()
|
||||
profileChatApiClient?.shutdown()
|
||||
upstreamTransport.disposeAllRouteClients()
|
||||
tailscaleDetector.shutdown()
|
||||
// Release the cached VirtualDisplay + ImageReader + HandlerThread
|
||||
// built by ScreenCapture on the first /screenshot call. Without
|
||||
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import com.hermesandroid.relay.data.BotChatTarget
|
||||
import com.hermesandroid.relay.data.BotGatewayRosterStatus
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.GatewayProfileAuthChoice
|
||||
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
|
||||
import com.hermesandroid.relay.data.GatewayProfilePatch
|
||||
import com.hermesandroid.relay.data.GatewayProfileSection
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal data class BotModeGatewaySnapshot(
|
||||
val connection: Connection,
|
||||
val dashboardUrl: String,
|
||||
val installId: String?,
|
||||
val roster: BotModeRoster,
|
||||
val stale: Boolean,
|
||||
val error: String?,
|
||||
)
|
||||
|
||||
class BotModeController(
|
||||
private val scope: CoroutineScope,
|
||||
private val connections: StateFlow<List<Connection>>,
|
||||
private val activeConnectionId: StateFlow<String?>,
|
||||
private val dashboardUrlProvider: (Connection) -> String,
|
||||
private val dashboardClientFactory: (connectionId: String, dashboardUrl: String) -> DashboardApiClient,
|
||||
private val gatewayLeaseFactory: (
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
profileName: String,
|
||||
retain: Boolean,
|
||||
) -> UpstreamTransportController.RouteGatewayLease,
|
||||
) {
|
||||
private val refreshMutex = Mutex()
|
||||
private val refreshGeneration = AtomicLong(0L)
|
||||
private val snapshots = linkedMapOf<String, BotModeGatewaySnapshot>()
|
||||
private val _state = MutableStateFlow(BotModeState())
|
||||
val state: StateFlow<BotModeState> = _state.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { refreshNow() }
|
||||
}
|
||||
|
||||
suspend fun refreshNow() {
|
||||
refreshMutex.withLock {
|
||||
val generation = refreshGeneration.incrementAndGet()
|
||||
val fleet = connections.value.toList()
|
||||
val liveIds = fleet.mapTo(linkedSetOf(), Connection::id)
|
||||
snapshots.keys.retainAll(liveIds)
|
||||
_state.value = aggregateForTest(
|
||||
fleet = fleet,
|
||||
snapshots = snapshots,
|
||||
loading = fleet.isNotEmpty(),
|
||||
)
|
||||
if (fleet.isEmpty()) {
|
||||
_state.value = BotModeState(error = "Connect to Hermes to use Bot Mode")
|
||||
return
|
||||
}
|
||||
|
||||
val limiter = Semaphore(3)
|
||||
val results = coroutineScope {
|
||||
fleet.map { connection ->
|
||||
async {
|
||||
limiter.withPermit { loadConnection(connection) }
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
if (refreshGeneration.get() != generation) return
|
||||
val currentIds = connections.value.mapTo(linkedSetOf(), Connection::id)
|
||||
results.filter { it.connection.id in currentIds }.forEach { result ->
|
||||
snapshots[result.connection.id] = result
|
||||
}
|
||||
snapshots.keys.retainAll(currentIds)
|
||||
_state.value = aggregateForTest(
|
||||
fleet = connections.value,
|
||||
snapshots = snapshots,
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadConnection(connection: Connection): BotModeGatewaySnapshot {
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
val prior = snapshots[connection.id]
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return prior?.copy(
|
||||
connection = connection,
|
||||
stale = true,
|
||||
error = "Gateway is not configured",
|
||||
) ?: BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = null,
|
||||
roster = BotModeRoster(),
|
||||
stale = true,
|
||||
error = "Gateway is not configured",
|
||||
)
|
||||
}
|
||||
val statusClient = dashboardClientFactory(connection.id, dashboardUrl)
|
||||
val installId = try {
|
||||
statusClient.getStatus().getOrNull()?.installId?.trim()?.takeIf(String::isNotEmpty)
|
||||
} finally {
|
||||
statusClient.shutdown()
|
||||
}
|
||||
val rosterResult = gatewayLeaseFactory(connection.id, dashboardUrl, "default", false).use { lease ->
|
||||
lease.client.listBotModeRoster()
|
||||
}
|
||||
return rosterResult.fold(
|
||||
onSuccess = { roster ->
|
||||
BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = installId ?: prior?.installId,
|
||||
roster = roster,
|
||||
stale = false,
|
||||
error = null,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
prior?.copy(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = installId ?: prior.installId,
|
||||
stale = true,
|
||||
error = error.message ?: "Gateway unavailable",
|
||||
) ?: BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = installId,
|
||||
roster = BotModeRoster(),
|
||||
stale = true,
|
||||
error = error.message ?: "Gateway unavailable",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun ensureCanonicalBotChat(route: BotGatewayRoute): Result<BotChatTarget> {
|
||||
val connection = connectionFor(route)
|
||||
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
|
||||
}
|
||||
return gatewayLeaseFactory(connection.id, dashboardUrl, route.profileName, false).use { lease ->
|
||||
lease.client.ensureCanonicalBotChat(route.profileName)
|
||||
}.mapCatching { target ->
|
||||
check(connectionFor(route) != null) { "The Bot's gateway was removed while opening Bot Chat" }
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
fun acquireGateway(route: BotGatewayRoute): Result<UpstreamTransportController.RouteGatewayLease> {
|
||||
val connection = connectionFor(route)
|
||||
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
|
||||
}
|
||||
return Result.success(gatewayLeaseFactory(connection.id, dashboardUrl, route.profileName, true))
|
||||
}
|
||||
|
||||
fun dashboardClient(route: BotGatewayRoute): Result<DashboardApiClient> {
|
||||
val connection = connectionFor(route)
|
||||
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
|
||||
}
|
||||
return Result.success(dashboardClientFactory(connection.id, dashboardUrl))
|
||||
}
|
||||
|
||||
suspend fun createBot(
|
||||
connectionId: String,
|
||||
name: String,
|
||||
title: String,
|
||||
description: String,
|
||||
): Result<String> {
|
||||
val connection = connections.value.firstOrNull { it.id == connectionId }
|
||||
?: return Result.failure(IllegalStateException("The target gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The target gateway is not configured"))
|
||||
}
|
||||
val lease = gatewayLeaseFactory(connection.id, dashboardUrl, "default", false)
|
||||
val client = lease.client
|
||||
val cleanTitle = title.trim().ifBlank { name.trim() }.take(128)
|
||||
val result = try {
|
||||
client.createProfile(
|
||||
GatewayProfileCreateRequest(
|
||||
name = name.trim(),
|
||||
description = description.trim().takeIf(String::isNotBlank),
|
||||
cloneFrom = "default",
|
||||
authChoice = GatewayProfileAuthChoice.Shared,
|
||||
),
|
||||
).mapCatching { created ->
|
||||
check(connections.value.any { it.id == connectionId }) {
|
||||
"The target gateway was removed while creating the Bot"
|
||||
}
|
||||
val configured = client.configureProfile(
|
||||
created.name,
|
||||
GatewayProfilePatch(
|
||||
uiMeta = buildJsonObject {
|
||||
put("hermes-bots", buildJsonObject {
|
||||
put("title", cleanTitle)
|
||||
put("created", System.currentTimeMillis())
|
||||
})
|
||||
},
|
||||
),
|
||||
).getOrThrow()
|
||||
check(GatewayProfileSection.UiMeta in configured.applied) {
|
||||
"The profile was created, but Bot Mode metadata was not saved"
|
||||
}
|
||||
created.name
|
||||
}
|
||||
} finally {
|
||||
lease.close()
|
||||
}
|
||||
if (result.isSuccess) refreshNow()
|
||||
return result
|
||||
}
|
||||
|
||||
fun connectionRemoved(connectionId: String) {
|
||||
snapshots.remove(connectionId)
|
||||
refreshGeneration.incrementAndGet()
|
||||
_state.value = aggregateForTest(connections.value, snapshots, loading = false)
|
||||
}
|
||||
|
||||
private fun connectionFor(route: BotGatewayRoute): Connection? =
|
||||
connections.value.firstOrNull { it.id == route.connectionId }
|
||||
|
||||
internal fun aggregateForTest(
|
||||
fleet: List<Connection>,
|
||||
snapshots: Map<String, BotModeGatewaySnapshot>,
|
||||
loading: Boolean,
|
||||
): BotModeState {
|
||||
val order = fleet.mapIndexed { index, connection -> connection.id to index }.toMap()
|
||||
val activeId = activeConnectionId.value
|
||||
val routed = snapshots.values.flatMap { snapshot ->
|
||||
snapshot.roster.bots.map { bot ->
|
||||
bot.copy(
|
||||
route = BotGatewayRoute(
|
||||
key = BotGatewayRouteKey(
|
||||
connectionId = snapshot.connection.id,
|
||||
profileName = bot.profile.name,
|
||||
),
|
||||
connectionLabel = snapshot.connection.label,
|
||||
installId = snapshot.installId,
|
||||
),
|
||||
stale = snapshot.stale,
|
||||
)
|
||||
}
|
||||
}
|
||||
val collapsed = routed
|
||||
.groupBy { bot ->
|
||||
val route = checkNotNull(bot.route)
|
||||
"${route.installId ?: "connection:${route.connectionId}"}::${bot.profile.name}"
|
||||
}
|
||||
.values
|
||||
.map { candidates ->
|
||||
candidates.sortedWith(
|
||||
compareByDescending<BotRosterEntry> { it.route?.connectionId == activeId }
|
||||
.thenBy { it.stale }
|
||||
.thenBy { order[it.route?.connectionId] ?: Int.MAX_VALUE },
|
||||
).first()
|
||||
}
|
||||
val duplicateNames = collapsed.groupingBy { it.profile.name }.eachCount()
|
||||
val bots = collapsed.map { bot ->
|
||||
val route = checkNotNull(bot.route)
|
||||
bot.copy(
|
||||
handle = if ((duplicateNames[bot.profile.name] ?: 0) > 1) {
|
||||
"${handleSlug(bot.profile.name)}-${handleSlug(route.connectionLabel)}"
|
||||
} else {
|
||||
handleSlug(bot.profile.name)
|
||||
},
|
||||
)
|
||||
}.sortedByDescending(BotRosterEntry::latestActivityAtMs)
|
||||
|
||||
val groups = snapshots.values
|
||||
.flatMap { snapshot ->
|
||||
snapshot.roster.groups.map { group -> Triple(snapshot, group, group.roomId ?: group.key) }
|
||||
}
|
||||
.groupBy { it.third }
|
||||
.values
|
||||
.map { candidates ->
|
||||
val selected = candidates.maxWithOrNull(
|
||||
compareBy<Triple<BotModeGatewaySnapshot, BotGroupRoom, String>> { it.second.revision }
|
||||
.thenBy { it.second.latestActivityAtMs },
|
||||
) ?: error("group candidate list cannot be empty")
|
||||
selected.second.copy(
|
||||
sourceConnectionIds = candidates.mapTo(linkedSetOf()) { it.first.connection.id },
|
||||
stale = candidates.all { it.first.stale },
|
||||
)
|
||||
}
|
||||
.sortedByDescending(BotGroupRoom::latestActivityAtMs)
|
||||
|
||||
val statuses = fleet.map { connection ->
|
||||
val snapshot = snapshots[connection.id]
|
||||
BotGatewayRosterStatus(
|
||||
connectionId = connection.id,
|
||||
label = connection.label,
|
||||
installId = snapshot?.installId,
|
||||
loading = loading && snapshot == null,
|
||||
stale = snapshot?.stale == true,
|
||||
error = snapshot?.error,
|
||||
botCount = snapshot?.roster?.bots?.size ?: 0,
|
||||
)
|
||||
}
|
||||
val errors = statuses.mapNotNull(BotGatewayRosterStatus::error)
|
||||
return BotModeState(
|
||||
loading = loading,
|
||||
roster = BotModeRoster(
|
||||
bots = bots,
|
||||
groups = groups,
|
||||
botModeProtocolSupported = snapshots.values.any {
|
||||
it.roster.botModeProtocolSupported
|
||||
},
|
||||
),
|
||||
gateways = statuses,
|
||||
error = errors.takeIf { it.size == statuses.size && bots.isEmpty() }
|
||||
?.firstOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSlug(value: String): String = value
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace(Regex("[^a-z0-9]+"), "-")
|
||||
.trim('-')
|
||||
.take(64)
|
||||
.ifBlank { "bot" }
|
||||
}
|
||||
@@ -388,6 +388,13 @@ class ProfileController(
|
||||
) { server, fallback, override -> preferredProfileIcon(server, fallback, override) }
|
||||
}
|
||||
|
||||
/** Exact profile identity on any saved connection; never consults active state. */
|
||||
fun profileIconFlow(connectionId: String, profileName: String): Flow<String?> = combine(
|
||||
profileIconStore.serverAvatarFlow(connectionId, profileName),
|
||||
profileIconStore.iconFlow(connectionId, profileName),
|
||||
profileIconStore.localOverrideFlow(connectionId, profileName),
|
||||
) { server, local, localOverride -> preferredProfileIcon(server, local, localOverride) }
|
||||
|
||||
data class HostIconImportState(
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
|
||||
+123
-2
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
@@ -18,6 +19,7 @@ import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreferen
|
||||
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -78,6 +80,8 @@ class UpstreamTransportController(
|
||||
* `hermes_dashboard_<id>` file (original behavior).
|
||||
*/
|
||||
private val tokenStoreKeyProvider: (String) -> String? = { null },
|
||||
/** Exact trusted Dashboard base for any saved connection, active or not. */
|
||||
private val trustedDashboardUrlProvider: (String) -> String? = { null },
|
||||
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
|
||||
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
|
||||
{ _, _ -> null },
|
||||
@@ -96,6 +100,25 @@ class UpstreamTransportController(
|
||||
ConcurrentHashMap<String, EncryptedNativeDashboardTokenStore>()
|
||||
private var dashboardHttpClientCache:
|
||||
Triple<String, String, okhttp3.OkHttpClient>? = null
|
||||
private data class RouteGatewayEntry(
|
||||
var dashboardUrl: String,
|
||||
var dashboardClient: DashboardApiClient,
|
||||
val client: GatewayChatClient,
|
||||
var activeRequests: Int = 0,
|
||||
var retained: Int = 0,
|
||||
var retired: Boolean = false,
|
||||
)
|
||||
private val routeGatewayClients = mutableMapOf<BotGatewayRouteKey, RouteGatewayEntry>()
|
||||
|
||||
class RouteGatewayLease internal constructor(
|
||||
val client: GatewayChatClient,
|
||||
private val releaseAction: () -> Unit,
|
||||
) : AutoCloseable {
|
||||
private val closed = AtomicBoolean(false)
|
||||
override fun close() {
|
||||
if (closed.compareAndSet(false, true)) releaseAction()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie store for [connectionId] — ONE instance per connection,
|
||||
@@ -137,9 +160,10 @@ class UpstreamTransportController(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
): DashboardBearerAuth? {
|
||||
if (activeConnectionIdProvider() != connectionId) return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
val trustedDashboardUrl = trustedDashboardUrlProvider(connectionId)
|
||||
?: (if (activeConnectionIdProvider() == connectionId) dashboardUrlProvider() else null)
|
||||
?: return null
|
||||
return trustedDashboardBearerAuthOrNull(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
@@ -252,6 +276,7 @@ class UpstreamTransportController(
|
||||
if (gatewayClientCache?.first == connectionId) {
|
||||
gatewayClientCache = null
|
||||
}
|
||||
disposeConnectionRouteClients(connectionId)
|
||||
}
|
||||
|
||||
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
|
||||
@@ -333,6 +358,99 @@ class UpstreamTransportController(
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot/agent Gateway client owned by one immutable connection + profile.
|
||||
* It never consults or changes the foreground connection and never shares
|
||||
* live-session state with the standard Chat client's dynamic profile.
|
||||
*/
|
||||
@Synchronized
|
||||
fun acquireGatewayRoute(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
profileName: String,
|
||||
retain: Boolean = false,
|
||||
): RouteGatewayLease {
|
||||
val profile = profileName.trim().ifBlank { "default" }
|
||||
val key = BotGatewayRouteKey(connectionId.trim(), profile)
|
||||
var entry = routeGatewayClients[key]
|
||||
entry?.let { cached ->
|
||||
if (cached.dashboardUrl == dashboardUrl) {
|
||||
if (retain) cached.retained += 1 else cached.activeRequests += 1
|
||||
return routeLease(key, cached, retain)
|
||||
}
|
||||
if (cached.client.hasActiveTurn()) {
|
||||
val replacementDashboard = dashboardClientFor(connectionId, dashboardUrl)
|
||||
val previousDashboard = cached.dashboardClient
|
||||
cached.client.retarget(replacementDashboard)
|
||||
cached.dashboardClient = replacementDashboard
|
||||
cached.dashboardUrl = dashboardUrl
|
||||
previousDashboard.shutdown()
|
||||
if (retain) cached.retained += 1 else cached.activeRequests += 1
|
||||
return routeLease(key, cached, retain)
|
||||
}
|
||||
cached.retired = true
|
||||
routeGatewayClients.remove(key)
|
||||
if (cached.activeRequests == 0 && cached.retained == 0) shutdownRouteEntry(cached)
|
||||
}
|
||||
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
|
||||
entry = RouteGatewayEntry(
|
||||
dashboardUrl = dashboardUrl,
|
||||
dashboardClient = dashboardClient,
|
||||
client = GatewayChatClient(
|
||||
initialDashboardClient = dashboardClient,
|
||||
fixedSessionProfile = profile,
|
||||
).also { it.setKeepAliveInBackground(gatewayKeepAliveProvider()) },
|
||||
)
|
||||
if (retain) entry.retained = 1 else entry.activeRequests = 1
|
||||
routeGatewayClients[key] = entry
|
||||
return routeLease(key, entry, retain)
|
||||
}
|
||||
|
||||
private fun routeLease(
|
||||
key: BotGatewayRouteKey,
|
||||
entry: RouteGatewayEntry,
|
||||
retained: Boolean,
|
||||
): RouteGatewayLease = RouteGatewayLease(entry.client) {
|
||||
releaseGatewayRoute(key, entry, retained)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun releaseGatewayRoute(
|
||||
key: BotGatewayRouteKey,
|
||||
entry: RouteGatewayEntry,
|
||||
retained: Boolean,
|
||||
) {
|
||||
if (retained) entry.retained = (entry.retained - 1).coerceAtLeast(0)
|
||||
else entry.activeRequests = (entry.activeRequests - 1).coerceAtLeast(0)
|
||||
if ((entry.retired || routeGatewayClients[key] !== entry) &&
|
||||
entry.activeRequests == 0 && entry.retained == 0
|
||||
) {
|
||||
shutdownRouteEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shutdownRouteEntry(entry: RouteGatewayEntry) {
|
||||
entry.client.shutdown()
|
||||
entry.dashboardClient.shutdown()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun disposeConnectionRouteClients(connectionId: String) {
|
||||
routeGatewayClients.entries
|
||||
.filter { it.key.connectionId == connectionId }
|
||||
.forEach { (key, entry) ->
|
||||
entry.retired = true
|
||||
shutdownRouteEntry(entry)
|
||||
routeGatewayClients.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun disposeAllRouteClients() {
|
||||
routeGatewayClients.values.forEach(::shutdownRouteEntry)
|
||||
routeGatewayClients.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the keep-alive-in-background flag to the cached gateway client, if
|
||||
* one exists. Driven by the ViewModel's `gatewayKeepAlive` collector.
|
||||
@@ -341,6 +459,9 @@ class UpstreamTransportController(
|
||||
*/
|
||||
fun applyGatewayKeepAlive(enabled: Boolean) {
|
||||
gatewayClientCache?.third?.setKeepAliveInBackground(enabled)
|
||||
synchronized(this) {
|
||||
routeGatewayClients.values.forEach { it.client.setKeepAliveInBackground(enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -838,6 +838,36 @@
|
||||
<string name="detail_tab_advanced">Avançado</string>
|
||||
<string name="detail_tab_security">Segurança</string>
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Modo Bot</string>
|
||||
<string name="bot_mode_drawer_summary">Bots e salas em grupo</string>
|
||||
<string name="bot_mode_search">Pesquisar no Modo Bot</string>
|
||||
<string name="bot_mode_search_hint">Pesquisar Bots e grupos</string>
|
||||
<string name="bot_mode_refresh">Atualizar Modo Bot</string>
|
||||
<string name="bot_mode_new_bot">Novo Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway indisponível</string>
|
||||
<string name="bot_mode_all_gateways">Todos os gateways</string>
|
||||
<string name="bot_mode_offline">Offline</string>
|
||||
<string name="bot_mode_filter_all">Todos</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Grupos</string>
|
||||
<string name="bot_mode_active_now">Ativos agora</string>
|
||||
<string name="bot_mode_opening_chat">Abrindo o Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Iniciar o Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Somente leitura</string>
|
||||
<string name="bot_mode_group_no_messages">Ainda não há mensagens na sala</string>
|
||||
<string name="bot_mode_empty">Ainda não há Bots nem salas em grupo</string>
|
||||
<string name="bot_mode_group_title">Sala em grupo</string>
|
||||
<string name="bot_mode_group_missing">Esta sala em grupo não está mais disponível.</string>
|
||||
<string name="bot_mode_group_read_only_help">Este é o histórico limitado e somente leitura compartilhado pelo Hermes Desktop. Por enquanto, continue com um Bot individual.</string>
|
||||
<string name="bot_mode_bot_name">Nome do perfil</string>
|
||||
<string name="bot_mode_bot_title">Nome do Bot</string>
|
||||
<string name="bot_mode_bot_description">Função e descrição</string>
|
||||
<string name="bot_mode_create_help">O novo Bot começa com o perfil padrão e compartilha o login dele. Você pode ajustar habilidades e modelo em Gerenciar.</string>
|
||||
<string name="bot_mode_create">Criar Bot</string>
|
||||
<string name="bot_mode_back_to_bots">Voltar ao Modo Bot</string>
|
||||
<string name="bot_mode_chat_open_failed">Não foi possível abrir o Bot Chat</string>
|
||||
<string name="bot_mode_created">%1$s criado</string>
|
||||
<string name="bot_mode_create_failed">Não foi possível criar o Bot</string>
|
||||
<string name="drawer_filter_by_source">Filtrar por origem</string>
|
||||
<string name="drawer_show_sources">Mostrar origens</string>
|
||||
<string name="drawer_refresh_sessions">Atualizar sessões</string>
|
||||
|
||||
@@ -884,6 +884,36 @@
|
||||
<string name="detail_tab_security">安全</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Bot 模式</string>
|
||||
<string name="bot_mode_drawer_summary">Bot 和群组房间</string>
|
||||
<string name="bot_mode_search">搜索 Bot 模式</string>
|
||||
<string name="bot_mode_search_hint">搜索 Bot 和群组</string>
|
||||
<string name="bot_mode_refresh">刷新 Bot 模式</string>
|
||||
<string name="bot_mode_new_bot">新建 Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">网关不可用</string>
|
||||
<string name="bot_mode_all_gateways">所有网关</string>
|
||||
<string name="bot_mode_offline">离线</string>
|
||||
<string name="bot_mode_filter_all">全部</string>
|
||||
<string name="bot_mode_filter_bots">Bot</string>
|
||||
<string name="bot_mode_filter_groups">群组</string>
|
||||
<string name="bot_mode_active_now">当前活跃</string>
|
||||
<string name="bot_mode_opening_chat">正在打开 Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">开始 Bot Chat</string>
|
||||
<string name="bot_mode_read_only">只读</string>
|
||||
<string name="bot_mode_group_no_messages">房间中还没有消息</string>
|
||||
<string name="bot_mode_empty">还没有 Bot 或群组房间</string>
|
||||
<string name="bot_mode_group_title">群组房间</string>
|
||||
<string name="bot_mode_group_missing">此群组房间已不可用。</string>
|
||||
<string name="bot_mode_group_read_only_help">这是 Hermes Desktop 共享的有限只读房间历史记录。目前请先与单个 Bot 继续对话。</string>
|
||||
<string name="bot_mode_bot_name">配置文件名称</string>
|
||||
<string name="bot_mode_bot_title">Bot 名称</string>
|
||||
<string name="bot_mode_bot_description">角色和说明</string>
|
||||
<string name="bot_mode_create_help">新 Bot 基于默认配置文件创建并共享其登录。你可以在“管理”中调整技能和模型。</string>
|
||||
<string name="bot_mode_create">创建 Bot</string>
|
||||
<string name="bot_mode_back_to_bots">返回 Bot 模式</string>
|
||||
<string name="bot_mode_chat_open_failed">无法打开 Bot Chat</string>
|
||||
<string name="bot_mode_created">已创建 %1$s</string>
|
||||
<string name="bot_mode_create_failed">无法创建 Bot</string>
|
||||
<string name="drawer_filter_by_source">按来源筛选</string>
|
||||
<string name="drawer_show_sources">显示来源</string>
|
||||
<string name="drawer_refresh_sessions">刷新会话</string>
|
||||
|
||||
@@ -887,6 +887,36 @@
|
||||
<string name="detail_tab_security">Sicherheit</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Bot-Modus</string>
|
||||
<string name="bot_mode_drawer_summary">Bots und Gruppenräume</string>
|
||||
<string name="bot_mode_search">Bot-Modus durchsuchen</string>
|
||||
<string name="bot_mode_search_hint">Bots und Gruppen durchsuchen</string>
|
||||
<string name="bot_mode_refresh">Bot-Modus aktualisieren</string>
|
||||
<string name="bot_mode_new_bot">Neuer Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway nicht verfügbar</string>
|
||||
<string name="bot_mode_all_gateways">Alle Gateways</string>
|
||||
<string name="bot_mode_offline">Offline</string>
|
||||
<string name="bot_mode_filter_all">Alle</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Gruppen</string>
|
||||
<string name="bot_mode_active_now">Jetzt aktiv</string>
|
||||
<string name="bot_mode_opening_chat">Bot-Chat wird geöffnet…</string>
|
||||
<string name="bot_mode_no_messages">Bot-Chat starten</string>
|
||||
<string name="bot_mode_read_only">Schreibgeschützt</string>
|
||||
<string name="bot_mode_group_no_messages">Noch keine Raumnachrichten</string>
|
||||
<string name="bot_mode_empty">Noch keine Bots oder Gruppenräume</string>
|
||||
<string name="bot_mode_group_title">Gruppenraum</string>
|
||||
<string name="bot_mode_group_missing">Dieser Gruppenraum ist nicht mehr verfügbar.</string>
|
||||
<string name="bot_mode_group_read_only_help">Dies ist der begrenzte, schreibgeschützte Raumverlauf aus Hermes Desktop. Fahre vorerst mit einem einzelnen Bot fort.</string>
|
||||
<string name="bot_mode_bot_name">Profilname</string>
|
||||
<string name="bot_mode_bot_title">Bot-Name</string>
|
||||
<string name="bot_mode_bot_description">Rolle und Beschreibung</string>
|
||||
<string name="bot_mode_create_help">Der neue Bot basiert auf dem Standardprofil und teilt dessen Anmeldung. Fähigkeiten und Modell kannst du unter Verwalten anpassen.</string>
|
||||
<string name="bot_mode_create">Bot erstellen</string>
|
||||
<string name="bot_mode_back_to_bots">Zurück zum Bot-Modus</string>
|
||||
<string name="bot_mode_chat_open_failed">Bot-Chat konnte nicht geöffnet werden</string>
|
||||
<string name="bot_mode_created">%1$s erstellt</string>
|
||||
<string name="bot_mode_create_failed">Bot konnte nicht erstellt werden</string>
|
||||
<string name="drawer_filter_by_source">Nach Quelle filtern</string>
|
||||
<string name="drawer_show_sources">Quellen anzeigen</string>
|
||||
<string name="drawer_refresh_sessions">Sitzungen aktualisieren</string>
|
||||
|
||||
@@ -802,6 +802,36 @@
|
||||
<string name="detail_tab_routes">Rutas</string>
|
||||
<string name="detail_tab_advanced">Avanzado</string>
|
||||
<string name="detail_tab_security">Seguridad</string>
|
||||
<string name="bot_mode_title">Modo Bot</string>
|
||||
<string name="bot_mode_drawer_summary">Bots y salas de grupo</string>
|
||||
<string name="bot_mode_search">Buscar en Modo Bot</string>
|
||||
<string name="bot_mode_search_hint">Buscar Bots y grupos</string>
|
||||
<string name="bot_mode_refresh">Actualizar Modo Bot</string>
|
||||
<string name="bot_mode_new_bot">Nuevo Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway no disponible</string>
|
||||
<string name="bot_mode_all_gateways">Todos los gateways</string>
|
||||
<string name="bot_mode_offline">Sin conexión</string>
|
||||
<string name="bot_mode_filter_all">Todo</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Grupos</string>
|
||||
<string name="bot_mode_active_now">Activos ahora</string>
|
||||
<string name="bot_mode_opening_chat">Abriendo Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Iniciar el Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Solo lectura</string>
|
||||
<string name="bot_mode_group_no_messages">Aún no hay mensajes en la sala</string>
|
||||
<string name="bot_mode_empty">Aún no hay Bots ni salas de grupo</string>
|
||||
<string name="bot_mode_group_title">Sala de grupo</string>
|
||||
<string name="bot_mode_group_missing">Esta sala de grupo ya no está disponible.</string>
|
||||
<string name="bot_mode_group_read_only_help">Este es el historial limitado y de solo lectura compartido por Hermes Desktop. Por ahora, continúa con un Bot individual.</string>
|
||||
<string name="bot_mode_bot_name">Nombre del perfil</string>
|
||||
<string name="bot_mode_bot_title">Nombre del Bot</string>
|
||||
<string name="bot_mode_bot_description">Rol y descripción</string>
|
||||
<string name="bot_mode_create_help">El nuevo Bot parte del perfil predeterminado y comparte su inicio de sesión. Puedes ajustar sus habilidades y modelo en Administrar.</string>
|
||||
<string name="bot_mode_create">Crear Bot</string>
|
||||
<string name="bot_mode_back_to_bots">Volver a Modo Bot</string>
|
||||
<string name="bot_mode_chat_open_failed">No se pudo abrir el Bot Chat</string>
|
||||
<string name="bot_mode_created">Se creó %1$s</string>
|
||||
<string name="bot_mode_create_failed">No se pudo crear el Bot</string>
|
||||
<string name="drawer_filter_by_source">Filtrar por fuente</string>
|
||||
<string name="drawer_show_sources">Mostrar fuentes</string>
|
||||
<string name="drawer_refresh_sessions">Actualizar sesiones</string>
|
||||
|
||||
@@ -900,6 +900,36 @@
|
||||
<string name="detail_overview_summary">Chat、Manage、Voice では標準の Hermes を使用します。Relay は高度な端末機能を追加するオプションの拡張機能です。</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Botモード</string>
|
||||
<string name="bot_mode_drawer_summary">Botとグループルーム</string>
|
||||
<string name="bot_mode_search">Botモードを検索</string>
|
||||
<string name="bot_mode_search_hint">Botとグループを検索</string>
|
||||
<string name="bot_mode_refresh">Botモードを更新</string>
|
||||
<string name="bot_mode_new_bot">新しいBot</string>
|
||||
<string name="bot_mode_gateway_unavailable">ゲートウェイを利用できません</string>
|
||||
<string name="bot_mode_all_gateways">すべてのゲートウェイ</string>
|
||||
<string name="bot_mode_offline">オフライン</string>
|
||||
<string name="bot_mode_filter_all">すべて</string>
|
||||
<string name="bot_mode_filter_bots">Bot</string>
|
||||
<string name="bot_mode_filter_groups">グループ</string>
|
||||
<string name="bot_mode_active_now">現在アクティブ</string>
|
||||
<string name="bot_mode_opening_chat">Bot Chatを開いています…</string>
|
||||
<string name="bot_mode_no_messages">Bot Chatを開始</string>
|
||||
<string name="bot_mode_read_only">読み取り専用</string>
|
||||
<string name="bot_mode_group_no_messages">ルームのメッセージはまだありません</string>
|
||||
<string name="bot_mode_empty">Botまたはグループルームはまだありません</string>
|
||||
<string name="bot_mode_group_title">グループルーム</string>
|
||||
<string name="bot_mode_group_missing">このグループルームは利用できなくなりました。</string>
|
||||
<string name="bot_mode_group_read_only_help">これはHermes Desktopが共有する範囲限定の読み取り専用ルーム履歴です。当面は個別のBotで会話を続けてください。</string>
|
||||
<string name="bot_mode_bot_name">プロファイル名</string>
|
||||
<string name="bot_mode_bot_title">Bot名</string>
|
||||
<string name="bot_mode_bot_description">役割と説明</string>
|
||||
<string name="bot_mode_create_help">新しいBotは既定のプロファイルを基に作成され、サインインを共有します。スキルとモデルは管理画面で調整できます。</string>
|
||||
<string name="bot_mode_create">Botを作成</string>
|
||||
<string name="bot_mode_back_to_bots">Botモードに戻る</string>
|
||||
<string name="bot_mode_chat_open_failed">Bot Chatを開けませんでした</string>
|
||||
<string name="bot_mode_created">%1$sを作成しました</string>
|
||||
<string name="bot_mode_create_failed">Botを作成できませんでした</string>
|
||||
<string name="drawer_filter_by_source">ソースによるフィルター</string>
|
||||
<string name="drawer_show_sources">ソースを表示</string>
|
||||
<string name="drawer_refresh_sessions">セッションを更新する</string>
|
||||
|
||||
@@ -910,6 +910,36 @@
|
||||
<string name="active_section_unencrypted_transport">Это соединение использует незашифрованный транспорт</string>
|
||||
<string name="active_section_actions">Действия</string>
|
||||
<string name="active_section_revoke_relay">Отозвать сопряжение Relay</string>
|
||||
<string name="bot_mode_title">Режим ботов</string>
|
||||
<string name="bot_mode_drawer_summary">Боты и групповые комнаты</string>
|
||||
<string name="bot_mode_search">Поиск в режиме ботов</string>
|
||||
<string name="bot_mode_search_hint">Поиск ботов и групп</string>
|
||||
<string name="bot_mode_refresh">Обновить режим ботов</string>
|
||||
<string name="bot_mode_new_bot">Новый бот</string>
|
||||
<string name="bot_mode_gateway_unavailable">Шлюз недоступен</string>
|
||||
<string name="bot_mode_all_gateways">Все шлюзы</string>
|
||||
<string name="bot_mode_offline">Не в сети</string>
|
||||
<string name="bot_mode_filter_all">Все</string>
|
||||
<string name="bot_mode_filter_bots">Боты</string>
|
||||
<string name="bot_mode_filter_groups">Группы</string>
|
||||
<string name="bot_mode_active_now">Сейчас активны</string>
|
||||
<string name="bot_mode_opening_chat">Открывается Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Начать Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Только чтение</string>
|
||||
<string name="bot_mode_group_no_messages">В комнате пока нет сообщений</string>
|
||||
<string name="bot_mode_empty">Пока нет ботов или групповых комнат</string>
|
||||
<string name="bot_mode_group_title">Групповая комната</string>
|
||||
<string name="bot_mode_group_missing">Эта групповая комната больше недоступна.</string>
|
||||
<string name="bot_mode_group_read_only_help">Это ограниченная история комнаты только для чтения, предоставленная Hermes Desktop. Пока продолжайте общение с отдельным ботом.</string>
|
||||
<string name="bot_mode_bot_name">Имя профиля</string>
|
||||
<string name="bot_mode_bot_title">Имя бота</string>
|
||||
<string name="bot_mode_bot_description">Роль и описание</string>
|
||||
<string name="bot_mode_create_help">Новый бот создаётся на основе профиля по умолчанию и использует его вход. Навыки и модель можно настроить в разделе управления.</string>
|
||||
<string name="bot_mode_create">Создать бота</string>
|
||||
<string name="bot_mode_back_to_bots">Назад в режим ботов</string>
|
||||
<string name="bot_mode_chat_open_failed">Не удалось открыть Bot Chat</string>
|
||||
<string name="bot_mode_created">%1$s создан</string>
|
||||
<string name="bot_mode_create_failed">Не удалось создать бота</string>
|
||||
<string name="drawer_filter_by_source">Фильтр по источнику</string>
|
||||
<string name="drawer_show_sources">Показать источники</string>
|
||||
<string name="drawer_refresh_sessions">Обновить сессии</string>
|
||||
|
||||
@@ -1002,6 +1002,36 @@
|
||||
<string name="active_section_revoke_relay">Revoke Relay pairing</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Bot Mode</string>
|
||||
<string name="bot_mode_drawer_summary">Bots and group rooms</string>
|
||||
<string name="bot_mode_search">Search Bot Mode</string>
|
||||
<string name="bot_mode_search_hint">Search Bots and groups</string>
|
||||
<string name="bot_mode_refresh">Refresh Bot Mode</string>
|
||||
<string name="bot_mode_new_bot">New Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway unavailable</string>
|
||||
<string name="bot_mode_all_gateways">All gateways</string>
|
||||
<string name="bot_mode_offline">Offline</string>
|
||||
<string name="bot_mode_filter_all">All</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Groups</string>
|
||||
<string name="bot_mode_active_now">Active now</string>
|
||||
<string name="bot_mode_opening_chat">Opening Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Start the Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Read only</string>
|
||||
<string name="bot_mode_group_no_messages">No room messages yet</string>
|
||||
<string name="bot_mode_empty">No Bots or group rooms yet</string>
|
||||
<string name="bot_mode_group_title">Group room</string>
|
||||
<string name="bot_mode_group_missing">This group room is no longer available.</string>
|
||||
<string name="bot_mode_group_read_only_help">This is the bounded read-only room history shared by Hermes Desktop. Continue with an individual Bot for now.</string>
|
||||
<string name="bot_mode_bot_name">Profile name</string>
|
||||
<string name="bot_mode_bot_title">Bot name</string>
|
||||
<string name="bot_mode_bot_description">Role and description</string>
|
||||
<string name="bot_mode_create_help">The new Bot starts from the default profile and shares its sign-in. You can refine its skills and model in Manage.</string>
|
||||
<string name="bot_mode_create">Create Bot</string>
|
||||
<string name="bot_mode_back_to_bots">Back to Bot Mode</string>
|
||||
<string name="bot_mode_chat_open_failed">Bot Chat could not open</string>
|
||||
<string name="bot_mode_created">%1$s created</string>
|
||||
<string name="bot_mode_create_failed">Bot could not be created</string>
|
||||
<string name="drawer_filter_by_source">Filter by source</string>
|
||||
<string name="drawer_show_sources">Show sources</string>
|
||||
<string name="drawer_refresh_sessions">Refresh sessions</string>
|
||||
|
||||
+4
-1
@@ -76,6 +76,7 @@ class DashboardApiClientTest {
|
||||
"""
|
||||
{
|
||||
"version": "0.16.0",
|
||||
"install_id": " install-a ",
|
||||
"auth_required": true,
|
||||
"auth_providers": ["basic", "nous"]
|
||||
}
|
||||
@@ -92,6 +93,7 @@ class DashboardApiClientTest {
|
||||
assertEquals(listOf("basic", "nous"), status.authProviders)
|
||||
assertEquals("basic", status.authProviderDetails.first().name)
|
||||
assertEquals("0.16.0", status.version)
|
||||
assertEquals("install-a", status.installId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -289,6 +291,7 @@ class DashboardApiClientTest {
|
||||
val wsUrl = DashboardApiClient.gatewayWebSocketUrl(
|
||||
baseUrl = "https://example.com/hermes/",
|
||||
ticket = "abc/123",
|
||||
profile = "research bot",
|
||||
)
|
||||
val landingPath = DashboardApiClient.authLandingPath("https://example.com/hermes/")
|
||||
|
||||
@@ -297,7 +300,7 @@ class DashboardApiClientTest {
|
||||
authUrl,
|
||||
)
|
||||
assertEquals(
|
||||
"wss://example.com/hermes/api/ws?ticket=abc%2F123",
|
||||
"wss://example.com/hermes/api/ws?ticket=abc%2F123&profile=research%20bot",
|
||||
wsUrl,
|
||||
)
|
||||
assertEquals("/hermes/", landingPath)
|
||||
|
||||
+166
@@ -190,6 +190,11 @@ class GatewayClientHarness(
|
||||
})))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var sessionListPayload: JsonObject = buildJsonObject {
|
||||
put("sessions", JsonArray(emptyList()))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var profileCreatePayload: JsonObject = buildJsonObject {
|
||||
put("ok", true)
|
||||
@@ -310,6 +315,8 @@ class GatewayClientHarness(
|
||||
"session.activate" -> recoveryPayload(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
|
||||
)
|
||||
"session.list" -> sessionListPayload
|
||||
"session.title" -> buildJsonObject { put("ok", true) }
|
||||
"prompt.submit" -> promptSubmitPayload
|
||||
"session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
"process.list" -> buildJsonObject {
|
||||
@@ -891,6 +898,165 @@ class GatewayChatClientTest {
|
||||
assertEquals(false, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bot roster parses canonical chats activity and read only room projection`() = runBlocking {
|
||||
harness.profilesListPayload = buildJsonObject {
|
||||
put("bot_mode_protocol", true)
|
||||
put("profiles", JsonArray(listOf(buildJsonObject {
|
||||
put("name", "default")
|
||||
put("display_name", "Hermes")
|
||||
put("model", "gpt-5.6")
|
||||
put("is_default", true)
|
||||
put("canonical_session", buildJsonObject {
|
||||
put("id", "bot-root")
|
||||
put("resolved_id", "bot-tip")
|
||||
put("root_title", "Bot Chat")
|
||||
put("preview", "Release plan ready")
|
||||
put("last_active", 1_777_000_000)
|
||||
put("message_count", 8)
|
||||
})
|
||||
put("worker_session", buildJsonObject {
|
||||
put("id", "worker-1")
|
||||
put("title", "Build")
|
||||
put("last_active", 1_777_000_030)
|
||||
})
|
||||
put("ui_meta", buildJsonObject {
|
||||
put("hermes-bots", buildJsonObject { put("title", "Lucy") })
|
||||
put("hermes-bots-groups", buildJsonObject {
|
||||
put("version", 3)
|
||||
put("rooms", buildJsonObject {
|
||||
put("id:launch", buildJsonObject {
|
||||
put("name", "Launch Council")
|
||||
put("roomId", "launch")
|
||||
put("revision", 4)
|
||||
put("members", JsonArray(listOf(buildJsonObject {
|
||||
put("name", "default")
|
||||
put("handle", "hermes")
|
||||
})))
|
||||
put("log", JsonArray(listOf(buildJsonObject {
|
||||
put("id", "message-1")
|
||||
put("from", buildJsonObject {
|
||||
put("kind", "member")
|
||||
put("name", "Lucy")
|
||||
})
|
||||
put("text", "Rollout is clear")
|
||||
put("at", 1_777_000_020)
|
||||
})))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
val roster = client.listBotModeRoster().getOrThrow()
|
||||
|
||||
assertTrue(roster.botModeProtocolSupported)
|
||||
assertEquals("Lucy", roster.bots.single().displayName)
|
||||
assertEquals("bot-tip", roster.bots.single().canonicalSession?.resolvedId)
|
||||
assertEquals(1_777_000_030_000L, roster.bots.single().workerSession?.lastActiveAtMs)
|
||||
assertEquals("Launch Council", roster.groups.single().name)
|
||||
assertEquals("Rollout is clear", roster.groups.single().latestMessage?.text)
|
||||
assertEquals(true, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot chat adopts exact title registry without creating`() = runBlocking {
|
||||
harness.sessionListPayload = buildJsonObject {
|
||||
put("sessions", JsonArray(listOf(buildJsonObject {
|
||||
put("id", "bot-root")
|
||||
put("resolved_id", "bot-tip")
|
||||
put("root_title", "Bot Chat")
|
||||
})))
|
||||
}
|
||||
|
||||
val target = client.ensureCanonicalBotChat("operator").getOrThrow()
|
||||
|
||||
assertEquals("bot-root", target.storedSessionId)
|
||||
assertEquals("bot-tip", target.resolvedSessionId)
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.create" })
|
||||
val lookup = harness.awaitRpc("session.list")
|
||||
assertEquals("operator", (lookup["profile"] as JsonPrimitive).content)
|
||||
assertEquals("Bot Chat", (lookup["title"] as JsonPrimitive).content)
|
||||
assertEquals(true, (lookup["include_hidden"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot chat creates hidden row only after authoritative empty lookup`() = runBlocking {
|
||||
harness.createdSessionProfileName = "operator"
|
||||
|
||||
val target = client.ensureCanonicalBotChat("operator").getOrThrow()
|
||||
|
||||
assertEquals("20260612_120000_abc123", target.storedSessionId)
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("operator", (create["profile"] as JsonPrimitive).content)
|
||||
assertEquals("Bot Chat", (create["title"] as JsonPrimitive).content)
|
||||
assertEquals(true, (create["hidden"] as JsonPrimitive).booleanOrNull)
|
||||
val title = harness.awaitRpc("session.title")
|
||||
assertEquals("live-1", (title["session_id"] as JsonPrimitive).content)
|
||||
assertEquals("Bot Chat", (title["title"] as JsonPrimitive).content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot chat lookup failure never creates replacement`() = runBlocking {
|
||||
harness.rpcErrors["session.list"] = 5006 to "profile db unavailable"
|
||||
|
||||
assertTrue(client.ensureCanonicalBotChat("operator").isFailure)
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.create" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed route profile rides websocket URL and canonical RPC`() = runBlocking {
|
||||
val routeClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "research bot",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
)
|
||||
try {
|
||||
routeClient.ensureCanonicalBotChat("research bot").getOrThrow()
|
||||
val requests = List(2) { harness.server.takeRequest(5, TimeUnit.SECONDS) }
|
||||
assertTrue(requests.filterNotNull().any {
|
||||
it.path?.contains("profile=research%20bot") == true
|
||||
})
|
||||
assertEquals(
|
||||
"research bot",
|
||||
(harness.awaitRpc("session.list")["profile"] as JsonPrimitive).content,
|
||||
)
|
||||
} finally {
|
||||
routeClient.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot lookup on remote route never reaches active gateway`() = runBlocking {
|
||||
val remoteHarness = GatewayClientHarness()
|
||||
val remoteScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val remoteClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = remoteHarness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "default",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = remoteScope,
|
||||
)
|
||||
try {
|
||||
remoteClient.ensureCanonicalBotChat("default").getOrThrow()
|
||||
assertTrue(remoteHarness.rpcLog.any { it.first == "session.list" })
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.list" })
|
||||
} finally {
|
||||
remoteClient.shutdown()
|
||||
remoteScope.cancel()
|
||||
remoteHarness.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile list drops oversized ui meta without dropping profile`() = runBlocking {
|
||||
harness.profilesListPayload = buildJsonObject {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.BotSessionSummary
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.components.LocalSphereSkin
|
||||
import com.hermesandroid.relay.ui.components.SphereRegistry
|
||||
import com.hermesandroid.relay.ui.screens.BotModeContent
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import java.io.File
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w390dp-h844dp-432dpi")
|
||||
class BotModeScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun approvedBotModeHome() {
|
||||
val output = File("build/ui-evidence/bot-mode-home.png")
|
||||
output.parentFile?.mkdirs()
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
CompositionLocalProvider(LocalSphereSkin provides SphereRegistry.Adaptive) {
|
||||
BotModeContent(
|
||||
state = fixtureState(),
|
||||
connections = listOf(
|
||||
fixtureConnection("hermes", "Hermes"),
|
||||
fixtureConnection("lab", "Lab server"),
|
||||
),
|
||||
activeConnection = fixtureConnection("hermes", "Hermes"),
|
||||
onBack = {},
|
||||
onRefresh = {},
|
||||
onSelectGateway = {},
|
||||
onOpenBot = {},
|
||||
onOpenGroup = {},
|
||||
onNewBot = {},
|
||||
nowMs = NOW,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage(output.absolutePath)
|
||||
}
|
||||
|
||||
private fun fixtureState() = BotModeState(
|
||||
roster = BotModeRoster(
|
||||
bots = listOf(
|
||||
bot("hermes", "Hermes", "default", "Lucy", "Drafted a rollout plan for the new flow.", NOW - 60_000L),
|
||||
bot("lab", "Lab server", "researcher", "Researcher", "Here are the latest findings.", NOW - 18 * 60_000L),
|
||||
bot("hermes", "Hermes", "builder", "Builder", "Build complete. 3 tests added.", NOW - 43 * 60_000L),
|
||||
),
|
||||
groups = listOf(
|
||||
room("id:launch", "Launch Council", "Maya", "Please review the deck when you can.", NOW - 86_400_000L),
|
||||
room("id:home", "Home Lab", "Alex", "Benchmarked the new model.", NOW - 3 * 86_400_000L),
|
||||
),
|
||||
botModeProtocolSupported = true,
|
||||
),
|
||||
)
|
||||
|
||||
private fun bot(
|
||||
connectionId: String,
|
||||
connectionLabel: String,
|
||||
name: String,
|
||||
title: String,
|
||||
preview: String,
|
||||
activeAt: Long,
|
||||
) = BotRosterEntry(
|
||||
profile = Profile(name = name, model = "gpt-5.6", description = title),
|
||||
displayName = title,
|
||||
route = com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey(connectionId, name),
|
||||
connectionLabel = connectionLabel,
|
||||
),
|
||||
canonicalSession = BotSessionSummary(
|
||||
id = "$name-bot-chat",
|
||||
preview = preview,
|
||||
lastActiveAtMs = activeAt,
|
||||
messageCount = 8,
|
||||
),
|
||||
workerSession = BotSessionSummary(
|
||||
id = "$name-worker",
|
||||
lastActiveAtMs = NOW - 30_000L,
|
||||
),
|
||||
)
|
||||
|
||||
private fun room(key: String, name: String, sender: String, text: String, at: Long) = BotGroupRoom(
|
||||
key = key,
|
||||
roomId = key.substringAfter(':'),
|
||||
name = name,
|
||||
messages = listOf(
|
||||
BotGroupMessage(
|
||||
id = "$key-message",
|
||||
senderName = sender,
|
||||
senderKind = "member",
|
||||
text = text,
|
||||
atMs = at,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun fixtureConnection(id: String, label: String) = Connection(
|
||||
id = id,
|
||||
label = label,
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "https://example.invalid",
|
||||
tokenStoreKey = "test-$id",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val NOW = 1_777_000_000_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.BotSessionSummary
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w390dp-h844dp-432dpi")
|
||||
class BotModeScreenTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `tabs filter bots and read only groups without changing workspace`() {
|
||||
render()
|
||||
|
||||
compose.onNodeWithText("Lucy").assertExists()
|
||||
compose.onNodeWithText("Launch Council").assertExists()
|
||||
|
||||
compose.onNodeWithText("Groups").performClick()
|
||||
|
||||
compose.onNodeWithText("Lucy").assertDoesNotExist()
|
||||
compose.onNodeWithText("Launch Council").assertExists()
|
||||
compose.onNodeWithText("Read only").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bot and group rows dispatch their exact owners`() {
|
||||
var botOwner: String? = null
|
||||
var groupOwner: String? = null
|
||||
render(
|
||||
onOpenBot = { botOwner = it.profile.name },
|
||||
onOpenGroup = { groupOwner = it.key },
|
||||
)
|
||||
|
||||
compose.onNodeWithText("Lucy").performClick()
|
||||
compose.onNodeWithText("Launch Council").performClick()
|
||||
|
||||
assertEquals("default", botOwner)
|
||||
assertEquals("id:launch", groupOwner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gateway scope filters union without switching active connection`() {
|
||||
render(selectedGatewayId = "lab")
|
||||
|
||||
compose.onNodeWithText("Researcher").assertExists()
|
||||
compose.onNodeWithText("Lucy").assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun render(
|
||||
onOpenBot: (BotRosterEntry) -> Unit = {},
|
||||
onOpenGroup: (BotGroupRoom) -> Unit = {},
|
||||
selectedGatewayId: String? = null,
|
||||
) {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
BotModeContent(
|
||||
state = state(),
|
||||
connections = listOf(connection(), labConnection()),
|
||||
activeConnection = connection(),
|
||||
selectedGatewayId = selectedGatewayId,
|
||||
onBack = {},
|
||||
onRefresh = {},
|
||||
onSelectGateway = {},
|
||||
onOpenBot = onOpenBot,
|
||||
onOpenGroup = onOpenGroup,
|
||||
onNewBot = {},
|
||||
nowMs = NOW + 200_000L,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun state() = BotModeState(
|
||||
roster = BotModeRoster(
|
||||
bots = listOf(
|
||||
BotRosterEntry(
|
||||
profile = Profile(name = "default", model = "gpt-5.6", description = "Operator"),
|
||||
displayName = "Lucy",
|
||||
route = com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey("home", "default"),
|
||||
connectionLabel = "Hermes",
|
||||
),
|
||||
canonicalSession = BotSessionSummary(
|
||||
id = "bot-root",
|
||||
preview = "Drafted a rollout plan",
|
||||
lastActiveAtMs = NOW - 10_000L,
|
||||
),
|
||||
),
|
||||
BotRosterEntry(
|
||||
profile = Profile(name = "researcher", model = "gpt-5.6", description = "Research"),
|
||||
displayName = "Researcher",
|
||||
route = com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "researcher"),
|
||||
connectionLabel = "Lab server",
|
||||
),
|
||||
canonicalSession = BotSessionSummary(
|
||||
id = "researcher-root",
|
||||
preview = "Findings ready",
|
||||
lastActiveAtMs = NOW - 30_000L,
|
||||
),
|
||||
),
|
||||
),
|
||||
groups = listOf(
|
||||
BotGroupRoom(
|
||||
key = "id:launch",
|
||||
roomId = "launch",
|
||||
name = "Launch Council",
|
||||
messages = listOf(
|
||||
BotGroupMessage(
|
||||
id = "message-1",
|
||||
senderName = "Lucy",
|
||||
senderKind = "member",
|
||||
text = "Rollout is clear",
|
||||
atMs = NOW - 20_000L,
|
||||
),
|
||||
),
|
||||
sourceConnectionIds = setOf("home", "lab"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun connection() = Connection(
|
||||
id = "home",
|
||||
label = "Hermes",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "https://example.invalid",
|
||||
tokenStoreKey = "test",
|
||||
)
|
||||
|
||||
private fun labConnection() = Connection(
|
||||
id = "lab",
|
||||
label = "Lab server",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "https://lab.invalid",
|
||||
tokenStoreKey = "test-lab",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val NOW = 1_777_000_000_000L
|
||||
}
|
||||
}
|
||||
+9
@@ -58,6 +58,15 @@ class ChatViewModelCommandCatalogTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalBotChat_rewritesOnlyConversationForkCommands() {
|
||||
assertTrue(shouldCompactCanonicalBotChat("new", canonicalBotChatMode = true))
|
||||
assertTrue(shouldCompactCanonicalBotChat("RESET", canonicalBotChatMode = true))
|
||||
assertFalse(shouldCompactCanonicalBotChat("new", canonicalBotChatMode = false))
|
||||
assertFalse(shouldCompactCanonicalBotChat("compact", canonicalBotChatMode = true))
|
||||
assertFalse(shouldCompactCanonicalBotChat("title", canonicalBotChatMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCommandsCatalog_ranksOnlySkillSlotsAndBoundsMetadata() {
|
||||
val catalog = buildJsonObject {
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayClientHarness
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BotModeControllerTest {
|
||||
@Test
|
||||
fun refreshAggregatesTwoGatewaysPreservesOfflineCacheAndNeverSwitchesActive() = runBlocking {
|
||||
val aHarness = GatewayClientHarness()
|
||||
val bHarness = GatewayClientHarness()
|
||||
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val clients = mapOf(
|
||||
"a" to routeClient(aHarness, routeScope),
|
||||
"b" to routeClient(bHarness, routeScope),
|
||||
)
|
||||
aHarness.profilesListPayload = rosterPayload("default")
|
||||
bHarness.profilesListPayload = rosterPayload("researcher")
|
||||
val connections = MutableStateFlow(
|
||||
listOf(
|
||||
connection("a", "Laptop", aHarness),
|
||||
connection("b", "Home Lab", bHarness),
|
||||
),
|
||||
)
|
||||
val active = MutableStateFlow<String?>("a")
|
||||
val controller = BotModeController(
|
||||
scope = this,
|
||||
connections = connections,
|
||||
activeConnectionId = active,
|
||||
dashboardUrlProvider = Connection::resolvedDashboardUrl,
|
||||
dashboardClientFactory = { _, url -> DashboardApiClient(url, OkHttpClient()) },
|
||||
gatewayLeaseFactory = { connectionId, _, _, _ ->
|
||||
UpstreamTransportController.RouteGatewayLease(checkNotNull(clients[connectionId])) {}
|
||||
},
|
||||
)
|
||||
try {
|
||||
controller.refreshNow()
|
||||
assertEquals(setOf("default", "researcher"), controller.state.value.roster.bots.map { it.profile.name }.toSet())
|
||||
assertEquals("a", active.value)
|
||||
|
||||
bHarness.rpcErrors["profiles.list"] = 5006 to "offline"
|
||||
controller.refreshNow()
|
||||
assertTrue(
|
||||
controller.state.value.roster.bots
|
||||
.first { it.profile.name == "researcher" }
|
||||
.stale,
|
||||
)
|
||||
assertEquals("a", active.value)
|
||||
|
||||
connections.value = connections.value.filterNot { it.id == "b" }
|
||||
controller.connectionRemoved("b")
|
||||
assertEquals(listOf("default"), controller.state.value.roster.bots.map { it.profile.name })
|
||||
} finally {
|
||||
clients.values.forEach(GatewayChatClient::shutdown)
|
||||
routeScope.cancel()
|
||||
aHarness.shutdown()
|
||||
bHarness.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeClient(harness: GatewayClientHarness, scope: CoroutineScope) = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
harness.server.url("/").toString().trimEnd('/'),
|
||||
OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "default",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private fun rosterPayload(profile: String) = buildJsonObject {
|
||||
put("profiles", JsonArray(listOf(buildJsonObject {
|
||||
put("name", profile)
|
||||
put("model", "gpt")
|
||||
put("is_default", profile == "default")
|
||||
})))
|
||||
put("bot_mode_protocol", true)
|
||||
}
|
||||
|
||||
private fun connection(id: String, label: String, harness: GatewayClientHarness) = Connection(
|
||||
id = id,
|
||||
label = label,
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
tokenStoreKey = "token-$id",
|
||||
)
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BotModeRosterAggregationTest {
|
||||
private val a = connection("a", "Laptop")
|
||||
private val b = connection("b", "Home Lab")
|
||||
private val connections = MutableStateFlow(listOf(a, b))
|
||||
private val active = MutableStateFlow<String?>("a")
|
||||
private val controller = BotModeController(
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
|
||||
connections = connections,
|
||||
activeConnectionId = active,
|
||||
dashboardUrlProvider = Connection::resolvedDashboardUrl,
|
||||
dashboardClientFactory = { _, url -> DashboardApiClient(url) },
|
||||
gatewayLeaseFactory = { _, _, _, _ -> error("not used by aggregation tests") },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun sameInstallCollapsesBeforeHandleCountingAndActiveRouteWins() {
|
||||
val state = aggregate(
|
||||
snapshot(a, "install-1", bots = listOf(bot("default"))),
|
||||
snapshot(b, "install-1", bots = listOf(bot("default"))),
|
||||
)
|
||||
|
||||
assertEquals(1, state.roster.bots.size)
|
||||
assertEquals("a", state.roster.bots.single().route?.connectionId)
|
||||
assertEquals("default", state.roster.bots.single().handle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameNameOnDifferentInstallsUsesSourceQualifiedHandles() {
|
||||
val state = aggregate(
|
||||
snapshot(a, "install-a", bots = listOf(bot("default"), bot("unique"))),
|
||||
snapshot(b, "install-b", bots = listOf(bot("default"))),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
setOf("default-laptop", "default-home-lab", "unique"),
|
||||
state.roster.bots.mapTo(linkedSetOf(), BotRosterEntry::handle),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingInstallIdsNeverCollapseAndOfflineRowsStayStale() {
|
||||
val state = aggregate(
|
||||
snapshot(a, null, bots = listOf(bot("worker")), stale = true),
|
||||
snapshot(b, null, bots = listOf(bot("worker"))),
|
||||
)
|
||||
|
||||
assertEquals(2, state.roster.bots.size)
|
||||
assertTrue(state.roster.bots.first { it.route?.connectionId == "a" }.stale)
|
||||
assertFalse(state.roster.bots.first { it.route?.connectionId == "b" }.stale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupProjectionDeduplicatesByRoomIdentityAndKeepsAllSources() {
|
||||
val old = room(revision = 1, text = "old")
|
||||
val fresh = room(revision = 2, text = "fresh")
|
||||
val state = aggregate(
|
||||
snapshot(a, "install-a", groups = listOf(old)),
|
||||
snapshot(b, "install-b", groups = listOf(fresh)),
|
||||
)
|
||||
|
||||
assertEquals(1, state.roster.groups.size)
|
||||
assertEquals("fresh", state.roster.groups.single().latestMessage?.text)
|
||||
assertEquals(setOf("a", "b"), state.roster.groups.single().sourceConnectionIds)
|
||||
}
|
||||
|
||||
private fun aggregate(vararg snapshots: BotModeGatewaySnapshot) = controller.aggregateForTest(
|
||||
fleet = connections.value,
|
||||
snapshots = snapshots.associateBy { it.connection.id },
|
||||
loading = false,
|
||||
)
|
||||
|
||||
private fun snapshot(
|
||||
connection: Connection,
|
||||
installId: String?,
|
||||
bots: List<BotRosterEntry> = emptyList(),
|
||||
groups: List<BotGroupRoom> = emptyList(),
|
||||
stale: Boolean = false,
|
||||
) = BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = connection.resolvedDashboardUrl,
|
||||
installId = installId,
|
||||
roster = BotModeRoster(bots = bots, groups = groups, botModeProtocolSupported = true),
|
||||
stale = stale,
|
||||
error = if (stale) "offline" else null,
|
||||
)
|
||||
|
||||
private fun bot(name: String) = BotRosterEntry(
|
||||
profile = Profile(name = name, model = "gpt"),
|
||||
displayName = name,
|
||||
)
|
||||
|
||||
private fun room(revision: Long, text: String) = BotGroupRoom(
|
||||
key = "id:room-1",
|
||||
roomId = "room-1",
|
||||
name = "Council",
|
||||
revision = revision,
|
||||
messages = listOf(
|
||||
BotGroupMessage(
|
||||
senderName = "Bot",
|
||||
senderKind = "member",
|
||||
text = text,
|
||||
atMs = revision,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun connection(id: String, label: String) = Connection(
|
||||
id = id,
|
||||
label = label,
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "http://$id.invalid",
|
||||
tokenStoreKey = "token-$id",
|
||||
)
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class UpstreamTransportControllerRoutePoolTest {
|
||||
private val controller = UpstreamTransportController(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
activeConnectionIdProvider = { "a" },
|
||||
dashboardUrlProvider = { "http://a.invalid" },
|
||||
gatewayKeepAliveProvider = { false },
|
||||
trustedDashboardUrlProvider = { id -> "http://$id.invalid" },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun exactConnectionAndProfileOwnOneClient() {
|
||||
val first = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
|
||||
val repeated = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
|
||||
val sibling = controller.acquireGatewayRoute("a", "http://a.invalid", "writer")
|
||||
val remoteTwin = controller.acquireGatewayRoute("b", "http://b.invalid", "default")
|
||||
|
||||
assertSame(first.client, repeated.client)
|
||||
assertNotSame(first.client, sibling.client)
|
||||
assertNotSame(first.client, remoteTwin.client)
|
||||
|
||||
first.close()
|
||||
repeated.close()
|
||||
sibling.close()
|
||||
remoteTwin.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionRemovalDisposesOnlyThatConnectionsRoutes() {
|
||||
val a = controller.acquireGatewayRoute("a", "http://a.invalid", "default", retain = true)
|
||||
val b = controller.acquireGatewayRoute("b", "http://b.invalid", "default", retain = true)
|
||||
val oldA = a.client
|
||||
val oldB = b.client
|
||||
|
||||
controller.disposeConnectionRouteClients("a")
|
||||
a.close()
|
||||
val newA = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
|
||||
val sameB = controller.acquireGatewayRoute("b", "http://b.invalid", "default")
|
||||
|
||||
assertNotSame(oldA, newA.client)
|
||||
assertSame(oldB, sameB.client)
|
||||
|
||||
b.close()
|
||||
newA.close()
|
||||
sameB.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleLeaseCannotEvictRouteReplacement() {
|
||||
val old = controller.acquireGatewayRoute("a", "http://a.invalid", "default", retain = true)
|
||||
val replacement = controller.acquireGatewayRoute(
|
||||
"a",
|
||||
"http://a.invalid/alternate",
|
||||
"default",
|
||||
)
|
||||
assertNotSame(old.client, replacement.client)
|
||||
|
||||
old.close()
|
||||
val repeated = controller.acquireGatewayRoute(
|
||||
"a",
|
||||
"http://a.invalid/alternate",
|
||||
"default",
|
||||
)
|
||||
assertSame(replacement.client, repeated.client)
|
||||
|
||||
replacement.close()
|
||||
repeated.close()
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,54 @@ No open P0, P1, or P2 findings.
|
||||
|
||||
final result: passed
|
||||
|
||||
# Android Bot Mode messenger workspace
|
||||
|
||||
**Comparison target**
|
||||
|
||||
- Source visual truth: `docs/mockups/bot-mode/bot-mode-home-approved.png`
|
||||
- Rendered implementation: `app/build/ui-evidence/bot-mode-home.png`
|
||||
- Combined evidence: `app/build/ui-evidence/bot-mode-comparison.png`
|
||||
- Viewport: Android Compose at `390dp x 844dp`, Robolectric qualifier `w390dp-h844dp-432dpi`
|
||||
- Pixels and normalization: source `853 x 1844`; implementation `1053 x 2278` at Android density `2.7`. The combined evidence downsamples the implementation to `853 x 1844` and places it beside the source at equal visible bounds.
|
||||
- State: dark Hermes Relay theme, active Hermes gateway, All filter, three active Bots, three Bot conversations, and two read-only group rooms.
|
||||
|
||||
**Findings**
|
||||
|
||||
- No actionable P0, P1, or P2 differences remain.
|
||||
- Fonts and typography: the implementation uses the app's established Material typography and optical weights. The title, selector labels, active roster, conversation titles, metadata, and one-line previews preserve the source hierarchy without clipped primary labels.
|
||||
- Spacing and layout rhythm: the implementation preserves the full-screen messenger composition, compact gateway selector, equal-width filters, horizontal active roster, divided chronological list, and one bottom-end primary action. Native touch targets make the selector and filters slightly taller than the generated source; all five rows remain visible at the target viewport.
|
||||
- Colors and visual tokens: the first capture overused the strong electric `primaryContainer`. The final capture uses the softer theme-scoped relay/periwinkle tokens for selected and identity surfaces, graphite containers, semantic green presence, and restrained hairlines.
|
||||
- Image quality and asset fidelity: production rows render the real cached upstream profile avatar when present. The deterministic fixture exercises the app's real initial fallback instead of shipping the mock's illustrative anime/crystal/robot images as product assets. Group rows use the app's Material group/lock icons; no placeholder bitmap or custom-drawn icon was introduced.
|
||||
- Copy and content: Bot Mode, All gateways, All/Bots/Groups, Active now, source-qualified rows, previews, and Read only match the selected workflow. Relative timestamps follow the app's existing locale-aware convention rather than the mock's fixed clock values.
|
||||
- Interaction: gateway selection, All/Bots/Groups filtering, Bot owner dispatch, group owner dispatch, search, New Bot, canonical Bot Chat open, read-only room detail, drawer entry, and Back to Bot Mode are wired. The fixture does not invent unread dots because current upstream supplies activity but no canonical mobile read-state contract.
|
||||
|
||||
**Comparison history**
|
||||
|
||||
1. Initial capture found P2 accent-weight drift: the selected tab, FAB, and fallback avatars used saturated electric fills; control borders were stronger than the source; an extra refresh action crowded the app bar.
|
||||
2. Replaced large fills with the softer theme-scoped relay/electric-muted tokens, reduced control-border opacity and vertical padding, and removed the extra refresh action while retaining automatic load and error-state retry.
|
||||
3. Re-rendered at the same `390dp x 844dp` state and normalized both artifacts into `bot-mode-comparison.png`. The corrected surface has no remaining P0/P1/P2 mismatch.
|
||||
4. After multi-gateway aggregation landed, re-rendered the final All gateways state with per-row source-qualified handles. The gateway scope now matches the approved mock literally while preserving readable row density; no new P0/P1/P2 mismatch appeared.
|
||||
|
||||
**Open Questions**
|
||||
|
||||
- None blocking. A physical-device pass may refine P3 density at the user's font/display scale, but the deterministic supported viewport is complete.
|
||||
|
||||
**Implementation Checklist**
|
||||
|
||||
- [x] Keep Bot Mode outside the ordinary session taxonomy.
|
||||
- [x] Match the approved list hierarchy and native return path.
|
||||
- [x] Render a truthful all-gateway union without changing the foreground connection.
|
||||
- [x] Render upstream group projection as visibly read-only.
|
||||
- [x] Verify the primary filters and owner-routing interactions.
|
||||
- [x] Capture and compare the actual Compose surface.
|
||||
|
||||
**Follow-up Polish**
|
||||
|
||||
- P3: physical-device review can tune handle truncation for unusually long gateway labels.
|
||||
- P3: add canonical read-state dots only if upstream publishes a durable read-state contract.
|
||||
|
||||
final result: passed
|
||||
|
||||
## Assistant overlay
|
||||
|
||||
- Reference: `C:\Users\Bailey\.codex\generated_images\019fb003-68ea-7052-85c7-3745fa7e838e\call_jPwpDr9QfaCDA6k2c01StBYe.png`
|
||||
|
||||
@@ -3683,3 +3683,57 @@ upstream drift, Android state-machine defects, rendered lifecycle failures, and
|
||||
physical-device failures are reported as distinct evidence lanes. A physical
|
||||
pass is never inferred from JVM or source checks, and scheduled execution can
|
||||
be considered later without being silently introduced now.
|
||||
|
||||
---
|
||||
|
||||
## ADR 66 — Android Bot Mode is a separate upstream-owned messaging workspace
|
||||
|
||||
**Status:** Accepted (2026-08-24).
|
||||
|
||||
**Context.** Upstream Hermes Desktop presents profiles as Bots with one durable
|
||||
canonical `Bot Chat`, profile-scoped routines, group rooms, and source-qualified
|
||||
multi-gateway ownership. Folding those rows into Android's ordinary session
|
||||
drawer would mix a standing identity/room roster with scratch conversations and
|
||||
repeat the category error avoided for platform Threads. Desktop also owns live
|
||||
group orchestration locally while publishing a bounded recent-history projection
|
||||
through Gateway `ui_meta` for other clients.
|
||||
|
||||
**Decision.** The existing drawer gains one `Bot Mode` entry and otherwise keeps
|
||||
its session behavior. Bot Mode is a full-screen messenger list with gateway,
|
||||
All/Bots/Groups, search, activity, individual Bot, and group-room affordances.
|
||||
It aggregates every saved gateway with bounded concurrency and keeps the
|
||||
foreground connection unchanged. Last-good rows remain visible as offline.
|
||||
Upstream `install_id` collapses two saved routes to the same installation before
|
||||
same-profile handle disambiguation.
|
||||
|
||||
Android consumes `profiles.list {include_sessions:true}` only for this surface.
|
||||
The Gateway's `canonical_session` wins. When absent, Android performs the exact
|
||||
hidden-title lookup for `Bot Chat`, fails closed on lookup error, and creates and
|
||||
materializes a hidden canonical row only after authoritative absence. Opening it
|
||||
uses the existing profile/session chat owner and exposes a direct return to Bot
|
||||
Mode. `/new` and `/reset` in that canonical surface run the existing bounded
|
||||
compression path instead of forking the relationship. New Bot creates an upstream profile with shared authentication and writes
|
||||
only the small `hermes-bots` metadata marker; profile skills/model remain managed
|
||||
through the established Hermes surfaces.
|
||||
|
||||
Every Bot owner is the immutable `(connectionId, profile)` pair. A typed route
|
||||
pool holds separate clients for separate owners, validates bearer authority
|
||||
against that connection's exact trusted Dashboard base, adds the profile to the
|
||||
WebSocket URL, mints a fresh one-use ticket on every dial, and uses request or
|
||||
retained leases so a stale release cannot evict a replacement. Opening a Bot
|
||||
uses a dedicated Gateway-only Chat destination and Dashboard history reader;
|
||||
it never switches the globally active connection or lets API fallback consult a
|
||||
different database.
|
||||
|
||||
Group rooms parse the bounded v3 `hermes-bots-groups` projection without its
|
||||
optional embedded image. They render read-only and cannot dispatch or mutate
|
||||
room state. Android does not copy Desktop's round-robin coordinator or local
|
||||
plugin store.
|
||||
|
||||
**Consequences.** Vanilla upstream owns Bot identity, history, auth, and prompt
|
||||
protocol; Relay remains optional. Ordinary sessions remain legible, same-named
|
||||
profiles on different gateways cannot alias, a remote Bot Chat cannot leak into
|
||||
the active connection, and a group cannot gain a competing mobile writer.
|
||||
Route-scoped Relay media, voice, background delivery notifications, autonomous
|
||||
peer delivery, and writable room control remain separate capabilities rather
|
||||
than implicit authority gained from appearing in the union roster.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
|
||||
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
|
||||
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
|
||||
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
|
||||
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
|
||||
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
|
||||
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -472,6 +472,9 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Profile creation** — Manage uses `profiles.create` on current Gateways and labels authentication as shared sign-in, copied credential snapshot, or isolated/no-copy. Android serializes `mirror_credentials` and `share_auth` explicitly, reports best-effort SOUL/model/credential results without claiming full success, and never receives or logs credentials. The user may explicitly enable the authenticated Dashboard create route as an older-host fallback only for the legacy shared/default choice; explicit isolation never degrades to an ambiguous older mutation.
|
||||
- **Deletion boundary** — Hermes exposes no `profiles.delete` Gateway RPC. Android continues to delete profiles only through authenticated Dashboard `DELETE /api/profiles/{name}`.
|
||||
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
|
||||
- **Bot Mode workspace** — the session drawer exposes one entry into a separate full-screen messenger surface; it does not add Bot or group rows to the ordinary session taxonomy. Android refreshes every saved Dashboard/Gateway with bounded concurrency, preserves last-good rows as visibly offline, and collapses duplicate routes by upstream `install_id` before assigning source-qualified handles. Every Bot carries an immutable `(connectionId, profile)` owner; labels, installation metadata, and the currently resolved URL are presentation/routing data rather than identity. All gateways and one-gateway filters never mutate the foreground connection.
|
||||
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
|
||||
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
|
||||
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
|
||||
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
|
||||
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
|
||||
|
||||
Reference in New Issue
Block a user