mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
fix(desktop): sync agent relay profile when persona avatar changes (#1512)
Signed-off-by: Kenny Lopez <kenny@block.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -5,9 +5,10 @@ use super::export_util::save_json_with_dialog;
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
agent_events::ManagedAgentEventContent, encode_persona_json, load_managed_agents,
|
||||
load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona,
|
||||
parse_zip_personas, persona_events::persona_d_tag, save_managed_agents, save_personas,
|
||||
agent_events::ManagedAgentEventContent, effective_agent_command, encode_persona_json,
|
||||
load_managed_agents, load_personas, load_teams, managed_agent_avatar_url,
|
||||
parse_json_persona, parse_md_persona, parse_png_persona, parse_zip_personas,
|
||||
persona_events::persona_d_tag, save_managed_agents, save_personas,
|
||||
team_events::TeamEventContent, team_persona_key, try_regenerate_nest,
|
||||
validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest,
|
||||
ManagedAgentRecord, ParsePersonaFilesResult, PersonaRecord, TeamRecord,
|
||||
@@ -225,67 +226,158 @@ pub async fn update_persona(
|
||||
app: AppHandle,
|
||||
) -> Result<PersonaRecord, String> {
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
// Do not trim system_prompt: `compose_prompt` appends pack_instructions
|
||||
// verbatim (including any trailing newline), and write_back_persona_md
|
||||
// decomposes by suffix-stripping. Trimming would break that exact-suffix
|
||||
// match for the common case where instructions.md has a trailing newline.
|
||||
let system_prompt = input.system_prompt.clone();
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
let runtime = trim_optional(input.runtime);
|
||||
let model = trim_optional(input.model);
|
||||
let provider = trim_optional(input.provider);
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} not found", input.id))?;
|
||||
/// Profile sync params collected under the store lock for async relay publish.
|
||||
type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option<String>, Option<String>)>;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be edited.".to_string());
|
||||
// Phase 1: synchronous save (persona record + linked agent avatar updates)
|
||||
let (result, profile_sync_params) = tokio::task::spawn_blocking({
|
||||
let app = app.clone();
|
||||
move || -> Result<(PersonaRecord, ProfileSyncParams), String> {
|
||||
let state = app.state::<AppState>();
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
// Do not trim system_prompt: `compose_prompt` appends pack_instructions
|
||||
// verbatim (including any trailing newline), and write_back_persona_md
|
||||
// decomposes by suffix-stripping. Trimming would break that exact-suffix
|
||||
// match for the common case where instructions.md has a trailing newline.
|
||||
let system_prompt = input.system_prompt.clone();
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
let runtime = trim_optional(input.runtime);
|
||||
let model = trim_optional(input.model);
|
||||
let provider = trim_optional(input.provider);
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} not found", input.id))?;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be edited.".to_string());
|
||||
}
|
||||
|
||||
// Track whether avatar changed so we can sync linked agents.
|
||||
let avatar_changed = persona.avatar_url != avatar_url;
|
||||
|
||||
persona.display_name = display_name;
|
||||
persona.avatar_url = avatar_url;
|
||||
persona.system_prompt = system_prompt;
|
||||
persona.runtime = runtime;
|
||||
persona.model = model;
|
||||
persona.provider = provider;
|
||||
persona.name_pool = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if let Some(env_vars) = input.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(&env_vars)?;
|
||||
persona.env_vars = env_vars;
|
||||
}
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
save_personas(&app, &personas)?;
|
||||
let result = personas
|
||||
.into_iter()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
|
||||
|
||||
// For pack-backed personas, also write the edit back to the source
|
||||
// `.persona.md` so that launch sync (which reads the file) becomes a
|
||||
// no-op rather than overwriting the record we just saved.
|
||||
write_back_persona_md(&app, &result);
|
||||
|
||||
retain_persona_pending(&app, &state, &result);
|
||||
try_regenerate_nest(&app);
|
||||
|
||||
// If the avatar changed, propagate to linked agent records and
|
||||
// collect relay profile sync params for the async phase.
|
||||
let sync_params: ProfileSyncParams = if avatar_changed {
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut params: ProfileSyncParams = Vec::new();
|
||||
let mut agents_modified = false;
|
||||
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
|
||||
|
||||
for record in records.iter_mut() {
|
||||
if record.persona_id.as_deref() != Some(&result.id) {
|
||||
continue;
|
||||
}
|
||||
// Update the persisted avatar so reconciliation on next
|
||||
// start agrees with what we're about to publish.
|
||||
// When the persona avatar is cleared, fall back to the
|
||||
// command-default icon so the record never stores `None`
|
||||
// (which reconcile_agent_profile treats as "un-migrated").
|
||||
let effective_cmd = effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
std::slice::from_ref(&result),
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
record.avatar_url = result
|
||||
.avatar_url
|
||||
.clone()
|
||||
.or_else(|| managed_agent_avatar_url(&effective_cmd));
|
||||
agents_modified = true;
|
||||
|
||||
if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) {
|
||||
let relay_url = crate::relay::effective_agent_relay_url(
|
||||
&record.relay_url,
|
||||
&workspace_relay,
|
||||
);
|
||||
params.push((
|
||||
agent_keys,
|
||||
relay_url,
|
||||
record.name.clone(),
|
||||
record.avatar_url.clone(),
|
||||
record.auth_tag.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if agents_modified {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
params
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok((result, sync_params))
|
||||
}
|
||||
persona.display_name = display_name;
|
||||
persona.avatar_url = avatar_url;
|
||||
persona.system_prompt = system_prompt;
|
||||
persona.runtime = runtime;
|
||||
persona.model = model;
|
||||
persona.provider = provider;
|
||||
persona.name_pool = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if let Some(env_vars) = input.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(&env_vars)?;
|
||||
persona.env_vars = env_vars;
|
||||
}
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
save_personas(&app, &personas)?;
|
||||
let result = personas
|
||||
.into_iter()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
|
||||
|
||||
// For pack-backed personas, also write the edit back to the source
|
||||
// `.persona.md` so that launch sync (which reads the file) becomes a
|
||||
// no-op rather than overwriting the record we just saved.
|
||||
write_back_persona_md(&app, &result);
|
||||
|
||||
retain_persona_pending(&app, &state, &result);
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
|
||||
|
||||
// Phase 2: await relay profile sync for linked agents whose avatar was
|
||||
// just updated. We await (rather than fire-and-forget) so the frontend
|
||||
// cache invalidation that follows the mutation settlement sees the fresh
|
||||
// relay profile. Best-effort — failures are logged, not surfaced.
|
||||
if !profile_sync_params.is_empty() {
|
||||
let state = app.state::<AppState>();
|
||||
for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params {
|
||||
if let Err(e) = crate::relay::sync_managed_agent_profile(
|
||||
&state,
|
||||
&relay_url,
|
||||
&agent_keys,
|
||||
&display_name,
|
||||
avatar_url.as_deref(),
|
||||
auth_tag.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
"buzz-desktop: relay profile sync failed after persona avatar update: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
mod writeback;
|
||||
|
||||
@@ -326,10 +326,32 @@ export function useUpdatePersonaMutation() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpdatePersonaInput) => updatePersona(input),
|
||||
onSettled: async () => {
|
||||
onSettled: async (_data, _error, variables) => {
|
||||
// Evict per-pubkey users-batch-entry caches for agents linked to this
|
||||
// persona so the subsequent batch invalidation refetches fresh profiles
|
||||
// instead of re-reading stale entries (mirrors useUpdateManagedAgentMutation).
|
||||
const agents = queryClient.getQueryData<ManagedAgent[]>(
|
||||
managedAgentsQueryKey,
|
||||
);
|
||||
if (agents) {
|
||||
const linkedPubkeys = agents
|
||||
.filter((a) => a.personaId === variables.id)
|
||||
.map((a) => a.pubkey.toLowerCase());
|
||||
evictUsersBatchEntries(queryClient, linkedPubkeys);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
|
||||
// Persona avatar changes re-sync linked agents' relay profiles;
|
||||
// invalidate cached user-profile and users-batch queries so the UI
|
||||
// picks up the updated kind:0 picture without waiting for staleTime
|
||||
// expiry — covers agent cards, message timelines, and member lists.
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "user-profile" ||
|
||||
query.queryKey[0] === "users-batch",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ function AgentPersonaCard({
|
||||
const isActive = agent ? isManagedAgentActive(agent) : false;
|
||||
const profileQuery = useUserProfileQuery(agent?.pubkey);
|
||||
const avatarUrl = agent
|
||||
? firstAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl)
|
||||
? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl)
|
||||
: persona.avatarUrl;
|
||||
const friendlyError = agent
|
||||
? friendlyAgentLastError(agent.lastError)?.copy
|
||||
|
||||
Reference in New Issue
Block a user