feat(buzz): persona create/edit from the agent console

Tier 3 write flow. AgentPersonaEditScreen + AgentPersonaEditViewModel publish
a Buzz Agent Persona (NIP-AP kind:30175) to the workspace's Buzz-dialect relays
(falling back to the owner outbox). The Personas tab gains a 'New persona' row
and each card is tappable to edit.

On edit the existing PersonaEvent is loaded from LocalCache so fields the form
does not expose (name pool, respond-to allowlist, parallelism) are preserved
rather than dropped on republish; the slug (d tag) is locked once chosen and
validated against the relay's grammar (^[a-z0-9][a-z0-9_-]{0,63}$).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 01:28:30 +00:00
parent 0ea5d0e876
commit bce544e8c3
5 changed files with 357 additions and 10 deletions

View File

@@ -101,6 +101,7 @@ 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.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -460,6 +461,7 @@ fun BuildNavigation(
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) }

View File

@@ -473,6 +473,10 @@ sealed class Route {
@Serializable object AgentAttestation : Route()
@Serializable data class AgentPersonaEdit(
val slug: String? = null,
) : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@Serializable object EditPaymentTargets : Route()

View File

@@ -20,6 +20,7 @@
*/
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
@@ -35,6 +36,7 @@ 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
@@ -126,7 +128,7 @@ fun AgentConsoleScreen(
Box(modifier = Modifier.fillMaxSize()) {
when (selectedTab) {
0 -> CostsTab(metrics)
1 -> PersonasTab(personas)
1 -> PersonasTab(personas, nav)
else -> ObserverTab(observerFrames)
}
@@ -223,25 +225,45 @@ private fun TokenBreakdown(totals: TokenTotals) {
}
@Composable
private fun PersonasTab(personas: List<AgentConsoleViewModel.PersonaCard>) {
if (personas.isEmpty()) {
EmptyState("No personas published. Personas you publish (kind:30175) appear here.")
return
}
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)
PersonaCardView(persona) { nav.nav(Route.AgentPersonaEdit(persona.slug)) }
}
}
}
@Composable
private fun PersonaCardView(persona: AgentConsoleViewModel.PersonaCard) {
Card(modifier = Modifier.fillMaxWidth()) {
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,

View File

@@ -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")
}
}
}
}

View File

@@ -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)
}
}