mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(app): pickers for agent key, kind, and persona model/provider/runtime
Replace raw-code text fields in the Buzz agent flows with pickers, matching the workflow-definition picker and the app's existing people-typeahead. - Agent Attestation "Agent public key": a single-agent people picker (name typeahead over the local user cache + removable chip), with npub/hex paste kept as the Enter escape hatch for keys that aren't contacts yet. - Agent Attestation "Restrict to kind": an editable dropdown of common named kinds (1 Text note, 7 Reaction, …), free numeric entry preserved, with the resolved name shown as supporting text. - Agent Persona edit "Model / Provider / Runtime": editable dropdowns of well-known values (claude-*, gpt-*, anthropic/openai/…, goose/…), free entry preserved since these stay free-form on the wire. - New reusable EditableSuggestDropdown (BuzzOptionDropdown.kt) backing the open-ended pickers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
+163
-16
@@ -20,50 +20,84 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
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.InputChip
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
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.note.UserPicture
|
||||
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.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
// Common event kinds an agent might be restricted to — suggestions for the "Restrict to kind" field;
|
||||
// any 0–65535 is still accepted by free numeric entry.
|
||||
private val KIND_OPTIONS =
|
||||
listOf(
|
||||
DropdownOption("1", "1 · Text note"),
|
||||
DropdownOption("7", "7 · Reaction"),
|
||||
DropdownOption("9", "9 · Group chat message"),
|
||||
DropdownOption("1111", "1111 · Comment"),
|
||||
DropdownOption("30023", "30023 · Long-form article"),
|
||||
DropdownOption("40002", "40002 · Buzz minichat message"),
|
||||
)
|
||||
|
||||
private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label }
|
||||
|
||||
/**
|
||||
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
|
||||
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
|
||||
@@ -106,7 +140,7 @@ fun AgentAttestationScreen(
|
||||
if (privKey == null) {
|
||||
ReadOnlyKeyNotice()
|
||||
} else {
|
||||
AttestationForm(ownerKey = keyPair)
|
||||
AttestationForm(ownerKey = keyPair, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,11 +280,15 @@ private fun ReadOnlyKeyNotice() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationForm(ownerKey: KeyPair) {
|
||||
private fun AttestationForm(
|
||||
ownerKey: KeyPair,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var agentInput by remember { mutableStateOf("") }
|
||||
var selectedAgent by remember { mutableStateOf<HexKey?>(null) }
|
||||
var kindInput by remember { mutableStateOf("") }
|
||||
var afterInput by remember { mutableStateOf("") }
|
||||
var beforeInput by remember { mutableStateOf("") }
|
||||
@@ -265,16 +303,23 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = agentInput,
|
||||
onValueChange = {
|
||||
agentInput = it
|
||||
// #1: pick the agent by name from the local user cache — or paste an npub/hex for a key that
|
||||
// isn't a contact yet (the common case for an external agent operator).
|
||||
AgentKeyPicker(
|
||||
selected = selectedAgent,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onSelect = {
|
||||
selectedAgent = it
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Agent public key (npub or hex)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClear = {
|
||||
selectedAgent = null
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
onInvalidPaste = { error = "Invalid agent public key. Enter an npub or 64-char hex key." },
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -283,17 +328,18 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
// #3: kind is any 0–65535, but the common ones have names — offer them, keep free numeric entry.
|
||||
EditableSuggestDropdown(
|
||||
value = kindInput,
|
||||
onValueChange = {
|
||||
kindInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Restrict to kind (0–65535)") },
|
||||
singleLine = true,
|
||||
label = "Restrict to kind (0–65535)",
|
||||
options = KIND_OPTIONS,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = KIND_LABELS[kindInput],
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -332,7 +378,8 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
|
||||
val agent = selectedAgent ?: return@Button
|
||||
when (val outcome = buildAttestation(agent, kindInput, afterInput, beforeInput, ownerKey)) {
|
||||
is AttestationOutcome.Failure -> {
|
||||
error = outcome.message
|
||||
result = null
|
||||
@@ -343,7 +390,7 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = agentInput.isNotBlank(),
|
||||
enabled = selectedAgent != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Generate attestation")
|
||||
@@ -357,6 +404,106 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-agent people picker: once an agent is chosen it shows as a removable chip; otherwise a
|
||||
* name typeahead over the local user cache with an npub/hex paste escape hatch (Enter accepts it).
|
||||
* Mirrors the New-DM recipient picker so authorizing an agent stops being the one raw-key field.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgentKeyPicker(
|
||||
selected: HexKey?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onSelect: (HexKey) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onInvalidPaste: () -> Unit,
|
||||
) {
|
||||
if (selected != null) {
|
||||
AgentChip(selected, accountViewModel, nav, onClear)
|
||||
return
|
||||
}
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var suggestions by remember { mutableStateOf<List<HexKey>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.isBlank()) {
|
||||
suggestions = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(150)
|
||||
suggestions =
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text("Agent (search a name, or paste npub / hex)") },
|
||||
leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
val hex = decodePublicKeyAsHexOrNull(query.trim())?.takeIf { it.isValid() }
|
||||
if (hex != null) onSelect(hex) else onInvalidPaste()
|
||||
},
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// A plain Column (not LazyColumn) — this form lives inside a verticalScroll parent.
|
||||
suggestions.forEach { hex ->
|
||||
AgentSuggestionRow(hex, accountViewModel, nav) { onSelect(hex) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One tappable agent search result — avatar + resolved name. */
|
||||
@Composable
|
||||
private fun AgentSuggestionRow(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
|
||||
val name by observeUserName(user, accountViewModel)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UserPicture(hex, 34.dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
/** The chosen agent as a removable chip — tapping the close affordance clears the selection. */
|
||||
@Composable
|
||||
private fun AgentChip(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
|
||||
val name by observeUserName(user, accountViewModel)
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = onRemove,
|
||||
label = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
avatar = { UserPicture(hex, 22.dp, accountViewModel = accountViewModel, nav = nav) },
|
||||
trailingIcon = { Icon(symbol = MaterialSymbols.Close, contentDescription = "Change agent", modifier = Modifier.size(16.dp)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationResultCard(
|
||||
attestation: OwnerAttestation,
|
||||
|
||||
+33
-12
@@ -42,6 +42,28 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
// Well-known values for the persona's optional model / provider / runtime — suggestions only; any
|
||||
// string is still accepted (the fields are free-form both in NIP-AP and in Buzz's persona events).
|
||||
private val MODEL_OPTIONS =
|
||||
listOf(
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-haiku-4-5",
|
||||
"gpt-4o",
|
||||
"gpt-4.1",
|
||||
"o3",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"llama-3.3-70b",
|
||||
).map { DropdownOption(it) }
|
||||
|
||||
private val PROVIDER_OPTIONS =
|
||||
listOf("anthropic", "openai", "google", "groq", "openrouter", "ollama", "bedrock", "azure").map { DropdownOption(it) }
|
||||
|
||||
private val RUNTIME_OPTIONS =
|
||||
listOf("goose", "claude-code", "custom").map { DropdownOption(it) }
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -96,26 +118,25 @@ fun AgentPersonaEditScreen(
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
// Model / provider / runtime are free-form strings upstream, but their real-world values
|
||||
// are a small known set — offer them as suggestions while keeping free entry.
|
||||
EditableSuggestDropdown(
|
||||
value = state.model,
|
||||
onValueChange = viewModel::onModelChange,
|
||||
label = { Text("Model (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = "Model (optional)",
|
||||
options = MODEL_OPTIONS,
|
||||
)
|
||||
OutlinedTextField(
|
||||
EditableSuggestDropdown(
|
||||
value = state.provider,
|
||||
onValueChange = viewModel::onProviderChange,
|
||||
label = { Text("Provider (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = "Provider (optional)",
|
||||
options = PROVIDER_OPTIONS,
|
||||
)
|
||||
OutlinedTextField(
|
||||
EditableSuggestDropdown(
|
||||
value = state.runtime,
|
||||
onValueChange = viewModel::onRuntimeChange,
|
||||
label = { Text("Runtime (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = "Runtime (optional)",
|
||||
options = RUNTIME_OPTIONS,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.avatarUrl,
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/** One suggested value for an [EditableSuggestDropdown] — its stored [value] and the [label] shown. */
|
||||
data class DropdownOption(
|
||||
val value: String,
|
||||
val label: String = value,
|
||||
) {
|
||||
fun matches(query: String): Boolean = value.contains(query, ignoreCase = true) || label.contains(query, ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* An editable text field that also offers a dropdown of known [options] — pick a suggestion or type
|
||||
* any value (free entry is preserved). The menu narrows to options matching the current text. This
|
||||
* is the "code → picker, but still open-ended" pattern for fields whose value set is well-known but
|
||||
* not closed (model / provider / runtime names, common event kinds).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditableSuggestDropdown(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
options: List<DropdownOption>,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
supportingText: String? = null,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val filtered = remember(value, options) { if (value.isBlank()) options else options.filter { it.matches(value) } }
|
||||
val showMenu = expanded && filtered.isNotEmpty()
|
||||
|
||||
ExposedDropdownMenuBox(expanded = showMenu, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
expanded = true
|
||||
},
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showMenu) },
|
||||
keyboardOptions = keyboardOptions,
|
||||
supportingText = supportingText?.let { { Text(it) } },
|
||||
modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = showMenu, onDismissRequest = { expanded = false }) {
|
||||
filtered.forEach { opt ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(opt.label) },
|
||||
onClick = {
|
||||
onValueChange(opt.value)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user