mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
15 Commits
c79b795477
...
12aa9fe954
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12aa9fe954 | ||
|
|
54364731a6 | ||
|
|
a0d50c69f6 | ||
|
|
5d9efc800a | ||
|
|
f869834e9d | ||
|
|
fb7bdf3057 | ||
|
|
f31e7a4c0b | ||
|
|
4a4baf6a23 | ||
|
|
bce544e8c3 | ||
|
|
0ea5d0e876 | ||
|
|
5137b92046 | ||
|
|
2124409025 | ||
|
|
1b76dd250e | ||
|
|
201aafb5bf | ||
|
|
81fa81612b |
@@ -153,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
@@ -2903,6 +2904,17 @@ class Account(
|
||||
follow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
|
||||
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
|
||||
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
|
||||
*/
|
||||
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
|
||||
if (!isWriteable()) return
|
||||
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
|
||||
client.publish(signed, setOf(channel.groupId.relayUrl))
|
||||
}
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
@@ -4666,7 +4667,13 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
|
||||
// pollute the note store, and do NOT mark the dialect from them (they are
|
||||
// never stored, so the verified-mark gate has nothing to check).
|
||||
is TypingIndicatorEvent -> false
|
||||
is TypingIndicatorEvent -> {
|
||||
// Live "who is typing" side-effect only — record the heartbeat into the
|
||||
// process-wide typing state (the channel view collects it) and return
|
||||
// false so it never becomes a feed row. Own-typing is filtered in the UI.
|
||||
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
|
||||
false
|
||||
}
|
||||
is ObserverFrameEvent -> false
|
||||
is HuddleReactionEvent -> false
|
||||
// Pairing (24134) is deliberately dialect-neutral: it flows during device
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
@@ -135,7 +137,7 @@ class AuthCoordinator(
|
||||
// Remember why we granted this relay so the settings screen can explain it.
|
||||
account.relayAuthLedger.recordGrant(context)
|
||||
try {
|
||||
signed.add(account.signer.sign(authTemplate))
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
}
|
||||
@@ -169,6 +171,27 @@ class AuthCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
|
||||
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
|
||||
* appended — so the relay grants virtual membership to an un-enrolled agent key while
|
||||
* its owner stays a member. Otherwise returns [template] unchanged.
|
||||
*
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
|
||||
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
|
||||
* add an `auth` tag where one isn't wanted.
|
||||
*/
|
||||
private fun buzzAugmented(
|
||||
template: EventTemplate<RelayAuthEvent>,
|
||||
accountPubKey: HexKey,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): EventTemplate<RelayAuthEvent> {
|
||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
|
||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||
}
|
||||
|
||||
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
|
||||
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
|
||||
|
||||
|
||||
@@ -99,6 +99,11 @@ 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.AgentAttestationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
|
||||
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
|
||||
@@ -453,9 +458,13 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AgentAttestation> { AgentAttestationScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.AgentPersonaEdit> { AgentPersonaEditScreen(it.slug, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
|
||||
@@ -752,6 +761,7 @@ fun BuildNavigation(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
|
||||
@@ -69,6 +69,7 @@ enum class NavBarItem {
|
||||
PODCASTS,
|
||||
PUBLIC_CHATS,
|
||||
RELAY_GROUPS,
|
||||
BUZZ,
|
||||
CONCORD,
|
||||
GEOHASH_CHATS,
|
||||
FOLLOW_PACKS,
|
||||
@@ -344,6 +345,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Forum,
|
||||
resolveRoute = { Route.RelayGroups },
|
||||
),
|
||||
NavBarItem.BUZZ to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.BUZZ,
|
||||
labelRes = R.string.buzz_workspaces_title,
|
||||
icon = MaterialSymbols.AutoAwesome,
|
||||
resolveRoute = { Route.BuzzWorkspaces },
|
||||
),
|
||||
NavBarItem.CONCORD to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.CONCORD,
|
||||
@@ -496,6 +504,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
listOf(
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.BUZZ,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
),
|
||||
|
||||
@@ -469,6 +469,14 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditNestsServers : Route()
|
||||
|
||||
@Serializable object AgentConsole : Route()
|
||||
|
||||
@Serializable object AgentAttestation : Route()
|
||||
|
||||
@Serializable data class AgentPersonaEdit(
|
||||
val slug: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
@@ -691,6 +699,10 @@ sealed class Route {
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BuzzCanvas(
|
||||
val channelId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupCreate(
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
@@ -702,6 +714,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object RelayGroups : Route()
|
||||
|
||||
@Serializable object BuzzWorkspaces : Route()
|
||||
|
||||
@Serializable object RelayGroupBrowse : Route()
|
||||
|
||||
// Concord Channels (encrypted communities). Addressed by community id + channel id
|
||||
|
||||
@@ -685,6 +685,11 @@ class AccountViewModel(
|
||||
account.sendConcordTyping(communityId, channelIdHex)
|
||||
}
|
||||
|
||||
fun sendBuzzTyping(channel: RelayGroupChannel) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendBuzzTyping(channel)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class NoteComposeReportState(
|
||||
val isPostHidden: Boolean = false,
|
||||
|
||||
@@ -137,6 +137,10 @@ private fun PreloadFor(
|
||||
// this tab needs no extra preload.
|
||||
NavBarItem.RELAY_GROUPS -> Unit
|
||||
|
||||
// Buzz workspaces are relay groups on Buzz-dialect relays; they ride the same
|
||||
// always-on relay-group state subscription, so there's nothing extra to preload.
|
||||
NavBarItem.BUZZ -> Unit
|
||||
|
||||
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
|
||||
|
||||
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* 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.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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
|
||||
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
|
||||
* [AttestationConditions]; the agent then attaches the resulting `auth` tag to its
|
||||
* events and the relay grants it virtual membership while the owner stays a member.
|
||||
*
|
||||
* This is entirely offline: the signature covers a hashed commitment string, not a
|
||||
* Nostr event, so it needs the owner's **raw private key** — a NIP-46 bunker or NIP-55
|
||||
* external signer cannot produce it. Read-only accounts see an explanation instead of
|
||||
* the form. Nothing is published; the signed tag is a credential the owner hands to the
|
||||
* agent operator out-of-band.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentAttestationScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val keyPair = accountViewModel.account.settings.keyPair
|
||||
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Attestations", nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Agent side: hold an attestation an owner gave you, so this account
|
||||
// authenticates to the owner's Buzz relays as a virtual member. Available to
|
||||
// any signer — holding a credential doesn't require the raw key.
|
||||
HoldAttestationSection(myPubkey = myPubkey)
|
||||
|
||||
// Owner side: issue an attestation for an agent key. Needs the raw private key.
|
||||
val privKey = keyPair.privKey
|
||||
if (privKey == null) {
|
||||
ReadOnlyKeyNotice()
|
||||
} else {
|
||||
AttestationForm(ownerKey = keyPair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
|
||||
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
|
||||
* now — re-paste after an app restart.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldAttestationSection(myPubkey: String) {
|
||||
val held by BuzzHeldAttestations.flow.collectAsState()
|
||||
val mine = held[myPubkey]
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Hold an attestation",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (mine != null) {
|
||||
Text(
|
||||
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
|
||||
Text("Remove")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = {
|
||||
input = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("auth tag JSON") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = parseHeldAttestation(input, myPubkey)) {
|
||||
is HoldOutcome.Failure -> error = outcome.message
|
||||
is HoldOutcome.Success -> {
|
||||
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
|
||||
input = ""
|
||||
error = null
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Hold attestation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface HoldOutcome {
|
||||
data class Success(
|
||||
val attestation: OwnerAttestation,
|
||||
) : HoldOutcome
|
||||
|
||||
data class Failure(
|
||||
val message: String,
|
||||
) : HoldOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
|
||||
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
|
||||
* non-`auth` tag, or a signature that doesn't verify for this key.
|
||||
*/
|
||||
private fun parseHeldAttestation(
|
||||
input: String,
|
||||
myPubkey: String,
|
||||
): HoldOutcome {
|
||||
val tag =
|
||||
try {
|
||||
Json
|
||||
.parseToJsonElement(input.trim())
|
||||
.jsonArray
|
||||
.map { it.jsonPrimitive.content }
|
||||
.toTypedArray()
|
||||
} catch (e: Exception) {
|
||||
return HoldOutcome.Failure("Not a valid JSON tag array. Paste the [\"auth\", …] tag you were given.")
|
||||
}
|
||||
val attestation =
|
||||
OwnerAttestation.parse(tag)
|
||||
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
|
||||
if (!attestation.verify(myPubkey)) {
|
||||
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
|
||||
}
|
||||
return HoldOutcome.Success(attestation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadOnlyKeyNotice() {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Local key required",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " +
|
||||
"so it can only be produced by a signer that holds your raw private key. This " +
|
||||
"account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " +
|
||||
"sign an attestation.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationForm(ownerKey: KeyPair) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var agentInput by remember { mutableStateOf("") }
|
||||
var kindInput by remember { mutableStateOf("") }
|
||||
var afterInput by remember { mutableStateOf("") }
|
||||
var beforeInput by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var result by remember { mutableStateOf<OwnerAttestation?>(null) }
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Authorize an agent pubkey to publish in your workspace without enrolling its key. " +
|
||||
"The agent attaches the signed tag below to its events.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = agentInput,
|
||||
onValueChange = {
|
||||
agentInput = it
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Agent public key (npub or hex)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Conditions (optional) — leave blank for an unrestricted attestation.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = kindInput,
|
||||
onValueChange = {
|
||||
kindInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Restrict to kind (0–65535)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = afterInput,
|
||||
onValueChange = {
|
||||
afterInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events after (unix seconds)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = beforeInput,
|
||||
onValueChange = {
|
||||
beforeInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events before (unix seconds)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
|
||||
is AttestationOutcome.Failure -> {
|
||||
error = outcome.message
|
||||
result = null
|
||||
}
|
||||
is AttestationOutcome.Success -> {
|
||||
error = null
|
||||
result = outcome.attestation
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = agentInput.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Generate attestation")
|
||||
}
|
||||
|
||||
result?.let { attestation ->
|
||||
AttestationResultCard(
|
||||
attestation = attestation,
|
||||
onCopy = { scope.launch { clipboard.setText(attestation.toTagJson()) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationResultCard(
|
||||
attestation: OwnerAttestation,
|
||||
onCopy: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Signed attestation",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"Grants: " +
|
||||
(attestation.conditions.ifEmpty { "any kind, any time (unrestricted)" }),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = attestation.toTagJson(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(onClick = onCopy) {
|
||||
Text("Copy tag")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
"⚠ Hand this to the agent operator only. While it is valid and you remain a " +
|
||||
"workspace member, the relay lets this agent post as a member under the " +
|
||||
"conditions above.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface AttestationOutcome {
|
||||
data class Success(
|
||||
val attestation: OwnerAttestation,
|
||||
) : AttestationOutcome
|
||||
|
||||
data class Failure(
|
||||
val message: String,
|
||||
) : AttestationOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form inputs and signs an [OwnerAttestation], or returns a
|
||||
* human-readable failure. Kept out of composition so the signing (raw-key) path is a
|
||||
* plain function.
|
||||
*/
|
||||
private fun buildAttestation(
|
||||
agentInput: String,
|
||||
kindInput: String,
|
||||
afterInput: String,
|
||||
beforeInput: String,
|
||||
ownerKey: KeyPair,
|
||||
): AttestationOutcome {
|
||||
val agentHex =
|
||||
decodePublicKeyAsHexOrNull(agentInput.trim())
|
||||
?: return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
|
||||
if (!agentHex.isValid()) {
|
||||
return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
|
||||
}
|
||||
|
||||
val kind =
|
||||
if (kindInput.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val parsed = kindInput.toIntOrNull()
|
||||
if (parsed == null || parsed !in 0..65535) {
|
||||
return AttestationOutcome.Failure("Kind must be between 0 and 65535.")
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
val after = parseOptionalUnix(afterInput) ?: return AttestationOutcome.Failure("\"After\" must be a unix time in 0–4294967295.")
|
||||
val before = parseOptionalUnix(beforeInput) ?: return AttestationOutcome.Failure("\"Before\" must be a unix time in 0–4294967295.")
|
||||
|
||||
val conditions = AttestationConditions(kind = kind, createdAtBefore = before.value, createdAtAfter = after.value)
|
||||
|
||||
return try {
|
||||
AttestationOutcome.Success(OwnerAttestation.sign(agentHex, conditions, ownerKey))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
AttestationOutcome.Failure(e.message ?: "Could not sign the attestation.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps a nullable parse so "absent" and "invalid" are distinguishable from the caller. */
|
||||
private class OptionalUnix(
|
||||
val value: Long?,
|
||||
)
|
||||
|
||||
private fun parseOptionalUnix(input: String): OptionalUnix? {
|
||||
if (input.isBlank()) return OptionalUnix(null)
|
||||
val parsed = input.toLongOrNull() ?: return null
|
||||
if (parsed !in 0..4294967295L) return null
|
||||
return OptionalUnix(parsed)
|
||||
}
|
||||
|
||||
/** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */
|
||||
private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* 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.clickable
|
||||
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.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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.DisposableEffect
|
||||
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.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
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.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* The workspace owner's agent-owner console — a dashboard over the fleet of AI agents
|
||||
* that publish to them. Three 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`).
|
||||
* - **Observer** — a live stream of ephemeral NIP-AO telemetry frames (`kind:24200`).
|
||||
*
|
||||
* A "Attest" FAB opens [AgentAttestationScreen] to issue a NIP-OA authorization for an
|
||||
* agent key. Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the
|
||||
* fetch/decrypt work survives navigation. The tabs are read-only; persona editing is a
|
||||
* follow-up.
|
||||
*/
|
||||
@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 observerFrames by viewModel.observerFrames.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val tabs = remember { listOf("Costs", "Personas", "Observer") }
|
||||
|
||||
// The observer stream is a live REQ; only keep it open while its tab is on screen.
|
||||
if (selectedTab == 2) {
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startObserving()
|
||||
onDispose { viewModel.stopObserving() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Agent Console", nav) },
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text("Attest") },
|
||||
icon = { Icon(symbol = MaterialSymbols.Key, contentDescription = null) },
|
||||
onClick = { nav.nav(Route.AgentAttestation) },
|
||||
)
|
||||
},
|
||||
) { 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)
|
||||
1 -> PersonasTab(personas, nav)
|
||||
else -> ObserverTab(observerFrames)
|
||||
}
|
||||
|
||||
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>,
|
||||
nav: INav,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = { nav.nav(Route.AgentPersonaEdit()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.AddCircle, contentDescription = null)
|
||||
Text(" New persona")
|
||||
}
|
||||
}
|
||||
if (personas.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No personas published yet. Create one to define an agent (kind:30175).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(personas) { persona ->
|
||||
PersonaCardView(persona) { nav.nav(Route.AgentPersonaEdit(persona.slug)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaCardView(
|
||||
persona: AgentConsoleViewModel.PersonaCard,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
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 ObserverTab(frames: List<AgentConsoleViewModel.ObserverRow>) {
|
||||
if (frames.isEmpty()) {
|
||||
EmptyState("Listening for live agent telemetry (kind:24200). Frames appear here while your agents are running.")
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(frames) { frame ->
|
||||
ObserverFrameRow(frame)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserverFrameRow(frame: AgentConsoleViewModel.ObserverRow) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = frame.kind,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = frame.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
val context =
|
||||
buildString {
|
||||
append(shortKey(frame.agentPubKey))
|
||||
frame.sessionId?.let { append(" · session ${it.take(8)}") }
|
||||
frame.turnId?.let { append(" · turn ${it.take(8)}") }
|
||||
append(" · #${frame.seq}")
|
||||
}
|
||||
Text(
|
||||
text = context,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.aoObserver.ObserverFrameEvent
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
|
||||
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.client.reqs.subscribeAsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
private val _observerFrames = MutableStateFlow<List<ObserverRow>>(emptyList())
|
||||
val observerFrames: StateFlow<List<ObserverRow>> = _observerFrames.asStateFlow()
|
||||
|
||||
private var observerJob: Job? = null
|
||||
|
||||
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
|
||||
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a live subscription to the owner's ephemeral observer telemetry frames
|
||||
* ([ObserverFrameEvent], `kind:24200`, `p` = owner) across every Buzz-dialect relay,
|
||||
* decrypts the `frame:telemetry` bodies, and pushes them newest-first into
|
||||
* [observerFrames] (bounded to [MAX_OBSERVER_ROWS]). Idempotent; call [stopObserving]
|
||||
* to tear the subscription down. Observer frames are never stored by relays or
|
||||
* [LocalCache], so this live REQ is the only way to see them.
|
||||
*/
|
||||
fun startObserving() {
|
||||
val account = account ?: return
|
||||
if (observerJob != null) return
|
||||
|
||||
val relays = BuzzRelayDialect.flow.value
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
val signer = account.signer
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
|
||||
|
||||
observerJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
account.client.subscribeAsFlow(relay, filter).collect { events ->
|
||||
val fresh =
|
||||
events
|
||||
.filterIsInstance<ObserverFrameEvent>()
|
||||
.filter { observerSeen.add(it.id) }
|
||||
if (fresh.isEmpty()) return@collect
|
||||
|
||||
val rows =
|
||||
fresh.mapNotNull { frame ->
|
||||
if (frame.frame() != FrameTag.TELEMETRY) return@mapNotNull null
|
||||
val payload = frame.decryptTelemetryOrNull(signer) ?: return@mapNotNull null
|
||||
ObserverRow(
|
||||
seq = payload.seq,
|
||||
timestamp = payload.timestamp,
|
||||
kind = payload.kind,
|
||||
agentPubKey = frame.agentPubKey() ?: frame.pubKey,
|
||||
sessionId = payload.sessionId,
|
||||
turnId = payload.turnId,
|
||||
)
|
||||
}
|
||||
if (rows.isNotEmpty()) {
|
||||
_observerFrames.update { existing ->
|
||||
(rows + existing)
|
||||
.sortedByDescending { it.timestamp }
|
||||
.take(MAX_OBSERVER_ROWS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopObserving() {
|
||||
observerJob?.cancel()
|
||||
observerJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopObserving()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/** One decrypted observer telemetry frame rendered on the Observer tab. */
|
||||
@Immutable
|
||||
data class ObserverRow(
|
||||
val seq: Long,
|
||||
val timestamp: String,
|
||||
val kind: String,
|
||||
val agentPubKey: HexKey,
|
||||
val sessionId: String?,
|
||||
val turnId: String?,
|
||||
)
|
||||
|
||||
/** 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?,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Ring size for the ephemeral observer stream — enough to scroll recent activity. */
|
||||
const val MAX_OBSERVER_ROWS = 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona;
|
||||
* otherwise edits the existing one (slug locked). Publishes to the workspace's Buzz relays
|
||||
* on save and pops back. Backed by [AgentPersonaEditViewModel], keyed by owner+slug so an
|
||||
* in-progress edit survives recomposition.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentPersonaEditScreen(
|
||||
slug: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
val viewModel: AgentPersonaEditViewModel = viewModel(key = "PersonaEdit-$pubkey-${slug ?: "new"}")
|
||||
viewModel.bind(accountViewModel.account, slug)
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val title = if (slug == null) "New persona" else "Edit persona"
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(title, nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.slug,
|
||||
onValueChange = viewModel::onSlugChange,
|
||||
label = { Text("Slug (persona id)") },
|
||||
singleLine = true,
|
||||
enabled = !state.slugLocked,
|
||||
supportingText = { Text("a-z, 0-9, '-' or '_'. Cannot change after creation.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.displayName,
|
||||
onValueChange = viewModel::onDisplayNameChange,
|
||||
label = { Text("Display name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.systemPrompt,
|
||||
onValueChange = viewModel::onSystemPromptChange,
|
||||
label = { Text("System prompt") },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.model,
|
||||
onValueChange = viewModel::onModelChange,
|
||||
label = { Text("Model (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.provider,
|
||||
onValueChange = viewModel::onProviderChange,
|
||||
label = { Text("Provider (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.runtime,
|
||||
onValueChange = viewModel::onRuntimeChange,
|
||||
label = { Text("Runtime (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.avatarUrl,
|
||||
onValueChange = viewModel::onAvatarUrlChange,
|
||||
label = { Text("Avatar URL (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
state.error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.save(onDone = nav::popBack) },
|
||||
enabled = state.canSave,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (state.isSaving) "Publishing…" else "Publish persona")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaContent
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Backing ViewModel for [AgentPersonaEditScreen] — creates or edits a Buzz Agent Persona
|
||||
* (NIP-AP `kind:30175`), a world-readable addressable event authored by the workspace
|
||||
* owner and addressed by `(owner, 30175, slug)`.
|
||||
*
|
||||
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
|
||||
* not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather
|
||||
* than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a
|
||||
* different slug is a different persona.
|
||||
*
|
||||
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
|
||||
* none are known), so the workspace and other members see it.
|
||||
*/
|
||||
class AgentPersonaEditViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
|
||||
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
|
||||
private var existing: PersonaContent? = null
|
||||
private var editingSlug: String? = null
|
||||
|
||||
private val _state = MutableStateFlow(FormState())
|
||||
val state: StateFlow<FormState> = _state.asStateFlow()
|
||||
|
||||
/** Binds the account and, when [slug] names an existing persona, seeds the form from it. */
|
||||
fun bind(
|
||||
account: Account,
|
||||
slug: String?,
|
||||
) {
|
||||
if (this.account != null) return
|
||||
this.account = account
|
||||
|
||||
if (slug.isNullOrBlank()) return
|
||||
val note = LocalCache.getAddressableNoteIfExists(Address(PersonaEvent.KIND, account.userProfile().pubkeyHex, slug))
|
||||
val event = note?.event as? PersonaEvent ?: return
|
||||
val content = event.personaOrNull() ?: return
|
||||
existing = content
|
||||
editingSlug = slug
|
||||
_state.value =
|
||||
FormState(
|
||||
slug = slug,
|
||||
slugLocked = true,
|
||||
displayName = content.displayName,
|
||||
systemPrompt = content.systemPrompt.orEmpty(),
|
||||
model = content.model.orEmpty(),
|
||||
runtime = content.runtime.orEmpty(),
|
||||
provider = content.provider.orEmpty(),
|
||||
avatarUrl = content.avatarUrl.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun onSlugChange(v: String) = _state.update { it.copy(slug = v.trim(), error = null) }
|
||||
|
||||
fun onDisplayNameChange(v: String) = _state.update { it.copy(displayName = v, error = null) }
|
||||
|
||||
fun onSystemPromptChange(v: String) = _state.update { it.copy(systemPrompt = v, error = null) }
|
||||
|
||||
fun onModelChange(v: String) = _state.update { it.copy(model = v.trim(), error = null) }
|
||||
|
||||
fun onRuntimeChange(v: String) = _state.update { it.copy(runtime = v.trim(), error = null) }
|
||||
|
||||
fun onProviderChange(v: String) = _state.update { it.copy(provider = v.trim(), error = null) }
|
||||
|
||||
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
|
||||
|
||||
/**
|
||||
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
|
||||
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
|
||||
*/
|
||||
fun save(onDone: () -> Unit) {
|
||||
val account = account ?: return
|
||||
val current = _state.value
|
||||
|
||||
val slug = current.slug.trim()
|
||||
if (!isValidSlug(slug)) {
|
||||
_state.update { it.copy(error = "Slug must match a-z, 0-9, '-' or '_' (max 64 chars).") }
|
||||
return
|
||||
}
|
||||
if (current.displayName.isBlank()) {
|
||||
_state.update { it.copy(error = "Display name is required.") }
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(isSaving = true, error = null) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base = existing ?: PersonaContent(displayName = current.displayName)
|
||||
val content =
|
||||
base.copy(
|
||||
displayName = current.displayName.trim(),
|
||||
systemPrompt = current.systemPrompt.blankToNull(),
|
||||
model = current.model.blankToNull(),
|
||||
runtime = current.runtime.blankToNull(),
|
||||
provider = current.provider.blankToNull(),
|
||||
avatarUrl = current.avatarUrl.blankToNull(),
|
||||
)
|
||||
|
||||
val template = PersonaEvent.build(content, slug)
|
||||
account.signAndSendPrivatelyOrBroadcast(template) {
|
||||
BuzzRelayDialect.flow.value
|
||||
.toList()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) { onDone() }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
_state.update { it.copy(isSaving = false, error = "Failed to publish: ${e.message ?: e::class.simpleName}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.blankToNull(): String? = trim().takeIf { it.isNotBlank() }
|
||||
|
||||
data class FormState(
|
||||
val slug: String = "",
|
||||
/** True when editing an existing persona — the slug (address `d` tag) can't change. */
|
||||
val slugLocked: Boolean = false,
|
||||
val displayName: String = "",
|
||||
val systemPrompt: String = "",
|
||||
val model: String = "",
|
||||
val runtime: String = "",
|
||||
val provider: String = "",
|
||||
val avatarUrl: String = "",
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val canSave: Boolean get() = slug.isNotBlank() && displayName.isNotBlank() && !isSaving
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
/** The persona slug grammar enforced by the relay (`validate_persona_envelope`). */
|
||||
fun isValidSlug(slug: String): Boolean = SLUG_REGEX.matches(slug)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Renders a Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown
|
||||
* document for a channel. The canvas is overlay state held in [BuzzWorkspaceStates]
|
||||
* (never a timeline row — a workspace has one live canvas, last-write-wins), so this
|
||||
* reads the registry by the channel's `h` id and re-composes off `canvasUpdates` when a
|
||||
* newer revision lands.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzCanvasScreen(
|
||||
channelId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
|
||||
// Re-read canvasNote whenever a newer canvas is consumed.
|
||||
val version by state.canvasUpdates.collectAsStateWithLifecycle()
|
||||
val canvas = remember(version) { state.canvasNote }
|
||||
val content = canvas?.event?.content
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
|
||||
) { padding ->
|
||||
if (content.isNullOrBlank() || canvas == null) {
|
||||
Box(
|
||||
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_canvas_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val bgColor = remember { mutableStateOf(Color.Transparent) }
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = content,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier,
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = bgColor,
|
||||
id = canvas.idHex,
|
||||
callbackUri = canvas.toNostrUri(),
|
||||
authorPubKey = canvas.author?.pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* 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.background
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* The **Workspaces** tab — a first-class navigation home for `block/buzz` workspaces,
|
||||
* giving Buzz a distinct identity instead of hiding inside the generic relay-group list.
|
||||
* A Buzz workspace is a NIP-29 group on a Buzz-dialect relay, so this filters the user's
|
||||
* joined groups to those relays ([BuzzRelayDialect]) and adds the Buzz-only surfaces (the
|
||||
* Agent Console) up top.
|
||||
*
|
||||
* Read-only aggregation: it reuses the always-on relay-group state subscription (mounted
|
||||
* at login), so no per-screen fetch is needed — it just projects the joined set.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzWorkspacesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||
.collectAsStateWithLifecycle()
|
||||
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
|
||||
|
||||
val workspaces =
|
||||
remember(joined, buzzRelays) {
|
||||
joined
|
||||
.mapNotNull { tag ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
|
||||
if (relay !in buzzRelays) return@mapNotNull null
|
||||
GroupId(tag.groupId, relay)
|
||||
}.sortedBy { it.id }
|
||||
}
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.BuzzWorkspaces, nav, accountViewModel) { route ->
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
|
||||
|
||||
if (workspaces.isEmpty()) {
|
||||
item { EmptyWorkspaces(onBrowse = { nav.nav(Route.RelayGroups) }) }
|
||||
} else {
|
||||
item {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
|
||||
WorkspaceRow(groupId, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bold accent card leading to the Buzz-only Agent Console — the tab's signature surface. */
|
||||
@Composable
|
||||
private fun AgentConsoleHeroCard(onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_console_card_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_console_card_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
|
||||
@Composable
|
||||
private fun WorkspaceRow(
|
||||
groupId: GroupId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val hasUnread by remember(groupId) {
|
||||
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
|
||||
}.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
val name = channel.toBestDisplayName()
|
||||
val memberCount = channel.memberCount()
|
||||
|
||||
Card(
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WorkspaceAvatar(name = name, seed = groupId.id)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = groupId.relayUrl.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (memberCount > 0) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Group,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Text(
|
||||
text = "$memberCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (hasUnread) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A round monogram whose color is derived deterministically from the workspace id. */
|
||||
@Composable
|
||||
private fun WorkspaceAvatar(
|
||||
name: String,
|
||||
seed: String,
|
||||
) {
|
||||
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
|
||||
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
|
||||
val initial =
|
||||
remember(name) {
|
||||
name
|
||||
.trim()
|
||||
.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.toString() ?: "#"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(42.dp).clip(CircleShape).background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Inviting empty state: what a Buzz workspace is + a way to go find one. */
|
||||
@Composable
|
||||
private fun EmptyWorkspaces(onBrowse: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.size(2.dp))
|
||||
FilledTonalButton(onClick = onBrowse) {
|
||||
Text(stringRes(R.string.buzz_workspaces_browse))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ fun RefreshingChatroomFeedView(
|
||||
// callers with no external jump affordance.
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
|
||||
listStateObserver(listState)
|
||||
@@ -97,6 +98,7 @@ fun RefreshingChatroomFeedView(
|
||||
sentinels,
|
||||
jumpToNoteId,
|
||||
onJumpHandled,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ fun RenderChatFeedView(
|
||||
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
val feedState by feed.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -148,6 +151,7 @@ fun RenderChatFeedView(
|
||||
sentinels,
|
||||
jumpToNoteId,
|
||||
onJumpHandled,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -169,6 +173,7 @@ fun ChatFeedLoaded(
|
||||
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -263,6 +268,7 @@ fun ChatFeedLoaded(
|
||||
onHighlightFinished = { highlightedNoteId.value = null },
|
||||
groupPosition = watchChatGroupPosition(newer, item, older),
|
||||
previousNoteId = older?.idHex,
|
||||
onWantsToEditBuzz = onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -119,6 +120,7 @@ fun ChatMessageActionSheet(
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
var showShareSheet by remember { mutableStateOf(false) }
|
||||
var wantsToEditPost by remember { mutableStateOf(false) }
|
||||
@@ -265,6 +267,23 @@ fun ChatMessageActionSheet(
|
||||
// Stage one: the primary chat action (reply / edit draft) is always shown.
|
||||
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
||||
|
||||
// Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit).
|
||||
// A 40002 event is inherently a Buzz message, so the type alone is the gate;
|
||||
// authorship restricts it to my own messages.
|
||||
val canEditBuzz =
|
||||
onWantsToEditBuzz != null &&
|
||||
note.event is StreamMessageV2Event &&
|
||||
note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||
if (canEditBuzz) {
|
||||
SectionDivider()
|
||||
TileRow {
|
||||
ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) {
|
||||
onWantsToEditBuzz!!(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val handlers =
|
||||
NoteActionHandlers(
|
||||
onShare = { showShareSheet = true },
|
||||
|
||||
@@ -56,7 +56,10 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||
@@ -67,9 +70,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncr
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
@@ -108,6 +114,9 @@ fun ChatroomMessageCompose(
|
||||
// reply quotes inside a DM, where the target is simply older than the loaded window (see
|
||||
// LoadingReplyNote). Null keeps the default blank for every other caller.
|
||||
onBlank: (@Composable () -> Unit)? = null,
|
||||
// Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for
|
||||
// every non-Buzz chat surface, which hides the action.
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
// Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted
|
||||
// chat message renders with the chat reply design instead of the quoted-note card.
|
||||
@@ -136,6 +145,16 @@ fun ChatroomMessageCompose(
|
||||
} else if (event is SystemMessageEvent) {
|
||||
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
|
||||
RenderBuzzSystemMessage(baseNote)
|
||||
} else if (event is StreamMessageDiffEvent) {
|
||||
// Buzz kind-40008: a code/text diff pushed into the channel.
|
||||
RenderBuzzDiff(baseNote)
|
||||
} else if (event is ForumVoteEvent) {
|
||||
// Buzz kind-45002: a forum up/down vote.
|
||||
RenderBuzzForumVote(baseNote)
|
||||
} else if (isBuzzActivityRow(event)) {
|
||||
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
|
||||
// especially must be caught here — their content is JSON, not chat text.
|
||||
RenderBuzzActivityRow(baseNote)
|
||||
} else {
|
||||
NormalChatNote(
|
||||
baseNote,
|
||||
@@ -152,6 +171,7 @@ fun ChatroomMessageCompose(
|
||||
onHighlightFinished,
|
||||
groupPosition,
|
||||
previousNoteId,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +202,7 @@ fun NormalChatNote(
|
||||
onHighlightFinished: (() -> Unit)? = null,
|
||||
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
|
||||
previousNoteId: HexKey? = null,
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
// A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as
|
||||
// self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment,
|
||||
@@ -312,6 +333,7 @@ fun NormalChatNote(
|
||||
onDismiss = onDismiss,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToEditBuzz = onWantsToEditBuzz,
|
||||
)
|
||||
},
|
||||
reactionsRow =
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -30,6 +35,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
@@ -41,7 +49,20 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
|
||||
/**
|
||||
@@ -131,3 +152,117 @@ fun RenderBuzzSystemMessage(note: Note) {
|
||||
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
|
||||
* narrates as a centered system line rather than a chat bubble. Huddle events in
|
||||
* particular MUST be caught here — their `content` is JSON, so rendering them as a plain
|
||||
* chat message would show raw `{"ephemeral_channel_id":…}`.
|
||||
*/
|
||||
fun isBuzzActivityRow(event: Event?): Boolean =
|
||||
event is JobRequestEvent ||
|
||||
event is JobAcceptedEvent ||
|
||||
event is JobProgressEvent ||
|
||||
event is JobResultEvent ||
|
||||
event is JobCancelEvent ||
|
||||
event is JobErrorEvent ||
|
||||
event is HuddleStartedEvent ||
|
||||
event is HuddleParticipantJoinedEvent ||
|
||||
event is HuddleParticipantLeftEvent ||
|
||||
event is HuddleEndedEvent
|
||||
|
||||
/**
|
||||
* Narrates a Buzz agent-job or huddle lifecycle event as a centered system line. The
|
||||
* label is derived from the kind; job progress/result/error also append a short content
|
||||
* snippet (the human-readable status/result/error the agent wrote).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzActivityRow(note: Note) {
|
||||
val event = note.event ?: return
|
||||
val text =
|
||||
remember(event) {
|
||||
when (event) {
|
||||
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
|
||||
is JobAcceptedEvent -> "⚙ job accepted"
|
||||
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
|
||||
is JobResultEvent -> "⚙ job result" + event.result().snippet()
|
||||
is JobCancelEvent -> "⚙ job cancelled"
|
||||
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
|
||||
is HuddleStartedEvent -> "🔊 huddle started"
|
||||
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
|
||||
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
|
||||
is HuddleEndedEvent -> "🔊 huddle ended"
|
||||
else -> event.content.take(120)
|
||||
}
|
||||
}
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
|
||||
private fun String.snippet(): String {
|
||||
val oneLine = trim().replace('\n', ' ')
|
||||
if (oneLine.isEmpty()) return ""
|
||||
return ": " + if (oneLine.length > 80) oneLine.take(80) + "…" else oneLine
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz kind-40008 stream diff: a code/text diff pushed into the channel. Renders the
|
||||
* repo/commit/file header from the `DiffMeta` tags plus the diff body in a monospace,
|
||||
* horizontally-scrollable block, rather than the plain-text fallback (which mangles the
|
||||
* `+`/`-` gutters into wrapped prose).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzDiff(note: Note) {
|
||||
val event = note.event as? StreamMessageDiffEvent ?: return
|
||||
val meta = remember(event) { event.diffMeta() }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
val header =
|
||||
remember(meta) {
|
||||
meta?.let {
|
||||
buildString {
|
||||
it.filePath?.let(::append)
|
||||
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
|
||||
it.prNumber?.let { pr -> append(" · PR #$pr") }
|
||||
it.commitSha
|
||||
?.takeIf { sha -> sha.isNotBlank() }
|
||||
?.let { sha -> append(" · ${sha.take(8)}") }
|
||||
}.ifBlank { null }
|
||||
}
|
||||
}
|
||||
if (header != null) {
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
if (meta?.truncated == true) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_diff_truncated),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
|
||||
@Composable
|
||||
fun RenderBuzzForumVote(note: Note) {
|
||||
val event = note.event as? ForumVoteEvent ?: return
|
||||
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
|
||||
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A live "… is typing" row for a Buzz workspace channel, driven by the ephemeral
|
||||
* kind-20002 heartbeats collected into [BuzzTypingState]. Placed just above the composer,
|
||||
* it slides in when someone starts typing and out when they stop (heartbeats age out of
|
||||
* the freshness window). Own typing is filtered by [myPubkey].
|
||||
*
|
||||
* The three animated dots are a small modern flourish; the wording collapses to a count
|
||||
* once more than two people type at once so the row never overflows.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzTypingIndicator(
|
||||
channelId: String,
|
||||
myPubkey: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val typingByChannel by BuzzTypingState.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-evaluate on a light timer so typists fade out when their heartbeats go stale,
|
||||
// but only while this channel actually has any — an idle channel never wakes the loop.
|
||||
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
|
||||
val raw = typingByChannel[channelId]
|
||||
LaunchedEffect(channelId, raw) {
|
||||
if (raw.isNullOrEmpty()) return@LaunchedEffect
|
||||
while (true) {
|
||||
nowSecs = TimeUtils.now()
|
||||
if (raw.values.none { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }) break
|
||||
delay(2000L)
|
||||
}
|
||||
}
|
||||
|
||||
val typers =
|
||||
remember(raw, myPubkey, nowSecs) {
|
||||
(raw ?: emptyMap())
|
||||
.filterKeys { it != myPubkey }
|
||||
.filterValues { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }
|
||||
.keys
|
||||
.sorted()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = typers.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TypingDots()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = typingLabel(typers, accountViewModel),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun typingLabel(
|
||||
typers: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String =
|
||||
when (typers.size) {
|
||||
1 -> stringRes(R.string.buzz_typing_one, rememberTypistName(typers[0], accountViewModel))
|
||||
2 ->
|
||||
stringRes(
|
||||
R.string.buzz_typing_two,
|
||||
rememberTypistName(typers[0], accountViewModel),
|
||||
rememberTypistName(typers[1], accountViewModel),
|
||||
)
|
||||
else -> stringRes(R.string.buzz_typing_many)
|
||||
}
|
||||
|
||||
/** Three dots that bounce in a staggered wave — a lightweight, always-on micro-animation. */
|
||||
@Composable
|
||||
private fun TypingDots() {
|
||||
val transition = rememberInfiniteTransition(label = "buzz-typing")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(3) { index ->
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 900, delayMillis = index * 150),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "dot-$index",
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(5.dp)
|
||||
.graphicsLayer {
|
||||
translationY = -3f * phase
|
||||
val s = 0.7f + 0.3f * phase
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
}.alpha(0.4f + 0.6f * phase)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
|
||||
@Composable
|
||||
private fun rememberTypistName(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String {
|
||||
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
|
||||
val info by observeUserInfo(user, accountViewModel)
|
||||
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
@@ -216,6 +217,7 @@ private fun ChannelView(
|
||||
avoidDraft = newPostModel.draftTag,
|
||||
onWantsToReply = newPostModel::reply,
|
||||
onWantsToEditDraft = newPostModel::editFromDraft,
|
||||
onWantsToEditBuzz = newPostModel::editBuzzMessage,
|
||||
jumpToNoteId = jumpToNoteId,
|
||||
onJumpHandled = { jumpToNoteId.value = null },
|
||||
// A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry.
|
||||
@@ -251,6 +253,17 @@ private fun ChannelView(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz workspaces surface a live "… is typing" row just above the composer, fed by
|
||||
// ephemeral kind-20002 heartbeats. Only rendered on a Buzz-dialect relay (the kind is
|
||||
// Buzz-only); a no-op otherwise.
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
BuzzTypingIndicator(
|
||||
channelId = channel.groupId.id,
|
||||
myPubkey = accountViewModel.userProfile().pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
// NIP-29 relays reject writes from non-members, so only show the composer when the
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
@@ -50,6 +51,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
@@ -164,6 +167,24 @@ fun RelayGroupTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz workspace canvas (kind 40100): shown only on a Buzz-dialect relay once
|
||||
// a canvas has arrived for this channel. Observing canvasUpdates flips it on
|
||||
// the moment the first canvas is consumed, without swapping the channel object.
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
val canvasState = remember(channel.groupId.id) { BuzzWorkspaceStates.getOrCreate(channel.groupId.id) }
|
||||
val canvasVersion by canvasState.canvasUpdates.collectAsState()
|
||||
val hasCanvas = remember(canvasVersion) { canvasState.canvasNote != null }
|
||||
if (hasCanvas) {
|
||||
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id)) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dashboard,
|
||||
contentDescription = stringRes(R.string.buzz_canvas_title),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val naddr = channel.toNAddr()
|
||||
if (naddr != null) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -20,6 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
@@ -91,12 +106,21 @@ val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
/**
|
||||
* Extra timeline kinds a `block/buzz` workspace relay serves in the same `h`-scoped
|
||||
* channels: stream messages v2 (40002), edits (40003), diffs (40008) and system rows
|
||||
* (40099). Requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
|
||||
* channels, requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
|
||||
* kinds simply match nothing. A dialect-gated version was tried and reverted: gating
|
||||
* creates a bootstrap hole (nothing asks for a Buzz kind until one is consumed) and a
|
||||
* worse one — history pages fetched before the mark advance their cursors past ranges
|
||||
* queried WITHOUT Buzz kinds, permanently skipping older workspace messages.
|
||||
*
|
||||
* All are `h`-scoped (`GroupIdTag`), so the same `#h` group REQ returns them:
|
||||
* - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100)
|
||||
* - forum posts/votes/comments (45001-45003)
|
||||
* - agent jobs (43001-43006)
|
||||
* - huddle lifecycle (48100-48103)
|
||||
*
|
||||
* Consumption for every one of these already exists in `LocalCache` (see
|
||||
* `consumeBuzzTimelineEvent`); requesting them here is what lets them actually arrive for
|
||||
* a group feed instead of only appearing if another subscription happened to fetch them.
|
||||
*/
|
||||
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
|
||||
listOf(
|
||||
@@ -104,11 +128,34 @@ val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
|
||||
StreamMessageEditEvent.KIND,
|
||||
StreamMessageDiffEvent.KIND,
|
||||
SystemMessageEvent.KIND,
|
||||
CanvasEvent.KIND,
|
||||
ForumPostEvent.KIND,
|
||||
ForumVoteEvent.KIND,
|
||||
ForumCommentEvent.KIND,
|
||||
JobRequestEvent.KIND,
|
||||
JobAcceptedEvent.KIND,
|
||||
JobProgressEvent.KIND,
|
||||
JobResultEvent.KIND,
|
||||
JobCancelEvent.KIND,
|
||||
JobErrorEvent.KIND,
|
||||
HuddleStartedEvent.KIND,
|
||||
HuddleParticipantJoinedEvent.KIND,
|
||||
HuddleParticipantLeftEvent.KIND,
|
||||
HuddleEndedEvent.KIND,
|
||||
)
|
||||
|
||||
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
|
||||
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
|
||||
|
||||
/**
|
||||
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
|
||||
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
|
||||
* (not the whole joined fleet) because it's a live "someone is typing" signal, never
|
||||
* stored (20000-29999) and never a feed row (`LocalCache` records it into `BuzzTypingState`
|
||||
* and drops it). It matches nothing on a vanilla relay.
|
||||
*/
|
||||
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
|
||||
|
||||
/** Forum-thread kinds shown in a group's Threads tab. */
|
||||
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
|
||||
|
||||
@@ -186,7 +233,7 @@ fun buildRelayGroupOpenChatTailFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
@@ -162,6 +163,11 @@ open class ChannelNewMessageViewModel :
|
||||
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
|
||||
val replyMode = mutableStateOf(ReplyMode.INLINE)
|
||||
|
||||
// When set, the composer is editing an existing Buzz stream message (kind 40002):
|
||||
// the next send publishes a kind-40003 edit targeting this note instead of a new
|
||||
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
|
||||
val editingBuzzMessage = mutableStateOf<Note?>(null)
|
||||
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
@@ -270,6 +276,26 @@ open class ChannelNewMessageViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
|
||||
* the next send as a kind-40003 edit of it. Editing and replying are mutually
|
||||
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
|
||||
* kind-40002 messages on a Buzz relay.
|
||||
*/
|
||||
fun editBuzzMessage(note: Note) {
|
||||
replyTo.value = null
|
||||
replyMode.value = ReplyMode.INLINE
|
||||
editingBuzzMessage.value = note
|
||||
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun clearBuzzEdit() {
|
||||
editingBuzzMessage.value = null
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun editFromDraft(draft: Note) {
|
||||
val noteEvent = draft.event
|
||||
val noteAuthor = draft.author
|
||||
@@ -687,6 +713,19 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel &&
|
||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||
editingBuzzMessage.value != null -> {
|
||||
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
|
||||
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
|
||||
// message; content is the replacement text. Kept minimal to mirror Buzz's own
|
||||
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
|
||||
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
|
||||
// re-renders with this content.
|
||||
val target = editingBuzzMessage.value!!
|
||||
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message)
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
|
||||
// Buzz workspace message: the native kind is 40002 (stream message v2)
|
||||
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
|
||||
@@ -777,6 +816,7 @@ open class ChannelNewMessageViewModel :
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
replyTo.value = null
|
||||
editingBuzzMessage.value = null
|
||||
|
||||
urlPreview = null
|
||||
|
||||
|
||||
@@ -22,21 +22,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
@@ -56,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
|
||||
@@ -87,6 +98,35 @@ fun EditFieldRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz edit mode: a banner reminding the user the next send replaces an existing
|
||||
// message (a kind-40003 edit), with an X to abandon the edit and clear the field.
|
||||
channelScreenModel.editingBuzzMessage.value?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_editing_banner),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
IconButton(onClick = { channelScreenModel.clearBuzzEdit() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(R.string.cancel),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelScreenModel.uploadState?.let { uploading ->
|
||||
uploading.multiOrchestrator?.let { _ ->
|
||||
ChannelFileUploadDialog(
|
||||
@@ -121,9 +161,28 @@ fun EditFieldRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Client-side throttle for the Buzz kind-20002 typing heartbeat (below).
|
||||
val lastTypingSecs = remember { longArrayOf(0L) }
|
||||
|
||||
ThinPaddingTextField(
|
||||
state = channelScreenModel.message,
|
||||
onTextChanged = { channelScreenModel.onMessageChanged() },
|
||||
onTextChanged = {
|
||||
channelScreenModel.onMessageChanged()
|
||||
// Buzz typing indicator: fire a throttled heartbeat while composing in a
|
||||
// Buzz workspace channel, so other members see "… is typing". No-op on any
|
||||
// other chat (the kind is Buzz-only and the relay would ignore it anyway).
|
||||
val channel = channelScreenModel.channel
|
||||
if (channel is RelayGroupChannel &&
|
||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||
channelScreenModel.message.text.isNotEmpty()
|
||||
) {
|
||||
val now = TimeUtils.now()
|
||||
if (now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) {
|
||||
lastTypingSecs[0] = now
|
||||
accountViewModel.sendBuzzTyping(channel)
|
||||
}
|
||||
}
|
||||
},
|
||||
onContentReceived = { uri, mimeType ->
|
||||
channelScreenModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -3289,6 +3291,21 @@
|
||||
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
|
||||
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
|
||||
<string name="buzz_message_edited">(edited)</string>
|
||||
<string name="buzz_diff_truncated">(diff truncated)</string>
|
||||
<string name="buzz_canvas_title">Canvas</string>
|
||||
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
|
||||
<string name="buzz_edit_message">Edit</string>
|
||||
<string name="buzz_editing_banner">Editing message</string>
|
||||
<string name="buzz_typing_one">%1$s is typing…</string>
|
||||
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
|
||||
<string name="buzz_typing_many">Several people are typing…</string>
|
||||
<string name="buzz_workspaces_title">Workspaces</string>
|
||||
<string name="buzz_workspaces_section">Your workspaces</string>
|
||||
<string name="buzz_workspaces_empty_title">No workspaces yet</string>
|
||||
<string name="buzz_workspaces_empty_body">Buzz workspaces are relays where humans and AI agents build together. Join one to see it here.</string>
|
||||
<string name="buzz_workspaces_browse">Browse groups</string>
|
||||
<string name="buzz_console_card_title">Agent Console</string>
|
||||
<string name="buzz_console_card_subtitle">Costs · Personas · Live observer</string>
|
||||
|
||||
<string name="chat_delivery_details_title">Message Delivery</string>
|
||||
<string name="close">Close</string>
|
||||
|
||||
@@ -128,10 +128,11 @@ class RelayGroupFilterBuildersTest {
|
||||
fun `open chat tail is a single host h-filter with since only`() {
|
||||
val f = buildRelayGroupOpenChatTailFilter(g1OnA, 5L)
|
||||
assertEquals(relayA, f.relay)
|
||||
// The open-channel tail always carries the Buzz timeline kinds in addition to
|
||||
// the NIP-29 set: it is the Buzz-dialect detection bootstrap (see
|
||||
// buildRelayGroupOpenChatTailFilter). Harmless on vanilla relays.
|
||||
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, f.filter.kinds)
|
||||
// The open-channel tail carries the full Buzz timeline set PLUS the ephemeral
|
||||
// kind-20002 typing indicator (RELAY_GROUP_OPEN_TAIL_KINDS) — typing is scoped to
|
||||
// the one channel on screen, not the joined fleet. It is also the Buzz-dialect
|
||||
// detection bootstrap. Harmless on vanilla relays.
|
||||
assertEquals(RELAY_GROUP_OPEN_TAIL_KINDS, f.filter.kinds)
|
||||
assertEquals(listOf("g1"), f.filter.tags!!["h"])
|
||||
assertEquals(5L, f.filter.since)
|
||||
assertNull(f.filter.until)
|
||||
|
||||
@@ -591,6 +591,22 @@ screen speaks.
|
||||
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
|
||||
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
||||
|
||||
### Buzz workspaces (block/buzz — NIP-29 dialect)
|
||||
|
||||
[`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz
|
||||
relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native
|
||||
surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the
|
||||
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
|
||||
and `commons` aggregator the app uses.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `amy buzz post RELAY GID <text>` | Post a kind:40002 stream message (Buzz-native) into a workspace. |
|
||||
| `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). |
|
||||
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
|
||||
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
|
||||
| `amy buzz personas [--relays R,R] [--timeout SECS]` | List my kind:30175 persona definitions (newest per slug). |
|
||||
|
||||
### Concord Channels (encrypted communities)
|
||||
|
||||
Encrypted, serverless communities (the CORD specs). Community secrets
|
||||
|
||||
@@ -59,6 +59,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| NIP-18 reposts / quotes | 🆕 | |
|
||||
| NIP-25 reactions | ✅ in groups · 🆕 elsewhere | `marmot message react` covers MLS group reactions; outer-event reactions still pending. |
|
||||
| NIP-29 relay groups (`amy relaygroup`) | ✅ | `RelayGroupCommands` — list/browse/info/create/join/leave/message/edit/invite/put-user/remove-user against a host relay; kind:10009 joined-list kept in sync. |
|
||||
| Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). |
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
| NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
|
||||
| NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. |
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.commands.AdminCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.CountCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
|
||||
@@ -285,6 +286,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"relay" -> RelayCommands.dispatch(dataDir, tail)
|
||||
"marmot" -> marmotDispatch(dataDir, tail)
|
||||
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
|
||||
"buzz" -> BuzzCommands.dispatch(dataDir, tail)
|
||||
"dm" -> DmCommands.dispatch(dataDir, tail)
|
||||
"geochat" -> GeochatCommands.dispatch(dataDir, tail)
|
||||
"profile" -> ProfileCommands.dispatch(dataDir, tail)
|
||||
@@ -802,6 +804,13 @@ private fun printUsage() {
|
||||
| [--role admin|moderator]
|
||||
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
||||
|
|
||||
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
|
||||
| buzz post RELAY GID <text> post a kind-40002 stream message
|
||||
| buzz read RELAY GID [--limit N] read recent workspace messages
|
||||
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
|
||||
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
|
||||
| buzz personas [--relays R,R] list my kind-30175 personas
|
||||
|
|
||||
|Marmot (MLS group messaging):
|
||||
| marmot key-package publish publish a fresh KeyPackage
|
||||
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* 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.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/**
|
||||
* `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the
|
||||
* same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29
|
||||
* groups, so join/leave/create still go through `amy relaygroup`; this verb group covers
|
||||
* the Buzz-native pieces: stream messages (40002), the owner-attestation primitive (OA),
|
||||
* and the agent console (turn-metric aggregation + personas).
|
||||
*/
|
||||
object BuzzCommands {
|
||||
private val USAGE =
|
||||
"""
|
||||
|amy buzz post RELAY GID <text> post a kind-40002 stream message
|
||||
|amy buzz read RELAY GID [--limit N] read recent workspace messages (9/40002/40099)
|
||||
| [--timeout SECS]
|
||||
|amy buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline; prints the auth tag)
|
||||
| [--after UNIX] [--before UNIX]
|
||||
|amy buzz console [--relays R,R] decrypt + aggregate my kind-44200 turn metrics
|
||||
| [--timeout SECS]
|
||||
|amy buzz personas [--relays R,R] list my kind-30175 personas
|
||||
| [--timeout SECS]
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"buzz",
|
||||
tail,
|
||||
USAGE,
|
||||
mapOf(
|
||||
"post" to { rest -> post(dataDir, rest) },
|
||||
"read" to { rest -> read(dataDir, rest) },
|
||||
"attest" to { rest -> attest(dataDir, rest) },
|
||||
"console" to { rest -> console(dataDir, rest) },
|
||||
"personas" to { rest -> personas(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
|
||||
private suspend fun post(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val usage = "buzz post RELAY GID <text>"
|
||||
val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
|
||||
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
|
||||
StreamMessageV2Event.build(groupId, text)
|
||||
}
|
||||
}
|
||||
|
||||
/** `buzz read RELAY GID [--limit N] [--timeout SECS]` → drains the recent human-visible timeline. */
|
||||
private suspend fun read(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "buzz read RELAY GID [--limit N] [--timeout SECS]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val limit = args.flag("limit")?.toIntOrNull() ?: 50
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
|
||||
args.rejectUnknown("limit", "timeout")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND, SystemMessageEvent.KIND),
|
||||
tags = mapOf("h" to listOf(groupId)),
|
||||
limit = limit,
|
||||
)
|
||||
val messages =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.take(limit)
|
||||
.map {
|
||||
mapOf(
|
||||
"id" to it.id,
|
||||
"kind" to it.kind,
|
||||
"author" to it.pubKey,
|
||||
"created_at" to it.createdAt,
|
||||
"content" to it.content,
|
||||
)
|
||||
}
|
||||
Output.emit(mapOf("group_id" to groupId, "relay" to relay.url, "count" to messages.size, "messages" to messages))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `buzz attest AGENT [--kind K] [--after T] [--before T]` → signs a NIP-OA
|
||||
* [OwnerAttestation] authorizing AGENT and prints the `auth` tag. Offline (the
|
||||
* signature covers a hashed commitment, not an event) and needs a local private key.
|
||||
*/
|
||||
private suspend fun attest(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]"
|
||||
val agentInput = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val agentHex =
|
||||
decodePublicKeyAsHexOrNull(agentInput.trim())?.takeIf { it.isValid() }
|
||||
?: return Output.error("bad_args", "invalid agent public key (npub or 64-char hex): $agentInput")
|
||||
val kind = args.flag("kind")?.let { it.toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer") }
|
||||
val after = args.flag("after")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "after must be a unix time") }
|
||||
val before = args.flag("before")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "before must be a unix time") }
|
||||
args.rejectUnknown("kind", "after", "before")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
val keyPair = ctx.identity.keyPair()
|
||||
if (keyPair.privKey == null) return Output.error("no_private_key", "attestation signing needs a local private key")
|
||||
val conditions = AttestationConditions(kind = kind, createdAtBefore = before, createdAtAfter = after)
|
||||
val attestation =
|
||||
try {
|
||||
OwnerAttestation.sign(agentHex, conditions, keyPair)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return Output.error("bad_args", e.message ?: "could not sign the attestation")
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"agent" to agentHex,
|
||||
"owner" to attestation.ownerPubKey,
|
||||
"conditions" to attestation.conditions.ifEmpty { null },
|
||||
"sig" to attestation.sig,
|
||||
"auth_tag" to attestation.toTag().toList(),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `buzz console [--relays R,R] [--timeout SECS]` → fetches my kind-44200 turn metrics
|
||||
* (`#p` = me), NIP-44-decrypts them, and aggregates fleet + per-agent cost via the same
|
||||
* [AgentFleetAggregator] the app's Agent Console uses.
|
||||
*/
|
||||
private suspend fun console(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relaysFlag = args.flag("relays")
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
|
||||
args.rejectUnknown("relays", "timeout")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val me = ctx.identity.pubKeyHex
|
||||
val relays = relaysFor(ctx, relaysFlag)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
|
||||
|
||||
val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me)))
|
||||
val decrypted =
|
||||
ctx
|
||||
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<AgentTurnMetricEvent>()
|
||||
.distinctBy { it.id }
|
||||
.mapNotNull { e -> e.decryptOrNull(ctx.signer)?.let { (e.agentPubKey() ?: e.pubKey) to it } }
|
||||
val metrics = AgentFleetAggregator.aggregate(decrypted)
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"total_cost_usd" to metrics.totals.costUsd,
|
||||
"total_tokens" to metrics.totals.totalTokens,
|
||||
"input_tokens" to metrics.totals.inputTokens,
|
||||
"output_tokens" to metrics.totals.outputTokens,
|
||||
"turns" to metrics.totalTurns,
|
||||
"sessions" to metrics.totalSessions,
|
||||
"agents" to metrics.agents.size,
|
||||
"estimated" to metrics.hasUnreliableEstimates,
|
||||
"breakdown" to
|
||||
metrics.agents.map {
|
||||
mapOf(
|
||||
"agent" to it.agentPubKey,
|
||||
"cost_usd" to it.totals.costUsd,
|
||||
"tokens" to it.totals.totalTokens,
|
||||
"turns" to it.turns,
|
||||
"sessions" to it.sessions,
|
||||
"models" to it.models.sorted(),
|
||||
"last_activity" to it.lastActivity,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `buzz personas [--relays R,R] [--timeout SECS]` → lists my kind-30175 persona definitions. */
|
||||
private suspend fun personas(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relaysFlag = args.flag("relays")
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
|
||||
args.rejectUnknown("relays", "timeout")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val me = ctx.identity.pubKeyHex
|
||||
val relays = relaysFor(ctx, relaysFlag)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
|
||||
|
||||
val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me))
|
||||
val personas =
|
||||
ctx
|
||||
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<PersonaEvent>()
|
||||
// Newest per addressable slug (replaceable): keep the latest for each d tag.
|
||||
.groupBy { it.slug() }
|
||||
.values
|
||||
.mapNotNull { versions -> versions.maxByOrNull { it.createdAt } }
|
||||
.sortedBy { it.personaOrNull()?.displayName ?: it.slug() ?: "" }
|
||||
.map {
|
||||
val content = it.personaOrNull()
|
||||
mapOf(
|
||||
"slug" to it.slug(),
|
||||
"display_name" to content?.displayName,
|
||||
"model" to content?.model,
|
||||
"runtime" to content?.runtime,
|
||||
"provider" to content?.provider,
|
||||
)
|
||||
}
|
||||
Output.emit(mapOf("count" to personas.size, "personas" to personas))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The `--relays` set if given, else the account's outbox relays. */
|
||||
private suspend fun relaysFor(
|
||||
ctx: Context,
|
||||
relaysFlag: String?,
|
||||
) = relaysFlag
|
||||
?.split(",")
|
||||
?.mapNotNull { normalizeGroupRelay(it) }
|
||||
?.toSet()
|
||||
?: ctx.outboxRelays()
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.commons.model.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
|
||||
* authorization per agent key that lets that key publish in the owner's Buzz workspace
|
||||
* without being enrolled as a relay member.
|
||||
*
|
||||
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
|
||||
* agent operator out-of-band): when the account whose pubkey equals a stored key
|
||||
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
|
||||
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
|
||||
* membership while the owner stays a member.
|
||||
*
|
||||
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
|
||||
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
|
||||
* store never carries a credential the relay would reject.
|
||||
*
|
||||
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
|
||||
* only: a held attestation is re-pasted after a process restart. Persisting it across
|
||||
* launches (per-account, encrypted) is a follow-up.
|
||||
*/
|
||||
object BuzzHeldAttestations {
|
||||
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
|
||||
|
||||
/** All held attestations, keyed by agent pubkey; UI can collect this. */
|
||||
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
|
||||
|
||||
/** The attestation held for [agentPubKey], or null. */
|
||||
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
|
||||
|
||||
/**
|
||||
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
|
||||
* verified attestation is held for that key.
|
||||
*/
|
||||
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
|
||||
|
||||
/**
|
||||
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
|
||||
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
|
||||
* writers don't clobber each other.
|
||||
*/
|
||||
fun put(
|
||||
agentPubKey: HexKey,
|
||||
attestation: OwnerAttestation,
|
||||
) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (current[agentPubKey] == attestation) return
|
||||
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes any attestation held for [agentPubKey]. */
|
||||
fun remove(agentPubKey: HexKey) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (agentPubKey !in current) return
|
||||
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clears all held attestations so unit tests don't leak state. */
|
||||
fun clearForTesting() {
|
||||
heldByAgent.value = emptyMap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.commons.model.buzz
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Live "who is typing" state for Buzz workspace channels, fed by the ephemeral
|
||||
* kind-20002 typing indicator (`TypingIndicatorEvent`). Buzz relays never store these
|
||||
* (20000-29999), so this is pure in-memory live state — the exact analog of
|
||||
* `ConcordCommunitySession.typing`, but keyed by the channel's `h` UUID because Buzz
|
||||
* channels are relay-authoritative NIP-29 groups rather than encrypted planes.
|
||||
*
|
||||
* Shape: `channelId -> (typistPubKey -> lastHeartbeatUnixSecs)`. A typist is "active"
|
||||
* while their last heartbeat is within [TYPING_STALE_SECS]; the UI ages them out on a
|
||||
* timer. Mutations are lock-guarded because `LocalCache` consume runs on several relay
|
||||
* reader threads and an unsynchronized check-then-act could drop a fresh heartbeat.
|
||||
*
|
||||
* Process-wide singleton like [BuzzRelayDialect] / `BuzzWorkspaceStates`.
|
||||
*/
|
||||
object BuzzTypingState {
|
||||
private val lock = KmpLock()
|
||||
private val typingByChannel = HashMap<String, HashMap<HexKey, Long>>()
|
||||
private val mutableTyping = MutableStateFlow<Map<String, Map<HexKey, Long>>>(emptyMap())
|
||||
|
||||
/** `channelId -> (typist -> lastHeartbeatSecs)`; the UI collects this and ages entries out. */
|
||||
val flow: StateFlow<Map<String, Map<HexKey, Long>>> = mutableTyping
|
||||
|
||||
/**
|
||||
* Records a typing heartbeat from [typist] in [channelId] at [atSecs] (clamped to
|
||||
* [nowSecs] so a future-dated peer can't wedge the freshness window), then prunes
|
||||
* anything already stale. No-op for a stale-on-arrival heartbeat.
|
||||
*/
|
||||
fun record(
|
||||
channelId: String,
|
||||
typist: HexKey,
|
||||
atSecs: Long,
|
||||
nowSecs: Long,
|
||||
) = lock.withLock {
|
||||
val stamp = minOf(atSecs, nowSecs)
|
||||
if (nowSecs - stamp > TYPING_STALE_SECS) return@withLock
|
||||
val perChannel = typingByChannel.getOrPut(channelId) { HashMap() }
|
||||
val prev = perChannel[typist]
|
||||
if (prev == null || stamp > prev) perChannel[typist] = stamp
|
||||
perChannel.entries.retainAll { nowSecs - it.value <= TYPING_STALE_SECS }
|
||||
if (perChannel.isEmpty()) typingByChannel.remove(channelId)
|
||||
mutableTyping.value = typingByChannel.mapValues { it.value.toMap() }
|
||||
}
|
||||
|
||||
/** Test-only: clears all typing state so unit tests don't leak into each other. */
|
||||
fun clearForTesting() =
|
||||
lock.withLock {
|
||||
typingByChannel.clear()
|
||||
mutableTyping.value = emptyMap()
|
||||
}
|
||||
|
||||
/** A peer must heartbeat at least this often to stay "typing"; also the UI fade window. */
|
||||
const val TYPING_STALE_SECS = 8L
|
||||
|
||||
/** Client-side throttle: send at most one typing heartbeat this often while composing. */
|
||||
const val TYPING_HEARTBEAT_SECS = 4L
|
||||
}
|
||||
@@ -56,6 +56,11 @@ class BuzzWorkspaceState {
|
||||
var canvasNote: Note? = null
|
||||
private set
|
||||
|
||||
private val canvasVersion = MutableStateFlow(0)
|
||||
|
||||
/** Bumps when [canvasNote] is replaced by a newer revision, so a canvas view re-reads it. */
|
||||
val canvasUpdates: StateFlow<Int> = canvasVersion
|
||||
|
||||
/** Records a 40003 edit; keeps only the newest per target (last-write-wins by created_at). */
|
||||
fun addEdit(
|
||||
targetId: HexKey,
|
||||
@@ -78,6 +83,7 @@ class BuzzWorkspaceState {
|
||||
lock.withLock {
|
||||
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
|
||||
canvasNote = note
|
||||
canvasVersion.value = canvasVersion.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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.commons.model.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.TokenCounts
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AgentFleetAggregatorTest {
|
||||
private val agentA = "a".repeat(64)
|
||||
private val agentB = "b".repeat(64)
|
||||
|
||||
private fun turn(
|
||||
agent: String,
|
||||
session: String?,
|
||||
seq: Long,
|
||||
turn: TokenCounts? = null,
|
||||
cumulative: TokenCounts? = null,
|
||||
deltaReliable: Boolean = true,
|
||||
ts: String = "2026-07-0${seq}T00:00:00Z",
|
||||
harness: String = "goose",
|
||||
model: String? = "claude",
|
||||
) = agent to
|
||||
AgentTurnMetricPayload(
|
||||
harness = harness,
|
||||
timestamp = ts,
|
||||
model = model,
|
||||
sessionId = session,
|
||||
turnId = "turn-$seq",
|
||||
turnSeq = seq,
|
||||
turn = turn,
|
||||
cumulative = cumulative,
|
||||
deltaReliable = deltaReliable,
|
||||
)
|
||||
|
||||
private fun counts(
|
||||
input: Long? = null,
|
||||
output: Long? = null,
|
||||
total: Long? = null,
|
||||
cost: Double? = null,
|
||||
) = TokenCounts(inputTokens = input, outputTokens = output, totalTokens = total, costUsd = cost)
|
||||
|
||||
@Test
|
||||
fun emptyYieldsEmpty() {
|
||||
assertEquals(AgentFleetMetrics.EMPTY, AgentFleetAggregator.aggregate(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cumulativeIsTheSessionTotalNotTheSumOfCumulatives() {
|
||||
// Three turns of one session; cumulative is the running total. Summing the
|
||||
// cumulatives (100+250+400) would triple-count — the total is the LAST one, 400.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10)),
|
||||
turn(agentA, "s1", 2, cumulative = counts(total = 250, cost = 0.25)),
|
||||
turn(agentA, "s1", 3, cumulative = counts(total = 400, cost = 0.40)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(400L, metrics.totals.totalTokens)
|
||||
assertEquals(0.40, metrics.totals.costUsd, 1e-9)
|
||||
assertEquals(3, metrics.totalTurns)
|
||||
assertEquals(1, metrics.totalSessions)
|
||||
assertFalse(metrics.hasUnreliableEstimates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingMiddleTurnStillGivesTheRightSessionTotal() {
|
||||
// Turn 2 never arrived; cumulative-max still recovers the true session total.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100)),
|
||||
turn(agentA, "s1", 3, cumulative = counts(total = 400)),
|
||||
),
|
||||
)
|
||||
assertEquals(400L, metrics.totals.totalTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withoutCumulativeItSumsTheTurnDeltas() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(input = 10, output = 5, total = 15, cost = 0.01)),
|
||||
turn(agentA, "s1", 2, turn = counts(input = 20, output = 8, total = 28, cost = 0.02)),
|
||||
),
|
||||
)
|
||||
assertEquals(30L, metrics.totals.inputTokens)
|
||||
assertEquals(13L, metrics.totals.outputTokens)
|
||||
assertEquals(43L, metrics.totals.totalTokens)
|
||||
assertEquals(0.03, metrics.totals.costUsd, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perFieldFallbackTakesCostFromDeltasWhenCumulativeOmitsIt() {
|
||||
// cumulative reports tokens but not cost; cost must still come from the deltas.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(cost = 0.05), cumulative = counts(total = 100)),
|
||||
turn(agentA, "s1", 2, turn = counts(cost = 0.07), cumulative = counts(total = 200)),
|
||||
),
|
||||
)
|
||||
assertEquals(200L, metrics.totals.totalTokens) // from cumulative max
|
||||
assertEquals(0.12, metrics.totals.costUsd, 1e-9) // from summed deltas
|
||||
}
|
||||
|
||||
@Test
|
||||
fun distinctSessionsSumTogether() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10)),
|
||||
turn(agentA, "s2", 1, cumulative = counts(total = 300, cost = 0.30)),
|
||||
),
|
||||
)
|
||||
assertEquals(400L, metrics.totals.totalTokens)
|
||||
assertEquals(0.40, metrics.totals.costUsd, 1e-9)
|
||||
assertEquals(2, metrics.totalSessions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun turnsWithNoSessionIdAreSeparateSingletons() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, null, 1, cumulative = counts(total = 100)),
|
||||
turn(agentA, null, 2, cumulative = counts(total = 100)),
|
||||
),
|
||||
)
|
||||
// Two sessionless turns must NOT collapse into one session (that would drop one).
|
||||
assertEquals(2, metrics.totalSessions)
|
||||
assertEquals(200L, metrics.totals.totalTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perAgentBreakdownSortedByCostDescending() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10), model = "haiku"),
|
||||
turn(agentB, "s2", 1, cumulative = counts(total = 900, cost = 0.90), model = "opus"),
|
||||
),
|
||||
)
|
||||
assertEquals(2, metrics.agents.size)
|
||||
assertEquals(agentB, metrics.agents[0].agentPubKey) // pricier agent first
|
||||
assertEquals(0.90, metrics.agents[0].totals.costUsd, 1e-9)
|
||||
assertEquals(setOf("opus"), metrics.agents[0].models)
|
||||
assertEquals("2026-07-01T00:00:00Z", metrics.agents[1].lastActivity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreliableDeltaWithoutCumulativeFlagsEstimate() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(total = 50, cost = 0.05), deltaReliable = false),
|
||||
),
|
||||
)
|
||||
assertEquals(50L, metrics.totals.totalTokens)
|
||||
assertTrue(metrics.hasUnreliableEstimates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cumulativePresenceMakesUnreliableDeltaIrrelevant() {
|
||||
// deltaReliable=false but cumulative is present → we use cumulative, no estimate flag.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(total = 999), cumulative = counts(total = 50), deltaReliable = false),
|
||||
),
|
||||
)
|
||||
assertEquals(50L, metrics.totals.totalTokens)
|
||||
assertFalse(metrics.hasUnreliableEstimates)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.commons.model.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class BuzzHeldAttestationsTest {
|
||||
private val agent = "a".repeat(64)
|
||||
private val owner = "b".repeat(64)
|
||||
private val attestation = OwnerAttestation(ownerPubKey = owner, conditions = "kind=40002", sig = "c".repeat(128))
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = BuzzHeldAttestations.clearForTesting()
|
||||
|
||||
@Test
|
||||
fun emptyStoreYieldsNoTag() {
|
||||
assertNull(BuzzHeldAttestations.attestationFor(agent))
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heldAttestationSurfacesAsItsAuthTag() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertEquals(attestation, BuzzHeldAttestations.attestationFor(agent))
|
||||
// The tag attached to the agent's AUTH is exactly the attestation's ["auth", …] tag.
|
||||
assertContentEquals(attestation.toTag(), BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeClearsTheHeldAttestation() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
BuzzHeldAttestations.remove(agent)
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAgentsAttestationDoesNotLeakToAnother() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertNull(BuzzHeldAttestations.authTagFor("d".repeat(64)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.commons.model.buzz
|
||||
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzTypingStateTest {
|
||||
private val channel = "chan-1"
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
|
||||
@BeforeTest fun setup() = BuzzTypingState.clearForTesting()
|
||||
|
||||
@AfterTest fun teardown() = BuzzTypingState.clearForTesting()
|
||||
|
||||
@Test
|
||||
fun recordsAFreshHeartbeat() {
|
||||
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
|
||||
assertEquals(setOf(alice), BuzzTypingState.flow.value[channel]?.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleOnArrivalIsDropped() {
|
||||
// atSecs is older than nowSecs by more than the window → never recorded.
|
||||
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100 + BuzzTypingState.TYPING_STALE_SECS + 1)
|
||||
assertNull(BuzzTypingState.flow.value[channel])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewerHeartbeatWins_andStaleTypistsArePruned() {
|
||||
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
|
||||
BuzzTypingState.record(channel, bob, atSecs = 101, nowSecs = 101)
|
||||
assertEquals(setOf(alice, bob), BuzzTypingState.flow.value[channel]?.keys)
|
||||
|
||||
// A heartbeat far in the future ages both prior ones out of the window; only the
|
||||
// fresh one survives, and the newer stamp for bob is what's kept.
|
||||
val later = 101 + BuzzTypingState.TYPING_STALE_SECS + 5
|
||||
BuzzTypingState.record(channel, bob, atSecs = later, nowSecs = later)
|
||||
assertEquals(setOf(bob), BuzzTypingState.flow.value[channel]?.keys)
|
||||
assertEquals(later, BuzzTypingState.flow.value[channel]?.get(bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun futureDatedHeartbeatIsClampedToNow() {
|
||||
// A peer claiming a wildly-future createdAt must not become un-expirable.
|
||||
BuzzTypingState.record(channel, alice, atSecs = 10_000, nowSecs = 100)
|
||||
assertEquals(100L, BuzzTypingState.flow.value[channel]?.get(alice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelsAreIsolated() {
|
||||
BuzzTypingState.record("c1", alice, atSecs = 100, nowSecs = 100)
|
||||
BuzzTypingState.record("c2", bob, atSecs = 100, nowSecs = 100)
|
||||
assertEquals(setOf(alice), BuzzTypingState.flow.value["c1"]?.keys)
|
||||
assertEquals(setOf(bob), BuzzTypingState.flow.value["c2"]?.keys)
|
||||
assertTrue(BuzzTypingState.flow.value.size == 2)
|
||||
}
|
||||
}
|
||||
@@ -141,15 +141,76 @@ kind-filtered, so they can never receive Buzz kinds by accident):
|
||||
replaceably, the rest store as queryable regular events, and ephemeral signals
|
||||
(typing 20002, observer 24200, huddle reactions 24810, pairing 24134) mark the
|
||||
dialect but are deliberately not persisted.
|
||||
- The group-chat REQ builders (`RelayGroupFilterBuilders`) request 40002/40003/40008/40099
|
||||
**unconditionally** alongside the NIP-29 kinds (they match nothing on a vanilla relay).
|
||||
- The group-chat REQ builders (`RelayGroupFilterBuilders`) request the whole Buzz timeline
|
||||
set — 40002/40003/40008/40099, canvas 40100, forum 45001-45003, agent jobs 43001-43006,
|
||||
huddle lifecycle 48100-48103 — **unconditionally** alongside the NIP-29 kinds (they match
|
||||
nothing on a vanilla relay; all are `h`-scoped so the same `#h` group REQ returns them).
|
||||
A dialect-gated version was reverted: gating advanced history cursors past ranges that
|
||||
had been queried without the Buzz kinds, permanently skipping older workspace messages.
|
||||
- Kind-aware chat rendering (`RenderBuzzNotes` + `ChatMessageCompose`): 40002 as text,
|
||||
40003 as an edit overlay, 40099 as a system row, **40008 diffs** as a monospace scrollable
|
||||
block with a repo/commit/file header, **agent-job (43xxx) + huddle (48xxx) lifecycle** as
|
||||
centered system narration (huddles carry JSON content, so they must not fall through to a
|
||||
raw text bubble), and **forum votes (45002)** as a glyph line. Forum posts stay plain text.
|
||||
- Dialect marking fires only off a **verified** event (`markBuzzIfVerified`), so an
|
||||
unverifiable frame from a hostile/buggy relay can't flip what the composer sends.
|
||||
|
||||
Not yet wired: kind-aware rendering of 40002/40099/diff rows in the chat UI, the
|
||||
composer switch (write 40002 on Buzz relays), and NIP-42-at-connect workspace sessions.
|
||||
### NIP-OA agent auth at connect — implemented
|
||||
|
||||
`commons/.../model/buzz/BuzzHeldAttestations.kt` holds the attestations this device has
|
||||
*received* (keyed by the agent pubkey they authorize; only `verify`-passing ones are
|
||||
stored). When an account whose pubkey matches a held attestation AUTHs (NIP-42) to a
|
||||
**Buzz-dialect** relay, `AuthCoordinator.buzzAugmented` appends the owner-signed `auth`
|
||||
tag to that account's AUTH event — and only that account's, never the Concord stream-key
|
||||
AUTHs that share the template — so an un-enrolled agent key gets virtual membership while
|
||||
its owner stays a member. The paste/hold + owner-side issue flows both live in
|
||||
`AgentAttestationScreen`. (Store is in-memory for now; persisting per-account is a follow-up.)
|
||||
|
||||
The **canvas (40100)** now has a viewer: `BuzzWorkspaceState` exposes a `canvasUpdates`
|
||||
flow and `amethyst/.../buzz/BuzzCanvasScreen` (`Route.BuzzCanvas`) renders the newest
|
||||
markdown, reached from a Dashboard action in the relay-group top bar that appears only on
|
||||
a Buzz relay once a canvas has arrived.
|
||||
|
||||
The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode
|
||||
(`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the
|
||||
original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's
|
||||
own 40002 messages threads to the composer through the shared chat feed via an optional
|
||||
`onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and
|
||||
`EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays
|
||||
already rendered).
|
||||
|
||||
The **social/moderation kinds work through the shared chat action sheet with no Buzz-specific
|
||||
wiring**, because Buzz's relay accept-list (`buzz-core/src/kind.rs` `ALL_KINDS`) deliberately
|
||||
includes the standard Nostr kinds: **reactions** (NIP-25 kind 7 — `requires_h_channel_scope`
|
||||
is false, so no h-tag needed), **deletions** (NIP-09 kind 5, plus NIP-29 group delete 9005/9008),
|
||||
**reports** (NIP-56 kind 1984, queued to the tenant's `moderation_reports`), and **custom emoji**
|
||||
(10030/30030). Amethyst's `ChatMessageActionSheet` already produces these and they are accepted,
|
||||
stored, and served by the Buzz relay. The one social action NOT in Buzz's accept-list is **zaps**
|
||||
(no 9734/9735) — but a zap is a Lightning payment whose receipt is published by the LN provider to
|
||||
the author's relays, so zapping still works; only the receipt isn't stored on the Buzz relay.
|
||||
|
||||
**Live typing indicators (kind 20002)** are wired: `commons/.../model/buzz/BuzzTypingState`
|
||||
is a process-wide, lock-guarded `channel -> typist -> heartbeat` registry (6 unit tests);
|
||||
`LocalCache` records each ephemeral 20002 into it (still no feed row), the open channel's
|
||||
live tail requests 20002 (`RELAY_GROUP_OPEN_TAIL_KINDS`, scoped to the room on screen),
|
||||
`Account.sendBuzzTyping` fires a throttled heartbeat from the composer, and
|
||||
`BuzzTypingIndicator` renders an animated "… is typing" row above the input. This matches
|
||||
Concord's typing feature. (`requires_h_channel_scope(20002)` is false on the relay, but
|
||||
Amethyst still `h`-scopes the send + subscription so it never leaks across channels.)
|
||||
|
||||
**Navigation identity — a "Workspaces" tab.** `NavBarItem.BUZZ` (→ `Route.BuzzWorkspaces`)
|
||||
is a pinnable bottom-nav destination; `amethyst/.../buzz/BuzzWorkspacesScreen` is a hub that
|
||||
filters the joined relay groups to Buzz-dialect relays and leads with an Agent-Console hero
|
||||
card. Buzz workspaces still ALSO appear in the generic Relay Groups list (they are NIP-29
|
||||
groups); the tab is a Buzz-branded lens over the same data, not a separate store.
|
||||
|
||||
Still not wired: the forum/job/huddle/DM message composers — each a send flow for a
|
||||
richer kind, and jobs/huddles are among the schema-inferred kinds, so they want Buzz-side
|
||||
confirmation before UI. **Presence (kind 20001)** is accepted by Buzz but collides with
|
||||
Amethyst's `GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash
|
||||
presence), so it needs disambiguation before it can render — unlike typing (20002), which
|
||||
has no such collision. A **workspace→channels shell** (Concord-style channel sidebar) and
|
||||
per-account persistence of held attestations remain follow-ups.
|
||||
|
||||
## Owner Attestation (NIP-OA) — implemented
|
||||
|
||||
@@ -175,3 +236,36 @@ An encrypted, durable record of one agent turn's token usage and cost, published
|
||||
agent to its owner. `content` is a NIP-44 v2 ciphertext of `AgentTurnMetricPayload`
|
||||
(camelCase) between the agent (author + `agent` tag) and the owner (`p` tag); either
|
||||
party can decrypt. See `amTurnMetrics/AgentTurnMetricEvent.kt`.
|
||||
|
||||
## Agent-owner console (commons + amethyst) — implemented
|
||||
|
||||
The owner-facing dashboard over their AI-agent fleet. Reachable from Settings → App →
|
||||
"Agent console" (`Route.AgentConsole`), three tabs:
|
||||
|
||||
- **Costs** — decrypted NIP-AM turn metrics (`kind:44200`, `p` = owner) rolled up by the
|
||||
pure `commons/.../model/buzz/AgentFleetMetrics.kt::AgentFleetAggregator`. Cost/token
|
||||
semantics are the aggregator's, not the UI's: per `(agent, session)` the **max
|
||||
cumulative** per field is the authoritative session total (robust to dropped turns),
|
||||
falling back per-field to **summing the `turn` deltas** only when no cumulative exists;
|
||||
a delta-sum touching a `deltaReliable == false` turn flags the fleet total estimated.
|
||||
Ten aggregator tests pin these (cumulative-not-summed, missing-turn recovery, per-field
|
||||
mixing, sessionless singletons, unreliable flag).
|
||||
- **Personas** — the owner's plaintext persona definitions (NIP-AP `kind:30175`).
|
||||
- **Observer** — a **live** subscription to ephemeral telemetry frames (NIP-AO
|
||||
`kind:24200`, `p` = owner) via `subscribeAsFlow`, decrypted `frame:telemetry` only,
|
||||
newest-first, bounded to a 200-row ring. Opened only while the tab is on screen
|
||||
(`DisposableEffect`) because relays and `LocalCache` never store observer frames.
|
||||
|
||||
`AgentConsoleViewModel` fetches 44200 + 30175 from the Buzz-dialect relays plus the
|
||||
owner's outbox set, lets `LocalCache` consume them, decrypts with the owner signer
|
||||
(cached by event id), and re-derives the aggregate.
|
||||
|
||||
A **"Attest" FAB** opens `AgentAttestationScreen` — owner-side **NIP-OA issuance**. The
|
||||
owner enters an agent pubkey (npub/hex) and optional `AttestationConditions` (kind, and/or
|
||||
`created_at` before/after bounds); `OwnerAttestation.sign(agent, conditions, ownerKey)`
|
||||
produces the signed `auth` tag, shown for copy so the owner hands it to the agent operator
|
||||
out-of-band. It is entirely offline (nothing is published) and needs the owner's **raw
|
||||
private key** — accounts on a NIP-46 bunker / NIP-55 external signer get an explanation
|
||||
instead of the form, because the signature covers a hashed commitment rather than an event.
|
||||
|
||||
Read-only v1 for the tabs — persona editing is the remaining follow-up.
|
||||
|
||||
Reference in New Issue
Block a user