mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
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:
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user