mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(buzz): agent-owner console with cost + personas tabs
Read-only owner dashboard over the AI-agent fleet. AgentConsoleViewModel fetches turn metrics (kind:44200, p=owner) and personas (kind:30175, authored by owner) from the Buzz-dialect relays plus the owner outbox set, decrypts the NIP-44 metric payloads with the owner signer (cached by event id), and derives fleet + per-agent totals via the pure AgentFleetAggregator. AgentConsoleScreen renders a Costs tab (fleet total, per-agent spend/tokens/ turns/sessions, estimated-total warning) and a Personas tab (display name, model, runtime, provider, system prompt). Wired as Route.AgentConsole with a settings-catalog entry under App settings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
@@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.Boo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
|
||||
@@ -456,6 +457,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -469,6 +469,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditNestsServers : Route()
|
||||
|
||||
@Serializable object AgentConsole : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentUsageSummary
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.TokenTotals
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* The workspace owner's agent-owner console — a read-only dashboard over the fleet of
|
||||
* AI agents that publish to them. Two tabs:
|
||||
* - **Costs** — fleet totals and a per-agent breakdown (turns, sessions, tokens, spend),
|
||||
* derived from decrypted NIP-AM turn metrics (`kind:44200`).
|
||||
* - **Personas** — the owner's published persona definitions (NIP-AP `kind:30175`).
|
||||
*
|
||||
* Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the fetch/decrypt
|
||||
* work survives navigation. This v1 is read-only; persona editing and NIP-OA attestation
|
||||
* issuance are follow-ups.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentConsoleScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$pubkey")
|
||||
|
||||
viewModel.bindAccountIfMissing(accountViewModel.account)
|
||||
|
||||
val metrics by viewModel.metrics.collectAsStateWithLifecycle()
|
||||
val personas by viewModel.personas.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val tabs = remember { listOf("Costs", "Personas") }
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Agent Console", nav) },
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (selectedTab) {
|
||||
0 -> CostsTab(metrics)
|
||||
else -> PersonasTab(personas)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CostsTab(metrics: AgentFleetMetrics) {
|
||||
if (metrics.agents.isEmpty()) {
|
||||
EmptyState("No agent turn metrics yet. Metrics appear once your agents publish kind:44200 events to you.")
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
FleetSummaryCard(metrics)
|
||||
}
|
||||
items(metrics.agents) { agent ->
|
||||
AgentCard(agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FleetSummaryCard(metrics: AgentFleetMetrics) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Fleet total",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = formatCost(metrics.totals.costUsd),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
MetaRow("Agents", metrics.agents.size.toString())
|
||||
MetaRow("Sessions", metrics.totalSessions.toString())
|
||||
MetaRow("Turns", metrics.totalTurns.toString())
|
||||
TokenBreakdown(metrics.totals)
|
||||
if (metrics.hasUnreliableEstimates) {
|
||||
Text(
|
||||
text = "⚠ Some totals are estimated from per-turn deltas (a session reported no cumulative counts).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentCard(agent: AgentUsageSummary) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = shortKey(agent.agentPubKey),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = formatCost(agent.totals.costUsd),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
MetaRow("Sessions", agent.sessions.toString())
|
||||
MetaRow("Turns", agent.turns.toString())
|
||||
if (agent.models.isNotEmpty()) MetaRow("Models", agent.models.sorted().joinToString(", "))
|
||||
if (agent.harnesses.isNotEmpty()) MetaRow("Harness", agent.harnesses.sorted().joinToString(", "))
|
||||
agent.lastActivity?.let { MetaRow("Last turn", it) }
|
||||
TokenBreakdown(agent.totals)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenBreakdown(totals: TokenTotals) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
MetaRow("Input tokens", formatCount(totals.inputTokens))
|
||||
MetaRow("Output tokens", formatCount(totals.outputTokens))
|
||||
MetaRow("Total tokens", formatCount(totals.totalTokens))
|
||||
if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens))
|
||||
if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonasTab(personas: List<AgentConsoleViewModel.PersonaCard>) {
|
||||
if (personas.isEmpty()) {
|
||||
EmptyState("No personas published. Personas you publish (kind:30175) appear here.")
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(personas) { persona ->
|
||||
PersonaCardView(persona)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaCardView(persona: AgentConsoleViewModel.PersonaCard) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = persona.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = persona.slug,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
persona.model?.let { MetaRow("Model", it) }
|
||||
persona.provider?.let { MetaRow("Provider", it) }
|
||||
persona.runtime?.let { MetaRow("Runtime", it) }
|
||||
persona.systemPrompt?.takeIf { it.isNotBlank() }?.let {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaRow(
|
||||
label: String,
|
||||
value: String,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(message: String) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCost(cost: Double): String = "$" + ((cost * 100).toLong() / 100.0).toString().let { padCents(it) }
|
||||
|
||||
/** Pads a dollar string to exactly two decimals (e.g. "1.5" -> "1.50", "3" -> "3.00"). */
|
||||
private fun padCents(value: String): String {
|
||||
val dot = value.indexOf('.')
|
||||
if (dot < 0) return "$value.00"
|
||||
val decimals = value.length - dot - 1
|
||||
return when {
|
||||
decimals >= 2 -> value.substring(0, dot + 3)
|
||||
decimals == 1 -> value + "0"
|
||||
else -> value + "00"
|
||||
}
|
||||
}
|
||||
|
||||
/** Groups a token count with thin spaces every three digits (1234567 -> "1 234 567"). */
|
||||
private fun formatCount(n: Long): String {
|
||||
val s = n.toString()
|
||||
if (s.length <= 3) return s
|
||||
val sb = StringBuilder()
|
||||
val firstGroup = s.length % 3
|
||||
if (firstGroup > 0) {
|
||||
sb.append(s, 0, firstGroup)
|
||||
}
|
||||
var i = firstGroup
|
||||
while (i < s.length) {
|
||||
if (sb.isNotEmpty()) sb.append(' ')
|
||||
sb.append(s, i, i + 3)
|
||||
i += 3
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun shortKey(hex: String): String = if (hex.length <= 16) hex else hex.take(8) + "…" + hex.takeLast(8)
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.filter
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.withLock
|
||||
|
||||
/**
|
||||
* Backing ViewModel for the [AgentConsoleScreen] — the workspace owner's read-only
|
||||
* dashboard over their AI-agent fleet.
|
||||
*
|
||||
* It reads two Buzz kinds the owner authored or received:
|
||||
* - **Personas** ([PersonaEvent], `kind:30175`) — plaintext, authored by the owner.
|
||||
* - **Turn metrics** ([AgentTurnMetricEvent], `kind:44200`) — NIP-44 ciphertext an
|
||||
* agent published to the owner (`p` tag = owner). Either party can decrypt; here the
|
||||
* owner's [Account.signer] does. Decryption is cached by event id so a refresh that
|
||||
* re-reads the cache never re-runs NIP-44 on an event it already opened.
|
||||
*
|
||||
* [refresh] fetches both kinds from the Buzz-dialect relays plus the owner's outbox set,
|
||||
* lets [LocalCache] consume them, then re-derives the aggregate via the pure
|
||||
* [AgentFleetAggregator] (which owns the cost/token semantics). The ViewModel is keyed by
|
||||
* the owner pubkey in the screen, so one instance survives navigation in and out.
|
||||
*/
|
||||
class AgentConsoleViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
|
||||
/** event id -> decrypted payload (null = decryption failed; don't retry). */
|
||||
private val decryptCache = HashMap<HexKey, AgentTurnMetricPayload?>()
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
private val _metrics = MutableStateFlow(AgentFleetMetrics.EMPTY)
|
||||
val metrics: StateFlow<AgentFleetMetrics> = _metrics.asStateFlow()
|
||||
|
||||
private val _personas = MutableStateFlow<List<PersonaCard>>(emptyList())
|
||||
val personas: StateFlow<List<PersonaCard>> = _personas.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
fun bindAccountIfMissing(account: Account) {
|
||||
if (this.account != null) return
|
||||
this.account = account
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
refreshMutex.withLock {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
fetchFromRelays(account)
|
||||
reloadFromCache(account)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot paged fetch of the owner's personas (authored by owner) and turn metrics
|
||||
* (addressed to the owner via the `p` tag) from every Buzz-dialect relay and the
|
||||
* owner's own outbox relays. Events land in [LocalCache] via the normal consume path;
|
||||
* this only primes the cache before [reloadFromCache] reads it back.
|
||||
*/
|
||||
private suspend fun fetchFromRelays(account: Account) {
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val relays = BuzzRelayDialect.flow.value + account.outboxRelays.flow.value
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
val filters =
|
||||
listOf(
|
||||
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
|
||||
Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)),
|
||||
)
|
||||
|
||||
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> }
|
||||
}
|
||||
|
||||
private suspend fun reloadFromCache(account: Account) {
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val signer = account.signer
|
||||
|
||||
_personas.value =
|
||||
LocalCache.addressables
|
||||
.filter(PersonaEvent.KIND, myPubkey) { _, note -> note.event is PersonaEvent }
|
||||
.mapNotNull { note ->
|
||||
val event = note.event as? PersonaEvent ?: return@mapNotNull null
|
||||
val content = event.personaOrNull()
|
||||
PersonaCard(
|
||||
slug = event.slug() ?: "",
|
||||
displayName = content?.displayName ?: event.slug() ?: "",
|
||||
model = content?.model,
|
||||
runtime = content?.runtime,
|
||||
provider = content?.provider,
|
||||
systemPrompt = content?.systemPrompt,
|
||||
)
|
||||
}.sortedBy { it.displayName.lowercase() }
|
||||
|
||||
val metricNotes =
|
||||
LocalCache.filter(
|
||||
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
|
||||
)
|
||||
|
||||
val decrypted =
|
||||
metricNotes.mapNotNull { note ->
|
||||
val event = note.event as? AgentTurnMetricEvent ?: return@mapNotNull null
|
||||
val payload =
|
||||
if (decryptCache.containsKey(event.id)) {
|
||||
decryptCache[event.id]
|
||||
} else {
|
||||
event.decryptOrNull(signer).also { decryptCache[event.id] = it }
|
||||
} ?: return@mapNotNull null
|
||||
val agent = event.agentPubKey() ?: event.pubKey
|
||||
agent to payload
|
||||
}
|
||||
|
||||
_metrics.value = AgentFleetAggregator.aggregate(decrypted)
|
||||
}
|
||||
|
||||
/** A persona rendered on the Personas tab; a flattened projection of [PersonaEvent]. */
|
||||
@Immutable
|
||||
data class PersonaCard(
|
||||
val slug: String,
|
||||
val displayName: String,
|
||||
val model: String?,
|
||||
val runtime: String?,
|
||||
val provider: String?,
|
||||
val systemPrompt: String?,
|
||||
)
|
||||
}
|
||||
+1
@@ -104,6 +104,7 @@ fun buildSettingsCatalog(
|
||||
symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings),
|
||||
symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings),
|
||||
symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage),
|
||||
symEntry(R.string.agent_console_title, MaterialSymbols.AutoAwesome, R.string.agent_console_search_keywords, Route.AgentConsole),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1278,6 +1278,7 @@
|
||||
<string name="nest_create_submit">Start space</string>
|
||||
<string name="nest_create_schedule_toggle">Schedule for later</string>
|
||||
<string name="nest_create_when">Pick a start time</string>
|
||||
<string name="agent_console_title">Agent console</string>
|
||||
<string name="nests_servers_title">Nest servers</string>
|
||||
<string name="nests_servers_explainer">Choose which MoQ host servers Amethyst publishes your nests to. The first entry is used by default when you start a new space.</string>
|
||||
<string name="nests_servers_my_section">Your servers</string>
|
||||
@@ -2128,6 +2129,7 @@
|
||||
<string name="event_sync_search_keywords" translatable="false">negentropy, sync, reconcile, backfill</string>
|
||||
<string name="import_follows_search_keywords" translatable="false">contacts, follows, follow list, import</string>
|
||||
<string name="nests_servers_search_keywords" translatable="false">audio rooms, live, spaces, rooms</string>
|
||||
<string name="agent_console_search_keywords" translatable="false">buzz, agents, personas, cost, tokens, fleet, ai, turn metrics</string>
|
||||
<string name="profile_badges_search_keywords" translatable="false">badges, awards</string>
|
||||
<string name="favorite_dvms_search_keywords" translatable="false">dvm, data vending machine, algo, algorithm, feeds</string>
|
||||
<string name="reactions_search_keywords" translatable="false">emoji, like, reaction</string>
|
||||
|
||||
Reference in New Issue
Block a user