diff --git a/desktop/src-tauri/assets/card_template.png b/desktop/src-tauri/assets/card_template.png index 2225d1d442b..b3d70b827e6 100644 Binary files a/desktop/src-tauri/assets/card_template.png and b/desktop/src-tauri/assets/card_template.png differ diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2b..8afafea6805 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -19,9 +19,10 @@ //! resize + chunk injection) via `validate_snapshot_encode_size`. //! - Round-trip verification decodes the final bytes and compares the logical //! manifest before anything is returned to the frontend. -//! - The API key is resolved through the same env layering the agent runtime -//! uses (global config < persona < agent record) and never leaves Rust. -//! It is never logged. +//! - A card-only API key is stored in the OS credential store and takes +//! precedence. Existing agent/persona/global/process keys remain a backwards- +//! compatible fallback, but card setup never mutates agent configuration. +//! The resolved key never leaves Rust and is never logged. use base64::{engine::general_purpose::STANDARD, Engine as _}; use serde::{Deserialize, Serialize}; @@ -44,14 +45,23 @@ use crate::{ decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload, }, load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas, - save_global_agent_config, validate_global_config, }, + secret_store::SecretStore, }; -/// The Buzz card frame template — Tyler's gold-honeycomb base. Generation -/// input only: it never participates in the snapshot manifest, PNG chunk, -/// import decoder, or attachment validation. Embedded at compile time for -/// deterministic packaging (see `card_template_decodes` test). +mod avatar; +use avatar::{preferred_avatar_url, resolve_inline_avatar_bytes}; +mod generation_input; +use generation_input::{ + build_card_followup_instructions, build_card_instructions, decode_reference_card, +}; +mod response; +use response::extract_card_output; + +/// The Buzz card frame template — a raster of the SVG used by Export. It never +/// participates in the snapshot manifest, PNG chunk, import decoder, or +/// attachment validation. Embedded at compile time for deterministic +/// packaging (see `card_template_decodes` test). const CARD_TEMPLATE_PNG: &[u8] = include_bytes!("../../../assets/card_template.png"); /// Designer model driving copy + art direction. @@ -66,9 +76,16 @@ const CARD_WIDTH: u32 = 1500; const MANIFEST_AVATAR_MAX_DIM: u32 = 512; /// Upper bound for a fetched avatar (pre-resize input to the model). const MAX_AVATAR_FETCH_BYTES: usize = 10 * 1024 * 1024; +/// A previous verified card may be supplied as image context for a revision. +const MAX_REFERENCE_CARD_BYTES: usize = 10 * 1024 * 1024; /// One mint is a single long API call (~2–3 minutes observed). const MINT_TIMEOUT_SECS: u64 = 600; +/// Dedicated OS-credential-store slot for custom-card generation. Keeping it +/// outside `GlobalAgentConfig.env_vars` prevents card setup from changing the +/// environment inherited by local agents. +const CARD_OPENAI_KEY_NAME: &str = "custom-card-openai-api-key"; + /// Error prefix the frontend matches to route the user to provider settings /// instead of showing a raw failure. pub(crate) const NO_KEY_ERROR_PREFIX: &str = "NO_OPENAI_KEY:"; @@ -287,11 +304,15 @@ pub(crate) fn resolve_env_from_layers( /// returns which layer supplies `OPENAI_API_KEY` (agent > persona > global > /// process > none). pub(crate) fn resolve_key_layer( + dedicated_key: Option<&str>, global_env: &std::collections::BTreeMap, persona_env: &std::collections::BTreeMap, record_env: &std::collections::BTreeMap, process_value: Option, ) -> &'static str { + if dedicated_key.is_some_and(|key| !key.trim().is_empty()) { + return "card"; + } let key = "OPENAI_API_KEY"; let nonempty = |m: &std::collections::BTreeMap| { m.get(key).is_some_and(|v| !v.trim().is_empty()) @@ -312,6 +333,39 @@ pub(crate) fn resolve_key_layer( "none" } +/// Resolve the key used by custom-card generation. A dedicated credential is +/// intentionally first so legacy agent configuration can remain untouched. +pub(crate) fn resolve_card_api_key( + dedicated_key: Option, + global_env: &std::collections::BTreeMap, + persona_env: &std::collections::BTreeMap, + record_env: &std::collections::BTreeMap, + process_value: Option, +) -> Option { + dedicated_key + .filter(|key| !key.trim().is_empty()) + .or_else(|| { + resolve_env_from_layers( + "OPENAI_API_KEY", + global_env, + persona_env, + record_env, + process_value, + ) + }) +} + +fn card_key_store() -> &'static SecretStore { + SecretStore::shared(crate::app_state::keyring_service()) +} + +fn load_dedicated_card_key() -> Result, String> { + card_key_store() + .load(CARD_OPENAI_KEY_NAME) + .map(|key| key.filter(|value| !value.trim().is_empty())) + .map_err(|error| format!("Could not access the custom-card API key: {error}")) +} + /// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env /// layering as the key) overrides the default host, supporting endpoints and /// proxies that speak the OpenAI Responses shape with Bearer auth. Azure @@ -322,52 +376,6 @@ pub(crate) fn responses_url(base_url: Option) -> String { format!("{}/responses", base.trim_end_matches('/')) } -// ── Prompt construction ─────────────────────────────────────────────────────── - -/// Build the designer instructions. Pure so tests can pin the contract: -/// style-match-the-avatar is DEFAULT behavior; owner directions (art AND -/// card text) take primacy over those style defaults, but never over the -/// fixed contract (frame identity, geometry, text fidelity). -pub(crate) fn build_card_instructions( - agent_name: &str, - persona_notes: &str, - style_notes: &str, -) -> String { - let owner_directions = if style_notes.trim().is_empty() { - String::new() - } else { - format!( - "\nOWNER'S DIRECTIONS — these override the default art-style and copy guidance \ - below wherever they conflict (they cannot change the frame, layout, or \ - text-fidelity requirements). The owner may direct the art, the card text \ - (type line, ability, flavor), or both:\n{style_notes}\n" - ) - }; - format!( - r#"You are designing one premium collectible trading card for the Buzz agent "{agent_name}". - -Input image 1 is the official Buzz card frame template (gold honeycomb border, dark interior, name banner top, hex badge top-right, text box lower third). Input image 2 is the agent's avatar — study its exact art style: medium, pixel grid if any, palette, shading, background motifs. - -Persona notes for the card copy: -{persona_notes} -{owner_directions} -First, write professional trading-card copy at Magic: The Gathering editorial quality: -- a type line (e.g. "Legendary Agent — Team Lead"), -- ONE keyworded ability: short bolded ability name + one sentence of crisp rules text written like real MTG rules (present tense, precise, no fluff), -- ONE italic flavor-text line, evocative and short, the kind that gets quoted. -Where the owner's directions specify card text, use their wording within the 220-character text-box limit below (edited only for spelling; if their text exceeds the limit, condense it minimally while keeping their words and intent); invent copy only for the parts they left open. -Keep total text-box copy under 220 characters so it renders cleanly. - -Then generate the finished card with the image tool, exactly 1024x1536 portrait: -- The frame must follow input image 1 faithfully: same gold honeycomb border, same layout, honey drip detail. -- Default art style: match input image 2's art style EXACTLY — same medium, same pixel density if pixel art, same palette, same background honeycomb-lattice sky. It must look like the same artist drew a larger scene: the character in a confident pose, conjuring glowing golden hexagons. The owner's directions above override any of this default styling where they conflict. -- Name banner: "{agent_name}" plus the type line beneath it in smaller type. -- Text box: the ability name in bold, rules text in regular, then the flavor line in italics, cleanly typeset like a real MTG card — professional kerning, no misspellings, hyphenate nothing. -- Top-right hex badge: one small emblem of your choice, no text. -Render all text with perfect fidelity."# - ) -} - /// Encode raw image bytes as a `data:image/png;base64,` URL, downscaling to /// `max_dim` on the longest edge so request payloads stay small. fn image_data_url(bytes: &[u8], max_dim: u32) -> Result { @@ -391,92 +399,39 @@ fn png_bytes_resized(bytes: &[u8], max_dim: u32) -> Result, String> { Ok(png) } -// ── Response parsing ────────────────────────────────────────────────────────── - -/// Extract the generated image (base64) and any designer text from a -/// Responses API payload. Pure for testability. -pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, String), String> { - let output = resp - .get("output") - .and_then(|o| o.as_array()) - .ok_or_else(|| "Responses payload has no output array".to_string())?; - - let mut image_b64 = None; - let mut notes = Vec::new(); - for item in output { - match item.get("type").and_then(|t| t.as_str()) { - Some("image_generation_call") => { - if let Some(result) = item.get("result").and_then(|r| r.as_str()) { - image_b64 = Some(result.to_string()); - } - } - Some("message") => { - if let Some(content) = item.get("content").and_then(|c| c.as_array()) { - for c in content { - if c.get("type").and_then(|t| t.as_str()) == Some("output_text") { - if let Some(text) = c.get("text").and_then(|t| t.as_str()) { - notes.push(text.to_string()); - } - } - } - } - } - _ => {} - } - } - - let image_b64 = image_b64.ok_or_else(|| { - let types: Vec<&str> = output - .iter() - .filter_map(|i| i.get("type").and_then(|t| t.as_str())) - .collect(); - format!("No image in Responses output (item types: {types:?})") - })?; - Ok((image_b64, notes.join("\n"))) -} - // ── Commands ────────────────────────────────────────────────────────────────── -/// Save an `OPENAI_API_KEY` into the global Agent Defaults env for card -/// minting — a narrow seam with deliberately different semantics from the -/// general `set_global_agent_config`: -/// -/// - **No agent restarts.** The general command stops/restarts every running -/// local agent whose effective env changes, because agent env is baked at -/// spawn time. The mint command re-reads the config from disk on every -/// mint, so minting needs no restart — and a card setup must never disrupt -/// running agents as a side effect. Agents pick the key up naturally on -/// their next (re)start. -/// - **Read-modify-write of the latest on-disk config.** The config is -/// re-read immediately before the single-key insert + write (under the -/// managed-agents store lock, which serializes it against the other card -/// and agent-store commands), so a settings save that landed after this -/// dialog opened is not clobbered with a stale dialog-open snapshot. -/// (The general settings editor performs its own whole-config write; as -/// today, the last writer wins between the two surfaces.) -/// -/// Standard global-config validation still applies (POSIX key shape, -/// reserved-key reject, size caps) — this is not a validation bypass. +/// Save a dedicated OpenAI key for custom-card generation in the OS credential +/// store. This never reads or writes Agent Defaults and therefore cannot alter +/// an agent's inherited environment or restart a running agent. #[tauri::command] -pub fn card_mint_save_openai_key( - key: String, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { +pub fn card_mint_save_openai_key(key: String) -> Result<(), String> { let key = key.trim().to_string(); if key.is_empty() { return Err("API key cannot be empty.".to_string()); } + if key.len() > 4096 { + return Err("API key is too long.".to_string()); + } + card_key_store() + .store(CARD_OPENAI_KEY_NAME, &key) + .map_err(|error| format!("Could not save the custom-card API key: {error}")) +} - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; +/// Whether a dedicated custom-card key is present. The key value never crosses +/// the IPC boundary. +#[tauri::command] +pub fn card_mint_dedicated_key_status() -> Result { + Ok(load_dedicated_card_key()?.is_some()) +} - let mut config = load_global_agent_config(&app)?; - config.env_vars.insert("OPENAI_API_KEY".to_string(), key); - validate_global_config(&config)?; - save_global_agent_config(&app, &config) +/// Remove only the dedicated custom-card key. Agent Defaults and every agent- +/// specific environment remain untouched. +#[tauri::command] +pub fn card_mint_delete_openai_key() -> Result<(), String> { + card_key_store() + .delete(CARD_OPENAI_KEY_NAME) + .map_err(|error| format!("Could not remove the custom-card API key: {error}")) } /// Report which env layer resolves the OpenAI key for a card mint of agent @@ -488,6 +443,7 @@ pub fn card_mint_key_status( app: AppHandle, state: State<'_, AppState>, ) -> Result { + let dedicated_key = load_dedicated_card_key()?; let _store_guard = state .managed_agents_store_lock .lock() @@ -507,6 +463,7 @@ pub fn card_mint_key_status( .unwrap_or_default(); Ok(resolve_key_layer( + dedicated_key.as_deref(), &global.env_vars, &persona_env, &record.env_vars, @@ -532,11 +489,14 @@ pub fn card_mint_key_status( /// Returns the final, chunk-injected, round-trip-verified `.agent.png` bytes. /// Reroll = call again; the command holds no session state. #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri exposes these as named IPC fields. pub async fn mint_agent_card( id: String, style_notes: Option, lock: Option, memory_level: Option, + avatar_data_url: Option, + reference_card_png_base64: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -563,18 +523,15 @@ pub async fn mint_agent_card( .map(|p| p.env_vars.clone()) .unwrap_or_default(); - let api_key = resolve_env_from_layers( - "OPENAI_API_KEY", + let api_key = resolve_card_api_key( + load_dedicated_card_key()?, &global.env_vars, &persona_env, &record.env_vars, std::env::var("OPENAI_API_KEY").ok(), ) .ok_or_else(|| { - format!( - "{NO_KEY_ERROR_PREFIX} No OPENAI_API_KEY found. Add one in the agent's \ - environment variables or global agent settings to mint cards." - ) + format!("{NO_KEY_ERROR_PREFIX} No OpenAI API key found for custom-card creation.") })?; let base_url = resolve_env_from_layers( "OPENAI_BASE_URL", @@ -633,6 +590,7 @@ pub async fn mint_agent_card( let listing = get_agent_memory(record.pubkey.clone(), app.clone(), state.clone()).await?; memory_entries_from_listing(listing, memory_level) }; + let reference_card_bytes = decode_reference_card(reference_card_png_base64.as_deref())?; let display_name = record .display_name @@ -664,8 +622,9 @@ pub async fn mint_agent_card( // ── Resolve avatar bytes (data URL, else fetch) ────────────────────────── let avatar_bytes = match record.avatar_url.as_deref() { - Some(url) if url.starts_with("data:") => decode_avatar_data_url(url) - .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, + Some(url) if url.starts_with("data:") => { + resolve_inline_avatar_bytes(url, avatar_data_url.as_deref())? + } Some(url) if url.starts_with("http://") || url.starts_with("https://") => { // Relay-hosted avatars (kind:0 pictures under the relay's /media/) // require Blossom get-auth. Mint the header ONLY for same-origin URLs @@ -718,26 +677,36 @@ pub async fn mint_agent_card( )); } } - let instructions = build_card_instructions( - &display_name, - snapshot.definition.system_prompt.as_deref().unwrap_or(""), - style_notes.as_deref().unwrap_or(""), - ); + let persona_notes = snapshot.definition.system_prompt.as_deref().unwrap_or(""); + let style_notes = style_notes.as_deref().unwrap_or(""); + let instructions = if reference_card_bytes.is_some() { + build_card_followup_instructions(&display_name, persona_notes, style_notes) + } else { + build_card_instructions(&display_name, persona_notes, style_notes) + }; + let mut input_content = vec![ + serde_json::json!({"type": "input_text", "text": instructions}), + serde_json::json!({"type": "input_image", "image_url": image_data_url(CARD_TEMPLATE_PNG, 1024)?}), + serde_json::json!({"type": "input_image", "image_url": image_data_url(&avatar_bytes, 1024)?}), + ]; + if let Some(reference_card_bytes) = reference_card_bytes.as_deref() { + input_content.push(serde_json::json!({ + "type": "input_image", + "image_url": image_data_url(reference_card_bytes, 1024)?, + })); + } let body = serde_json::json!({ "model": DESIGNER_MODEL, "reasoning": {"effort": "high"}, "instructions": "You are a senior TCG card designer and MTG rules editor.", "input": [{ "role": "user", - "content": [ - {"type": "input_text", "text": instructions}, - {"type": "input_image", "image_url": image_data_url(CARD_TEMPLATE_PNG, 1024)?}, - {"type": "input_image", "image_url": image_data_url(&avatar_bytes, 1024)?}, - ], + "content": input_content, }], "tools": [{ "type": "image_generation", "model": IMAGE_MODEL, + "action": if reference_card_bytes.is_some() { "edit" } else { "generate" }, "quality": "high", "size": "1024x1536", "output_format": "png", @@ -758,6 +727,11 @@ pub async fn mint_agent_card( .map_err(|e| format!("Card mint request failed: {e}"))?; let status = resp.status(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); let payload: serde_json::Value = resp .json() .await @@ -773,7 +747,12 @@ pub async fn mint_agent_card( return Err(format!("Card mint failed (HTTP {status}): {detail}")); } - let (image_b64, designer_notes) = extract_card_output(&payload)?; + let (image_b64, designer_notes) = extract_card_output(&payload).map_err(|error| { + if let Some(request_id) = request_id { + eprintln!("buzz-desktop: custom-card request {request_id} returned no image: {error}"); + } + error + })?; let raw_card = STANDARD .decode(image_b64.as_bytes()) .map_err(|e| format!("Generated image was not valid base64: {e}"))?; @@ -853,20 +832,6 @@ pub async fn mint_agent_card( Ok(minted) } -/// The avatar the mint should use: the agent's kind:0 `picture` when one is -/// published and non-blank, else the local record's `avatar_url`. -/// -/// Pure so the precedence is unit-testable without a relay: a blank or -/// whitespace-only `picture` must NOT shadow a real record avatar. -fn preferred_avatar_url( - kind0_picture: Option, - record_avatar_url: Option, -) -> Option { - kind0_picture - .filter(|p| !p.trim().is_empty()) - .or(record_avatar_url) -} - /// The avatar bytes the card manifest should inline. /// /// Unlocked cards must carry the agent's REAL avatar inline: the PNG body is diff --git a/desktop/src-tauri/src/commands/personas/card/avatar.rs b/desktop/src-tauri/src/commands/personas/card/avatar.rs new file mode 100644 index 00000000000..9faedeec39e --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/avatar.rs @@ -0,0 +1,49 @@ +use super::{decode_avatar_data_url, MAX_AVATAR_FETCH_BYTES}; + +/// Prefer the agent's published profile picture when it is non-blank. +pub(super) fn preferred_avatar_url( + kind0_picture: Option, + record_avatar_url: Option, +) -> Option { + kind0_picture + .filter(|picture| !picture.trim().is_empty()) + .or(record_avatar_url) +} + +/// Resolve a native-decodable raster from an inline avatar. +/// +/// Buzz emoji avatars are deliberately stored as percent-encoded SVG data +/// URLs so WebKit can render them crisply. The image crate does not decode +/// SVG, so Export may provide a PNG rasterized from that exact URL. The +/// fallback is accepted only when the authoritative avatar is SVG; ordinary +/// raster data URLs must decode on their own and cannot be replaced by an +/// arbitrary caller-provided image. +pub(super) fn resolve_inline_avatar_bytes( + source_url: &str, + svg_raster_fallback: Option<&str>, +) -> Result, String> { + if let Some(bytes) = decode_raster_data_url(source_url) { + return Ok(bytes); + } + + if source_url.split_once(',').is_some_and(|(header, _)| { + header + .strip_prefix("data:") + .and_then(|metadata| metadata.split(';').next()) + .is_some_and(|mime| mime.eq_ignore_ascii_case("image/svg+xml")) + }) { + if let Some(bytes) = svg_raster_fallback.and_then(decode_raster_data_url) { + return Ok(bytes); + } + } + + Err("Agent avatar data URL could not be decoded.".to_string()) +} + +fn decode_raster_data_url(url: &str) -> Option> { + let bytes = decode_avatar_data_url(url)?; + if bytes.len() > MAX_AVATAR_FETCH_BYTES || image::load_from_memory(&bytes).is_err() { + return None; + } + Some(bytes) +} diff --git a/desktop/src-tauri/src/commands/personas/card/generation_input.rs b/desktop/src-tauri/src/commands/personas/card/generation_input.rs new file mode 100644 index 00000000000..53ae4863445 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/generation_input.rs @@ -0,0 +1,81 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; + +use super::MAX_REFERENCE_CARD_BYTES; + +/// Build the designer instructions. Pure so tests can pin the contract: +/// style-match-the-avatar is DEFAULT behavior; owner directions (art AND +/// card text) take primacy over those style defaults, but never over the +/// fixed contract (frame identity, geometry, text fidelity). +pub(crate) fn build_card_instructions( + agent_name: &str, + persona_notes: &str, + style_notes: &str, +) -> String { + let owner_directions = if style_notes.trim().is_empty() { + String::new() + } else { + format!( + "\nOWNER'S DIRECTIONS — these override the default art-style and copy guidance \ + below wherever they conflict (they cannot change the frame, layout, or \ + text-fidelity requirements). The owner may direct the art, the card text \ + (type line, ability, flavor), or both:\n{style_notes}\n" + ) + }; + format!( + r#"You are designing one premium collectible trading card for the Buzz agent "{agent_name}". + +Input image 1 is the official Buzz card template: a rounded 2:3 portrait card with an iridescent foil perimeter, a white inner panel, Buzz Agent branding and card number at the top, a large circular art window, a bold name and subtitle below it, and two compact metadata columns at the bottom. Input image 2 is the agent's avatar — study its exact art style: medium, pixel grid if any, palette, shading, and background motifs. + +Persona notes for the card copy: +{persona_notes} +{owner_directions} +First, write concise card copy that describes this agent: +- a short subtitle beneath the agent name, +- one compact "Good for" phrase, +- one compact "Vibes" phrase. +Where the owner's directions specify card text, use their wording within the 220-character text-box limit below (edited only for spelling; if their text exceeds the limit, condense it minimally while keeping their words and intent); invent copy only for the parts they left open. +Keep the total generated card copy under 220 characters so it renders cleanly. + +Then generate the finished card with the image tool, exactly 1024x1536 portrait: +- The frame and information architecture must follow input image 1 faithfully: keep its concentric rounded geometry, iridescent foil perimeter, white inner panel, top brand/number row, circular art mask, lower name block, and two-column metadata row. +- Default art style: match input image 2's art style EXACTLY — same medium, same pixel density if pixel art, palette, and shading. Replace the template's placeholder portrait with a custom interpretation of this agent inside the same circular art mask. The owner's directions above override the default art styling where they conflict. +- Use "{agent_name}" as the bold card name and set the short subtitle directly beneath it. +- Use the bottom two columns for "Good for" and "Vibes". Keep both phrases brief enough to fit without crowding. +- Derive the foil perimeter palette from the avatar unless the owner's directions explicitly request a different palette. +Render all text with perfect fidelity."# + ) +} + +pub(crate) fn build_card_followup_instructions( + agent_name: &str, + persona_notes: &str, + style_notes: &str, +) -> String { + format!( + "{}\n\nFOLLOW-UP REVISION: Input image 3 is the current finished card. Apply the \ + owner's directions as a revision to that card. Preserve every visual and textual \ + detail the owner did not ask to change, while continuing to obey the fixed Buzz \ + template, geometry, and text-fidelity requirements.", + build_card_instructions(agent_name, persona_notes, style_notes) + ) +} + +pub(crate) fn decode_reference_card( + reference_card_png_base64: Option<&str>, +) -> Result>, String> { + let Some(encoded) = reference_card_png_base64.filter(|value| !value.trim().is_empty()) else { + return Ok(None); + }; + if encoded.len() > MAX_REFERENCE_CARD_BYTES.div_ceil(3) * 4 { + return Err("The follow-up card reference is too large.".to_string()); + } + let bytes = STANDARD + .decode(encoded.as_bytes()) + .map_err(|_| "The follow-up card reference is not valid base64.".to_string())?; + if bytes.len() > MAX_REFERENCE_CARD_BYTES { + return Err("The follow-up card reference is too large.".to_string()); + } + image::load_from_memory(&bytes) + .map_err(|_| "The follow-up card reference is not a valid image.".to_string())?; + Ok(Some(bytes)) +} diff --git a/desktop/src-tauri/src/commands/personas/card/response.rs b/desktop/src-tauri/src/commands/personas/card/response.rs new file mode 100644 index 00000000000..1b6a470f0d6 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/response.rs @@ -0,0 +1,113 @@ +//! Parsing for the Responses API image-generation output. +//! +//! The API can return a successful top-level response even when every hosted +//! image tool call failed. Keep those failures readable without echoing the +//! full payload (which can contain large image results and model reasoning). + +const MAX_FAILURE_DETAIL_CHARS: usize = 600; + +/// Extract the generated image (base64) and any designer text from a +/// Responses API payload. Pure for testability. +pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, String), String> { + let output = resp + .get("output") + .and_then(|o| o.as_array()) + .ok_or_else(|| "OpenAI returned a response without an output array.".to_string())?; + + let mut image_b64 = None; + let mut notes = Vec::new(); + let mut call_statuses = Vec::new(); + let mut call_errors = Vec::new(); + + for item in output { + match item.get("type").and_then(|t| t.as_str()) { + Some("image_generation_call") => { + if let Some(status) = item.get("status").and_then(|s| s.as_str()) { + call_statuses.push(status.to_string()); + } + if let Some(result) = item + .get("result") + .and_then(|r| r.as_str()) + .filter(|r| !r.is_empty()) + { + image_b64 = Some(result.to_string()); + } + if let Some(error) = item.get("error") { + if let Some(message) = error.get("message").and_then(|m| m.as_str()) { + call_errors.push(message.to_string()); + } else if let Some(code) = error.get("code").and_then(|c| c.as_str()) { + call_errors.push(code.to_string()); + } + } + } + Some("message") => collect_message_text(item, &mut notes), + _ => {} + } + } + + if let Some(image_b64) = image_b64 { + return Ok((image_b64, notes.join("\n"))); + } + + let detail = if !notes.is_empty() { + Some(notes.join(" ")) + } else if !call_errors.is_empty() { + Some(call_errors.join(" ")) + } else { + resp.get("incomplete_details") + .and_then(|details| details.get("reason")) + .and_then(|reason| reason.as_str()) + .map(str::to_string) + }; + + let mut message = "OpenAI did not return a card image".to_string(); + if let Some(detail) = detail { + let detail = compact_and_truncate(&detail, MAX_FAILURE_DETAIL_CHARS); + if !detail.is_empty() { + message.push_str(": "); + message.push_str(&detail); + } + } else if !call_statuses.is_empty() { + message.push_str(&format!( + " (image generation statuses: {})", + call_statuses.join(", ") + )); + } else { + message.push_str(". Try again, or revise the card description if the problem repeats"); + } + if !message.ends_with(['.', '!', '?']) { + message.push('.'); + } + Err(message) +} + +fn collect_message_text(item: &serde_json::Value, notes: &mut Vec) { + let Some(content) = item.get("content").and_then(|c| c.as_array()) else { + return; + }; + for part in content { + match part.get("type").and_then(|t| t.as_str()) { + Some("output_text") => { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + notes.push(text.to_string()); + } + } + Some("refusal") => { + if let Some(text) = part.get("refusal").and_then(|t| t.as_str()) { + notes.push(text.to_string()); + } + } + _ => {} + } + } +} + +fn compact_and_truncate(value: &str, max_chars: usize) -> String { + let compact = value.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= max_chars { + return compact; + } + let mut truncated = compact.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index 407ab449744..45c3633d8cb 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -82,12 +82,15 @@ fn key_status_layer_matches_mint_resolution_priority() { let mut record = BTreeMap::new(); // No key anywhere → "none" - assert_eq!(resolve_key_layer(&global, &persona, &record, None), "none"); + assert_eq!( + resolve_key_layer(None, &global, &persona, &record, None), + "none" + ); // Only global → "global" (the only writable layer) global.insert(key.to_string(), "sk-global".to_string()); assert_eq!( - resolve_key_layer(&global, &persona, &record, None), + resolve_key_layer(None, &global, &persona, &record, None), "global" ); // mint resolution also picks global when record and persona are empty @@ -99,7 +102,7 @@ fn key_status_layer_matches_mint_resolution_priority() { // Persona overrides global → status must report "persona", NOT "global" persona.insert(key.to_string(), "sk-persona".to_string()); assert_eq!( - resolve_key_layer(&global, &persona, &record, None), + resolve_key_layer(None, &global, &persona, &record, None), "persona" ); // mint would use the persona key @@ -119,7 +122,10 @@ fn key_status_layer_matches_mint_resolution_priority() { // Agent record overrides both → status must report "agent" record.insert(key.to_string(), "sk-agent".to_string()); - assert_eq!(resolve_key_layer(&global, &persona, &record, None), "agent"); + assert_eq!( + resolve_key_layer(None, &global, &persona, &record, None), + "agent" + ); assert_eq!( resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(), Some("sk-agent") @@ -128,7 +134,7 @@ fn key_status_layer_matches_mint_resolution_priority() { // Process env is last resort (only when all map layers are empty) let empty = BTreeMap::new(); assert_eq!( - resolve_key_layer(&empty, &empty, &empty, Some("sk-process".to_string())), + resolve_key_layer(None, &empty, &empty, &empty, Some("sk-process".to_string())), "process" ); @@ -137,6 +143,7 @@ fn key_status_layer_matches_mint_resolution_priority() { blank_global.insert(key.to_string(), " ".to_string()); assert_eq!( resolve_key_layer( + None, &blank_global, &empty, &empty, @@ -144,6 +151,19 @@ fn key_status_layer_matches_mint_resolution_priority() { ), "process" ); + + // A dedicated custom-card key wins over every agent environment layer so + // adding it never needs to rewrite those layers. + assert_eq!( + resolve_key_layer( + Some("sk-card-only"), + &global, + &persona, + &record, + Some("sk-process".to_string()) + ), + "card" + ); } #[test] @@ -159,6 +179,31 @@ fn key_resolution_skips_blank_values() { ); } +#[test] +fn dedicated_card_key_wins_without_mutating_agent_layers() { + let mut global = BTreeMap::new(); + global.insert("OPENAI_API_KEY".to_string(), "sk-global".to_string()); + let mut persona = BTreeMap::new(); + persona.insert("OPENAI_API_KEY".to_string(), "sk-persona".to_string()); + let mut record = BTreeMap::new(); + record.insert("OPENAI_API_KEY".to_string(), "sk-agent".to_string()); + + assert_eq!( + resolve_card_api_key( + Some("sk-card-only".to_string()), + &global, + &persona, + &record, + Some("sk-process".to_string()) + ) + .as_deref(), + Some("sk-card-only") + ); + assert_eq!(global["OPENAI_API_KEY"], "sk-global"); + assert_eq!(persona["OPENAI_API_KEY"], "sk-persona"); + assert_eq!(record["OPENAI_API_KEY"], "sk-agent"); +} + #[test] fn responses_url_default_and_override() { assert_eq!(responses_url(None), "https://api.openai.com/v1/responses"); @@ -177,6 +222,10 @@ fn responses_url_default_and_override() { fn instructions_pin_style_match_default_and_owner_primacy() { let base = build_card_instructions("Eva", "leads the team", ""); assert!(base.contains("match input image 2's art style EXACTLY")); + assert!(base.contains("iridescent foil perimeter")); + assert!(base.contains("same circular art mask")); + assert!(base.contains("two-column metadata row")); + assert!(base.contains("\"Good for\" and \"Vibes\"")); assert!(base.contains("\"Eva\"")); assert!(!base.contains("OWNER'S DIRECTIONS")); @@ -197,6 +246,24 @@ fn instructions_pin_style_match_default_and_owner_primacy() { assert!(directed.contains("use their wording within the 220-character text-box limit")); } +#[test] +fn followup_instructions_preserve_unrequested_card_details() { + let followup = + build_card_followup_instructions("Eva", "leads the team", "make only the sky warmer"); + assert!(followup.contains("Input image 3 is the current finished card")); + assert!(followup.contains("Preserve every visual and textual detail")); + assert!(followup.contains("make only the sky warmer")); +} + +#[test] +fn followup_reference_must_be_a_bounded_image() { + let png = test_png_data_url(); + let encoded = png.split_once(',').unwrap().1; + assert!(decode_reference_card(Some(encoded)).unwrap().is_some()); + assert!(decode_reference_card(Some("not-base64")).is_err()); + assert!(decode_reference_card(None).unwrap().is_none()); +} + #[test] fn extract_card_output_happy_path_and_missing_image() { let ok = serde_json::json!({ @@ -214,12 +281,49 @@ fn extract_card_output_happy_path_and_missing_image() { let missing = serde_json::json!({"output": [{"type": "message", "content": []}]}); let err = extract_card_output(&missing).unwrap_err(); - assert!(err.contains("No image"), "{err}"); + assert!(err.contains("did not return a card image"), "{err}"); let no_output = serde_json::json!({}); assert!(extract_card_output(&no_output).is_err()); } +#[test] +fn extract_card_output_surfaces_failed_tool_message() { + let failed = serde_json::json!({ + "status": "completed", + "output": [ + {"type": "reasoning"}, + {"type": "image_generation_call", "status": "failed", "result": null}, + {"type": "reasoning"}, + {"type": "image_generation_call", "status": "failed"}, + {"type": "message", "content": [{ + "type": "output_text", + "text": "The supplied description could not be used to create an image." + }]} + ] + }); + + let err = extract_card_output(&failed).unwrap_err(); + assert!( + err.contains("The supplied description could not be used"), + "{err}" + ); + assert!(!err.contains("item types"), "{err}"); +} + +#[test] +fn extract_card_output_reports_status_when_provider_omits_detail() { + let failed = serde_json::json!({ + "output": [ + {"type": "image_generation_call", "status": "failed"}, + {"type": "image_generation_call", "status": "incomplete"} + ] + }); + + let err = extract_card_output(&failed).unwrap_err(); + assert!(err.contains("statuses: failed, incomplete"), "{err}"); +} + #[test] fn kind0_picture_wins_over_record_avatar_unless_blank() { let some = |s: &str| Some(s.to_string()); @@ -246,6 +350,34 @@ fn kind0_picture_wins_over_record_avatar_unless_blank() { assert_eq!(preferred_avatar_url(None, None), None); } +#[test] +fn inline_svg_avatar_uses_its_rasterized_png_fallback() { + let svg = + "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E"; + let png = test_png_data_url(); + + let bytes = resolve_inline_avatar_bytes(svg, Some(&png)).unwrap(); + let image = image::load_from_memory(&bytes).unwrap(); + assert_eq!((image.width(), image.height()), (1, 1)); +} + +#[test] +fn raster_avatar_cannot_be_overridden_when_the_source_is_not_svg() { + let malformed_png = "data:image/png;base64,bm90LWEtcG5n"; + let valid_png = test_png_data_url(); + + assert!(resolve_inline_avatar_bytes(malformed_png, Some(&valid_png)).is_err()); +} + +fn test_png_data_url() -> String { + let image = image::DynamicImage::new_rgba8(1, 1); + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + format!("data:image/png;base64,{}", STANDARD.encode(png)) +} + #[test] fn unlocked_manifest_inlines_real_avatar_bytes_downscaled() { // 700px source (over MANIFEST_AVATAR_MAX_DIM) in a solid color. diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e63..de4053e205e 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -23,6 +23,8 @@ use crate::{ }; pub(crate) mod import; +mod import_preview; +mod import_setup; // Re-export import-side commands so callers see a flat `snapshot::` namespace. pub use import::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; @@ -229,12 +231,14 @@ fn materialize_portable_runtime_defaults( /// - Memory-source pubkey validation /// - Secret exclusion (env_vars never enter the manifest via `build_snapshot`) /// - Output filename derived from the agent display name +#[allow(clippy::too_many_arguments)] pub(crate) async fn materialize_snapshot_bytes( id: String, memory_source_pubkey: Option, memory_level: MemoryLevel, is_png: bool, avatar_png_data_url: Option, + source_avatar_png_data_url: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -285,7 +289,7 @@ pub(crate) async fn materialize_snapshot_bytes( // ── Resolve avatar bytes ───────────────────────────────────────────────── // If the avatar_url is a data URL we decode it inline; otherwise we keep // it as an external reference in the manifest (the importer will use it). - let avatar_bytes: Option> = record + let stored_avatar_bytes: Option> = record .avatar_url .as_deref() .and_then(crate::managed_agents::agent_snapshot::decode_avatar_data_url); @@ -299,11 +303,15 @@ pub(crate) async fn materialize_snapshot_bytes( }; // ── Build manifest ─────────────────────────────────────────────────────── + let manifest_avatar_bytes = resolve_manifest_avatar_bytes( + source_avatar_png_data_url.as_deref(), + stored_avatar_bytes.clone(), + ); let snapshot = build_snapshot( &record, memory_level, memory_entries, - avatar_bytes.as_deref(), + manifest_avatar_bytes.as_deref(), ); // ── Encode ─────────────────────────────────────────────────────────────── @@ -311,7 +319,7 @@ pub(crate) async fn materialize_snapshot_bytes( if is_png { let png_body_avatar_bytes = - resolve_png_body_avatar_bytes(avatar_png_data_url.as_deref(), avatar_bytes); + resolve_png_body_avatar_bytes(avatar_png_data_url.as_deref(), manifest_avatar_bytes); let png_bytes = encode_snapshot_png(&snapshot, png_body_avatar_bytes.as_deref()) .map_err(|e| format!("Failed to encode .agent.png: {e}"))?; validate_snapshot_encode_size(png_bytes.len(), true)?; @@ -331,7 +339,8 @@ pub(crate) async fn materialize_snapshot_bytes( } /// Choose bytes for the PNG image body without changing the source avatar the -/// manifest preserves for import. +/// manifest preserves for import. The export UI normally supplies the fully +/// flattened trading card here; older callers may still supply just an avatar. fn resolve_png_body_avatar_bytes( avatar_png_data_url: Option<&str>, store_avatar_bytes: Option>, @@ -341,6 +350,17 @@ fn resolve_png_body_avatar_bytes( .or(store_avatar_bytes) } +/// Prefer the source avatar separately from the flattened card image so the +/// manifest remains capable of restoring the real agent face on import. +fn resolve_manifest_avatar_bytes( + source_avatar_png_data_url: Option<&str>, + store_avatar_bytes: Option>, +) -> Option> { + source_avatar_png_data_url + .and_then(crate::managed_agents::agent_snapshot::decode_avatar_data_url) + .or(store_avatar_bytes) +} + /// Export an agent definition as a `buzz-agent-snapshot v1` file. /// /// `id` is a definition slug or a keyed-instance pubkey. @@ -353,12 +373,14 @@ fn resolve_png_body_avatar_bytes( /// The user picks the save path via the OS dialog. Returns `true` when the /// file was written, `false` when the dialog was cancelled. #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri exposes these as named IPC fields. pub async fn export_agent_snapshot( id: String, memory_source_pubkey: Option, memory_level: String, format: String, avatar_png_data_url: Option, + source_avatar_png_data_url: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -371,6 +393,7 @@ pub async fn export_agent_snapshot( memory_level, is_png, avatar_png_data_url, + source_avatar_png_data_url, app.clone(), state, ) @@ -416,12 +439,14 @@ pub struct EncodedSnapshotPayload { /// but **never opens a file dialog**. The frontend passes the returned bytes /// through `uploadMediaBytes` → message construction → channel/DM send. #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri exposes these as named IPC fields. pub async fn encode_agent_snapshot_for_send( id: String, memory_source_pubkey: Option, memory_level: String, format: String, avatar_png_data_url: Option, + source_avatar_png_data_url: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -434,6 +459,7 @@ pub async fn encode_agent_snapshot_for_send( memory_level, is_png, avatar_png_data_url, + source_avatar_png_data_url, app, state, ) @@ -512,4 +538,23 @@ mod png_body_tests { assert_eq!((reader.info().width, reader.info().height), (3, 2)); assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); } + + #[test] + fn source_avatar_stays_distinct_from_flattened_card_body() { + let source_avatar = vec![0x89, b'P', b'N', b'G', 1, 2, 3]; + let flattened_card = vec![0x89, b'P', b'N', b'G', 4, 5, 6]; + let source_data_url = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&source_avatar) + ); + + assert_eq!( + resolve_manifest_avatar_bytes(Some(&source_data_url), Some(flattened_card.clone())), + Some(source_avatar) + ); + assert_eq!( + resolve_manifest_avatar_bytes(None, Some(flattened_card.clone())), + Some(flattened_card) + ); + } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304b..71849df0479 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -10,10 +10,13 @@ use nostr::ToBech32; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; +pub(crate) use super::import_preview::build_agent_snapshot_import_preview; +use super::import_preview::AgentSnapshotImportPreview; +use super::import_setup::{apply_import_setup, AgentSnapshotImportSetup}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{extract_chunk_payload_png, AgentSnapshot, MemoryLevel}, + agent_snapshot::{extract_chunk_payload_png, MemoryLevel}, agent_snapshot_envelope::{ decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, LOCKED_CARD_REFUSAL, @@ -46,48 +49,6 @@ pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), Stri Ok(()) } -// ── Import preview types ────────────────────────────────────────────────────── - -/// Materialized preview returned to the UI before any write is committed. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentSnapshotImportPreview { - /// Agent display name from the snapshot. - pub display_name: String, - /// Whether the exported source definition was built in. This is display - /// metadata only; confirmed imports are always independent custom agents. - pub is_builtin: bool, - /// Preferred model from the exported definition. - pub model: Option, - /// Preferred runtime from the exported definition. - pub runtime: Option, - /// System prompt, if any. - pub system_prompt: Option, - /// Effective avatar: data URL if present, otherwise the source URL fallback. - /// The UI renders this as a single avatar source. - pub avatar_url: Option, - /// Memory level declared in the snapshot. - pub memory_level: String, - /// Number of memory entries bundled in the snapshot. - pub memory_entry_count: usize, - /// True when the snapshot's `respond_to_allowlist` is non-empty. These - /// pubkeys come from the source environment and are meaningless on the - /// importer's relay — the UI must offer Keep / Clear. - pub has_source_allowlist: bool, - /// Number of source allowlist entries. - pub source_allowlist_count: usize, - /// Full source allowlist entries, surfaced before import so hidden access - /// configuration is never reduced to a count. - pub source_allowlist: Vec, - /// Pretty-printed, validated manifest exactly as decoded from the file. - /// The UI makes this available before confirmation for full payload review. - pub manifest_json: String, - /// True when the snapshot came from a locked (encrypted) card that this - /// machine successfully unlocked. Cards that cannot be unlocked never - /// reach a preview — they fail closed with the locked-card refusal. - pub locked: bool, -} - /// The confirmation request sent from the UI after the user reviews the preview. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -97,6 +58,9 @@ pub struct AgentSnapshotImportConfirm { /// When true, copy source `respond_to_allowlist` to the new agent. /// When false (the safe default), the allowlist is cleared. pub keep_allowlist: bool, + /// Optional user-reviewed values from the setup dialog. Older callers may + /// omit this and retain the original direct-import behavior. + pub setup: Option, } /// Structured result returned after a confirmed import. @@ -129,11 +93,9 @@ pub struct AgentSnapshotImportResult { /// function so that unit tests exercise the exact production logic rather /// than a reconstruction of it. /// -/// # UI contract -/// -/// The Keep/Clear toggle is shown whenever `has_source_allowlist` is true -/// (i.e. the raw allowlist is non-empty), regardless of the source mode. -/// The mode (`respond_to` wire string) and the list are independent axes. +/// The reviewed setup flow supplies explicit behavioral values. This resolver +/// remains the compatibility path for callers that omit setup values and for +/// normalizing the final values before the imported agent is persisted. /// /// # Decision table /// @@ -182,8 +144,8 @@ pub(crate) fn resolve_snapshot_import_behavior( ); } - // Step 4: apply Keep/Clear when the toggle was visible (list non-empty), - // or preserve the source mode when it was not. + // Step 4: apply the caller's Keep/Clear choice when a list is present, or + // preserve the source mode when there is no list to resolve. let (resolved_mode, resolved_allowlist) = if has_source_allowlist { if keep_allowlist { // Keep: preserve source mode and validated list. @@ -198,7 +160,7 @@ pub(crate) fn resolve_snapshot_import_behavior( (source_mode, Vec::new()) } } else { - // No list present → toggle was never shown; preserve source mode as-is. + // No list present: preserve source mode as-is. (source_mode, normalized_allowlist) }; @@ -396,43 +358,6 @@ pub async fn preview_agent_snapshot_import( .map_err(|e| format!("spawn_blocking failed: {e}"))? } -pub(crate) fn build_agent_snapshot_import_preview( - snapshot: &AgentSnapshot, - locked: bool, -) -> Result { - let memory_level = match snapshot.memory.level { - MemoryLevel::None => "none", - MemoryLevel::Core => "core", - MemoryLevel::Everything => "everything", - } - .to_string(); - - let manifest_json = serde_json::to_string_pretty(snapshot) - .map_err(|e| format!("failed to render snapshot manifest: {e}"))?; - let source_allowlist = snapshot.definition.respond_to_allowlist.clone(); - - Ok(AgentSnapshotImportPreview { - display_name: snapshot.profile.display_name.clone(), - is_builtin: snapshot.definition.source_is_builtin, - model: snapshot.definition.model.clone(), - runtime: snapshot.definition.runtime.clone(), - system_prompt: snapshot.definition.system_prompt.clone(), - // Effective avatar: data URL wins; URL fallback if no data URL. - avatar_url: snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()), - memory_level, - memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: source_allowlist.len(), - has_source_allowlist: !source_allowlist.is_empty(), - source_allowlist, - manifest_json, - locked, - }) -} - // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── /// Import a `buzz-agent-snapshot v1` file as a brand-new agent. @@ -461,7 +386,7 @@ pub async fn confirm_agent_snapshot_import( // ── Phase 1: validate (no writes) ──────────────────────────────────────── // Locked cards unlock only via this machine's exact key endpoints; // anything else fails closed here, before key generation. - let snapshot = { + let mut snapshot = { let owner_keys = state.signing_keys().ok(); let records = { let _store_guard = state @@ -473,6 +398,12 @@ pub async fn confirm_agent_snapshot_import( decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0 }; + let reviewed_env_vars = input + .setup + .as_ref() + .map(|setup| apply_import_setup(&mut snapshot, setup)) + .unwrap_or_default(); + let display_name = snapshot.profile.display_name.trim().to_string(); if display_name.is_empty() { return Err("Snapshot display name is empty.".to_string()); @@ -483,7 +414,7 @@ pub async fn confirm_agent_snapshot_import( snapshot.definition.respond_to.as_deref(), &snapshot.definition.respond_to_allowlist, snapshot.definition.parallelism, - input.keep_allowlist, + input.setup.is_some() || input.keep_allowlist, )?; let minted_parallelism = minted.parallelism; @@ -578,7 +509,7 @@ pub async fn confirm_agent_snapshot_import( source_team: None, source_team_persona_slug: None, catalog_source: None, - env_vars: std::collections::BTreeMap::new(), + env_vars: reviewed_env_vars.clone(), respond_to: respond_to_wire.clone(), respond_to_allowlist: minted.respond_to_allowlist.clone(), parallelism: minted_parallelism, @@ -620,7 +551,7 @@ pub async fn confirm_agent_snapshot_import( model: snapshot.definition.model.clone(), provider: snapshot.definition.provider.clone(), persona_source_version: None, - env_vars: std::collections::BTreeMap::new(), + env_vars: reviewed_env_vars, start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import_preview.rs b/desktop/src-tauri/src/commands/personas/snapshot/import_preview.rs new file mode 100644 index 00000000000..00eefb427bf --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/import_preview.rs @@ -0,0 +1,67 @@ +use serde::Serialize; + +use crate::managed_agents::agent_snapshot::{AgentSnapshot, MemoryLevel}; + +/// Materialized preview returned to the UI before any write is committed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportPreview { + pub display_name: String, + pub is_builtin: bool, + pub model: Option, + pub runtime: Option, + pub provider: Option, + pub system_prompt: Option, + pub avatar_url: Option, + pub source_avatar_url: Option, + pub name_pool: Vec, + pub respond_to: Option, + pub parallelism: Option, + pub memory_level: String, + pub memory_entry_count: usize, + pub has_source_allowlist: bool, + pub source_allowlist_count: usize, + pub source_allowlist: Vec, + pub manifest_json: String, + pub locked: bool, +} + +pub(crate) fn build_agent_snapshot_import_preview( + snapshot: &AgentSnapshot, + locked: bool, +) -> Result { + let memory_level = match snapshot.memory.level { + MemoryLevel::None => "none", + MemoryLevel::Core => "core", + MemoryLevel::Everything => "everything", + } + .to_string(); + let manifest_json = serde_json::to_string_pretty(snapshot) + .map_err(|e| format!("failed to render snapshot manifest: {e}"))?; + let source_allowlist = snapshot.definition.respond_to_allowlist.clone(); + + Ok(AgentSnapshotImportPreview { + display_name: snapshot.profile.display_name.clone(), + is_builtin: snapshot.definition.source_is_builtin, + model: snapshot.definition.model.clone(), + runtime: snapshot.definition.runtime.clone(), + provider: snapshot.definition.provider.clone(), + system_prompt: snapshot.definition.system_prompt.clone(), + avatar_url: snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()), + source_avatar_url: snapshot.profile.avatar_url.clone(), + name_pool: snapshot.definition.name_pool.clone(), + respond_to: snapshot.definition.respond_to.clone(), + parallelism: snapshot.definition.parallelism, + memory_level, + memory_entry_count: snapshot.memory.entries.len(), + source_allowlist_count: source_allowlist.len(), + has_source_allowlist: !source_allowlist.is_empty(), + source_allowlist, + manifest_json, + locked, + }) +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import_setup.rs b/desktop/src-tauri/src/commands/personas/snapshot/import_setup.rs new file mode 100644 index 00000000000..12dc2aee266 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/import_setup.rs @@ -0,0 +1,151 @@ +use serde::Deserialize; +use std::collections::BTreeMap; + +use crate::managed_agents::agent_snapshot::AgentSnapshot; + +/// User-reviewed definition values submitted from the imported-agent setup +/// dialog. Snapshot memory and identity material remain owned by the import +/// command; this only overrides editable definition/profile fields. Environment +/// values are returned separately because snapshots intentionally do not carry +/// machine-local credentials. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportSetup { + pub display_name: String, + pub avatar_url: Option, + pub system_prompt: String, + pub runtime: Option, + pub model: Option, + pub provider: Option, + #[serde(default)] + pub name_pool: Vec, + #[serde(default)] + pub env_vars: BTreeMap, + pub respond_to: Option, + #[serde(default)] + pub respond_to_allowlist: Vec, + pub parallelism: Option, +} + +pub(super) fn apply_import_setup( + snapshot: &mut AgentSnapshot, + setup: &AgentSnapshotImportSetup, +) -> BTreeMap { + snapshot.profile.display_name = setup.display_name.clone(); + match setup + .avatar_url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + { + Some(url) if url.starts_with("data:image/") => { + snapshot.profile.avatar_data_url = Some(url.to_string()); + snapshot.profile.avatar_url = None; + } + Some(url) => { + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = Some(url.to_string()); + } + None => { + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = None; + } + } + snapshot.definition.system_prompt = Some(setup.system_prompt.clone()); + snapshot.definition.runtime = setup.runtime.clone(); + snapshot.definition.model = setup.model.clone(); + snapshot.definition.provider = setup.provider.clone(); + snapshot.definition.name_pool = setup.name_pool.clone(); + snapshot.definition.respond_to = setup.respond_to.clone(); + snapshot.definition.respond_to_allowlist = setup.respond_to_allowlist.clone(); + snapshot.definition.parallelism = setup.parallelism; + setup.env_vars.clone() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::agent_snapshot::{ + AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, + FORMAT_VERSION, + }; + + #[test] + fn applies_reviewed_agent_definition_before_creation() { + let mut snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Source Agent".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Source Agent".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + }; + let setup = AgentSnapshotImportSetup { + display_name: "Reviewed Agent".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + system_prompt: "Use the reviewed instructions.".to_string(), + runtime: Some("claude-code".to_string()), + model: Some("claude-sonnet-4-5".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Reviewed Agent".to_string(), "Helper".to_string()], + env_vars: BTreeMap::from([( + "ANTHROPIC_API_KEY".to_string(), + "reviewed-secret".to_string(), + )]), + respond_to: Some("anyone".to_string()), + respond_to_allowlist: vec![], + parallelism: Some(3), + }; + + let reviewed_env_vars = apply_import_setup(&mut snapshot, &setup); + + assert_eq!(snapshot.profile.display_name, "Reviewed Agent"); + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert!(snapshot.profile.avatar_data_url.is_none()); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("Use the reviewed instructions.") + ); + assert_eq!(snapshot.definition.runtime.as_deref(), Some("claude-code")); + assert_eq!( + snapshot.definition.model.as_deref(), + Some("claude-sonnet-4-5") + ); + assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); + assert_eq!( + snapshot.definition.name_pool, + vec!["Reviewed Agent".to_string(), "Helper".to_string()] + ); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("anyone")); + assert_eq!(snapshot.definition.parallelism, Some(3)); + assert_eq!( + reviewed_env_vars + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("reviewed-secret") + ); + } +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9de..7f6638cceb1 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -561,6 +561,7 @@ fn import_preview_includes_exported_definition_metadata() { snapshot.definition.source_is_builtin = true; snapshot.definition.model = Some("claude-opus-4-5".to_string()); snapshot.definition.runtime = Some("goose".to_string()); + snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string()); let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); @@ -569,6 +570,10 @@ fn import_preview_includes_exported_definition_metadata() { assert!(preview.is_builtin); assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); assert_eq!(preview.runtime.as_deref(), Some("goose")); + assert_eq!( + preview.source_avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); } // ── Import: resolve_snapshot_import_behavior — the production selection path diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e6..b5245eaad64 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -808,6 +808,8 @@ pub fn run() { update_team, delete_team, export_agent_snapshot, + card_mint_dedicated_key_status, + card_mint_delete_openai_key, card_mint_key_status, card_mint_save_openai_key, mint_agent_card, diff --git a/desktop/src/features/agents/assets/buzz-trading-card-template.svg b/desktop/src/features/agents/assets/buzz-trading-card-template.svg new file mode 100644 index 00000000000..f9de064ff19 --- /dev/null +++ b/desktop/src/features/agents/assets/buzz-trading-card-template.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src/features/agents/cardMintStore.test.mjs b/desktop/src/features/agents/cardMintStore.test.mjs index 6c2f4522abd..4ae754d5057 100644 --- a/desktop/src/features/agents/cardMintStore.test.mjs +++ b/desktop/src/features/agents/cardMintStore.test.mjs @@ -41,20 +41,43 @@ describe("cardMintStore", () => { it("forwards the mint input — including memoryLevel — to mintFn", async () => { const seen = []; await runCardMintJob( - { ...INPUT, styleNotes: "stormy", lock: true, memoryLevel: "core" }, + { + ...INPUT, + styleNotes: "stormy", + lock: true, + memoryLevel: "core", + avatarDataUrl: "data:image/png;base64,YXZhdGFy", + referenceCardPngBase64: "cHJldmlvdXMtY2FyZA==", + }, (...args) => { seen.push(args); return Promise.resolve({ ...CARD, memoryLevel: "core" }); }, ); - assert.deepEqual(seen, [["agent-1", "stormy", true, "core"]]); + assert.deepEqual(seen, [ + [ + "agent-1", + "stormy", + true, + "core", + "data:image/png;base64,YXZhdGFy", + "cHJldmlvdXMtY2FyZA==", + ], + ]); // Omitted memoryLevel stays undefined so Rust applies its "none" default. await runCardMintJob(INPUT, (...args) => { seen.push(args); return Promise.resolve(CARD); }); - assert.deepEqual(seen[1], ["agent-1", undefined, undefined, undefined]); + assert.deepEqual(seen[1], [ + "agent-1", + undefined, + undefined, + undefined, + undefined, + undefined, + ]); }); it("tracks a successful mint through minting → done", async () => { @@ -110,8 +133,8 @@ describe("cardMintStore", () => { `expected 'invalid or expired' in: ${error}`, ); assert.ok( - error?.includes("Update API key"), - `expected 'Update API key' in: ${error}`, + error?.includes("custom-card API key"), + `expected 'custom-card API key' in: ${error}`, ); }); diff --git a/desktop/src/features/agents/cardMintStore.ts b/desktop/src/features/agents/cardMintStore.ts index c38bf393cc8..09e6807332d 100644 --- a/desktop/src/features/agents/cardMintStore.ts +++ b/desktop/src/features/agents/cardMintStore.ts @@ -27,6 +27,10 @@ export type CardMintInput = { lock?: boolean; /** Memory to embed in the card's snapshot. Omitted = "none". */ memoryLevel?: SnapshotMemoryLevel; + /** Raster fallback for Buzz's inline SVG avatar format. */ + avatarDataUrl?: string; + /** Previous finished card used as image context for an iterative revision. */ + referenceCardPngBase64?: string; }; export type CardMintJob = { @@ -40,6 +44,9 @@ export type CardMintJob = { startedAt: number; }; +export const INVALID_OPENAI_KEY_MESSAGE = + "The OpenAI API key is invalid or expired. Update the custom-card API key and try again."; + /** Card content shown by the global viewer dialog. */ export type CardViewerState = { card: MintedAgentCard; @@ -74,19 +81,7 @@ function updateJob(jobId: string, patch: Partial): void { emitChange(); } -/** - * Run one mint as a background job. `mintFn` is injectable for tests; the - * public `startCardMint` binds the real Tauri command. - */ -export async function runCardMintJob( - input: CardMintInput, - mintFn: ( - id: string, - styleNotes?: string, - lock?: boolean, - memoryLevel?: SnapshotMemoryLevel, - ) => Promise, -): Promise { +function addCardMintJob(input: CardMintInput): string { const jobId = `card-mint-${nextJobId++}`; jobs = [ ...jobs, @@ -100,13 +95,29 @@ export async function runCardMintJob( }, ]; emitChange(); + return jobId; +} +async function completeCardMintJob( + jobId: string, + input: CardMintInput, + mintFn: ( + id: string, + styleNotes?: string, + lock?: boolean, + memoryLevel?: SnapshotMemoryLevel, + avatarDataUrl?: string, + referenceCardPngBase64?: string, + ) => Promise, +): Promise { try { const card = await mintFn( input.agentId, input.styleNotes, input.lock, input.memoryLevel, + input.avatarDataUrl, + input.referenceCardPngBase64, ); updateJob(jobId, { phase: "done", card }); toast.success(`${input.agentName}'s card is ready`, { @@ -119,9 +130,8 @@ export async function runCardMintJob( } catch (error) { let message = error instanceof Error ? error.message : "Card mint failed."; if (message.startsWith(NO_OPENAI_KEY_PREFIX)) { - // The dialog pre-checks the key, so this only happens when the key was - // removed between dialog-open and mint. The dialog's key-setup panel is - // long gone — surface a plain instruction instead of the wire prefix. + // The wire prefix only routes the old setup UI. Inline custom-card + // creation needs the actionable message without its transport marker. message = message.slice(NO_OPENAI_KEY_PREFIX.length).trim(); } else if ( message.startsWith("Card mint failed (HTTP 401 ") || @@ -130,8 +140,7 @@ export async function runCardMintJob( // The saved OpenAI key is invalid or expired. Only match the OpenAI-call // envelope prefix and the specific Incorrect-API-key message to avoid // rewriting unrelated 401s (e.g. "Avatar fetch failed: HTTP 401 …"). - message = - 'The OpenAI API key is invalid or expired. Open the mint dialog and use "Update API key" to replace it.'; + message = INVALID_OPENAI_KEY_MESSAGE; } updateJob(jobId, { phase: "error", error: message }); toast.error(`Minting ${input.agentName}'s card failed`, { @@ -140,9 +149,34 @@ export async function runCardMintJob( } } -/** Start a mint in the background. Fire-and-forget; state flows via the store. */ -export function startCardMint(input: CardMintInput): void { - void runCardMintJob(input, mintAgentCard); +/** + * Run one mint as a background job. `mintFn` is injectable for tests; the + * public `startCardMint` binds the real Tauri command. + */ +export async function runCardMintJob( + input: CardMintInput, + mintFn: ( + id: string, + styleNotes?: string, + lock?: boolean, + memoryLevel?: SnapshotMemoryLevel, + avatarDataUrl?: string, + referenceCardPngBase64?: string, + ) => Promise, +): Promise { + const jobId = addCardMintJob(input); + await completeCardMintJob(jobId, input, mintFn); +} + +/** + * Start a mint in the background and return its stable job id immediately. + * Callers that stay open can use the id to reveal the result inline; callers + * that close keep the existing composer-chip and completion-toast behavior. + */ +export function startCardMint(input: CardMintInput): string { + const jobId = addCardMintJob(input); + void completeCardMintJob(jobId, input, mintAgentCard); + return jobId; } /** Open the finished card of a job in the viewer and clear its rail chip. */ diff --git a/desktop/src/features/agents/snapshotHooks.ts b/desktop/src/features/agents/snapshotHooks.ts index cbbaf122117..6d862e7b38d 100644 --- a/desktop/src/features/agents/snapshotHooks.ts +++ b/desktop/src/features/agents/snapshotHooks.ts @@ -22,23 +22,27 @@ export function useExportAgentSnapshotMutation() { format, memorySourcePubkey, avatarUrl, + cardPngDataUrl, }: { id: string; memoryLevel: SnapshotMemoryLevel; format: SnapshotFormat; memorySourcePubkey?: string | null; avatarUrl?: string | null; + cardPngDataUrl?: string; }) => { - const avatarPngDataUrl = + const sourceAvatarPngDataUrl = format === "png" ? await resolveSnapshotAvatarPng(avatarUrl) : undefined; + const avatarPngDataUrl = cardPngDataUrl ?? sourceAvatarPngDataUrl; return exportAgentSnapshot( id, memoryLevel, format, memorySourcePubkey, avatarPngDataUrl, + sourceAvatarPngDataUrl, ); }, }); @@ -52,12 +56,14 @@ export function useEncodeAgentSnapshotForSendMutation() { format, memorySourcePubkey, avatarPngDataUrl, + sourceAvatarPngDataUrl, }: { id: string; memoryLevel: SnapshotMemoryLevel; format: SnapshotFormat; memorySourcePubkey?: string | null; avatarPngDataUrl?: string; + sourceAvatarPngDataUrl?: string; }) => encodeAgentSnapshotForSend( id, @@ -65,6 +71,7 @@ export function useEncodeAgentSnapshotForSendMutation() { format, memorySourcePubkey, avatarPngDataUrl, + sourceAvatarPngDataUrl, ), }); } diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs index ff3efaec927..9e4e7facf43 100644 --- a/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs @@ -26,16 +26,20 @@ describe("cardMintKeyUtils — key panel derivations", () => { assert.equal(isWritableLayer("global"), true); }); - it("isWritableLayer_agent_false", () => { - assert.equal(isWritableLayer("agent"), false); + it("isWritableLayer_agent_true", () => { + assert.equal(isWritableLayer("agent"), true); }); - it("isWritableLayer_persona_false", () => { - assert.equal(isWritableLayer("persona"), false); + it("isWritableLayer_persona_true", () => { + assert.equal(isWritableLayer("persona"), true); }); - it("isWritableLayer_process_false", () => { - assert.equal(isWritableLayer("process"), false); + it("isWritableLayer_process_true", () => { + assert.equal(isWritableLayer("process"), true); + }); + + it("isWritableLayer_card_true", () => { + assert.equal(isWritableLayer("card"), true); }); it("isWritableLayer_undefined_false", () => { @@ -45,16 +49,16 @@ describe("cardMintKeyUtils — key panel derivations", () => { // ── isReadOnlyLayer ──────────────────────────────────────────────────────── - it("isReadOnlyLayer_agent_true", () => { - assert.equal(isReadOnlyLayer("agent"), true); + it("isReadOnlyLayer_agent_false", () => { + assert.equal(isReadOnlyLayer("agent"), false); }); - it("isReadOnlyLayer_persona_true", () => { - assert.equal(isReadOnlyLayer("persona"), true); + it("isReadOnlyLayer_persona_false", () => { + assert.equal(isReadOnlyLayer("persona"), false); }); - it("isReadOnlyLayer_process_true", () => { - assert.equal(isReadOnlyLayer("process"), true); + it("isReadOnlyLayer_process_false", () => { + assert.equal(isReadOnlyLayer("process"), false); }); it("isReadOnlyLayer_global_false", () => { @@ -166,9 +170,12 @@ describe("cardMintKeyUtils — key panel derivations", () => { assert.equal(showKeyStatusRow("none", false), false); }); - it("showKeyStatusRow_agent_notEditing_hides", () => { - // Read-only layer: use showReadOnlyRow instead - assert.equal(showKeyStatusRow("agent", false), false); + it("showKeyStatusRow_agent_notEditing_shows", () => { + assert.equal(showKeyStatusRow("agent", false), true); + }); + + it("showKeyStatusRow_card_notEditing_shows", () => { + assert.equal(showKeyStatusRow("card", false), true); }); it("showKeyStatusRow_undefined_notEditing_hides", () => { @@ -179,16 +186,16 @@ describe("cardMintKeyUtils — key panel derivations", () => { // ── showReadOnlyRow ──────────────────────────────────────────────────────── // Inline provenance row on the mint form for keys the dialog cannot update. - it("showReadOnlyRow_agent_notEditing_shows", () => { - assert.equal(showReadOnlyRow("agent", false), true); + it("showReadOnlyRow_agent_notEditing_hides", () => { + assert.equal(showReadOnlyRow("agent", false), false); }); - it("showReadOnlyRow_persona_notEditing_shows", () => { - assert.equal(showReadOnlyRow("persona", false), true); + it("showReadOnlyRow_persona_notEditing_hides", () => { + assert.equal(showReadOnlyRow("persona", false), false); }); - it("showReadOnlyRow_process_notEditing_shows", () => { - assert.equal(showReadOnlyRow("process", false), true); + it("showReadOnlyRow_process_notEditing_hides", () => { + assert.equal(showReadOnlyRow("process", false), false); }); it("showReadOnlyRow_agent_editing_hides", () => { @@ -247,11 +254,11 @@ describe("cardMintKeyUtils — key panel derivations", () => { assert.equal(keyPanelTitle("global", true), "Update OpenAI API key"); }); - it("keyPanelTitle_agent_readOnly", () => { - assert.equal(keyPanelTitle("agent", false), "OpenAI API key"); + it("keyPanelTitle_agent_override", () => { + assert.equal(keyPanelTitle("agent", false), "Update OpenAI API key"); }); - it("keyPanelTitle_persona_readOnly", () => { - assert.equal(keyPanelTitle("persona", true), "OpenAI API key"); + it("keyPanelTitle_persona_override", () => { + assert.equal(keyPanelTitle("persona", true), "Update OpenAI API key"); }); }); diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx index b2bffc00df7..f3a5033a2eb 100644 --- a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx @@ -16,7 +16,6 @@ import { setCardGalleryOpen, startCardMint, } from "@/features/agents/cardMintStore"; -import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig"; import { cardMintKeyStatus, cardMintSaveOpenaiKey, @@ -151,29 +150,21 @@ export function AgentCardMintDialog({ // True when the key resolves from a layer this dialog cannot update. const keyIsReadOnly = isReadOnlyLayer(keyLayer); - // Save the pasted key into the global Agent Defaults env — the same single - // source of truth every agent inherits. Narrow Rust seam: validated - // single-key merge, never restarts running agents (the mint re-reads - // config per call, so no restart is needed for minting). + // Save a card-only credential. Existing agent configuration remains a + // fallback, but this key takes precedence without changing it. const saveKeyMutation = useMutation({ mutationFn: (key: string) => cardMintSaveOpenaiKey(key), onSuccess: () => { - // The key now lives in global defaults — update the cached layer so the - // status row shows correctly without waiting for a refetch. queryClient.setQueryData( ["cardMintKeyStatus", agentId], - "global", + "card", ); - // The Agent Defaults editor caches the whole config — refetch it so a - // later-opened settings view shows the key we just wrote. void queryClient.invalidateQueries({ - queryKey: globalAgentConfigQueryKey, + queryKey: ["cardMintDedicatedKeyStatus"], }); setKeyDraft(""); setEditingKey(false); - toast.success( - "API key saved to your agent defaults. Running agents pick it up on their next restart.", - ); + toast.success("OpenAI key saved for custom cards only."); }, onError: (error) => toast.error(typeof error === "string" ? error : "Couldn't save the key."), @@ -235,10 +226,9 @@ export function AgentCardMintDialog({

Minting a card costs money — it generates the art and card text through the OpenAI API with your key (typically well - under a dollar per mint, billed by OpenAI). The key is saved - as OPENAI_API_KEY in your - agent defaults env — that's the row to update in Settings if - you ever need to change it there. + under a dollar per mint, billed by OpenAI). This key is + stored securely for custom cards only and does not change + Agent Defaults.

- - + + Close + + + + {mode === "custom" + ? editingKey + ? "Update OpenAI key" + : "Create custom card" + : `Export ${agentName}`} + + + +
+
+ +
+ +
+ +
+
+ + {customCardImageUrl ? ( + + + + ) : ( + + + + )} + +
+
+
+
+ +
+ + {mode === "default" ? ( + +
+
+ + + Memories + + {hasLinkedAgent ? ( + + setMemoryLevel(value as SnapshotMemoryLevel) + } + options={MEMORY_LEVELS} + testId="agent-snapshot-memory-trigger" + value={memoryLevel} + /> + ) : ( + + Agent only + + )} +
+ +
+ + + File format + + + setFormat(value as SnapshotFormat) + } + options={FORMAT_OPTIONS} + testId="agent-snapshot-format-trigger" + value={format} + /> +
+
+ + + {showMemoryWarning ? ( + +
+ +

+ Memory is stored as plaintext{" "} + in exported files. Only share those with people + you trust. +

+
+
+ ) : null} +
+ +
+ + +
+
+ ) : ( + + {isCreatingCustomCard ? ( +
+
+ Creating card… + + This can take a few minutes + +
+
+ +
+
+ ) : editingKey ? ( +
+
+ + + {keyLayer === "none" + ? "Add OpenAI API key" + : "Update OpenAI API key"} + + {needsOpenAIKeyReplacement ? ( +

+ That OpenAI key is invalid or expired. Add a + replacement to continue creating this card. +

+ ) : null} +

+ Creating custom card art uses the OpenAI API and + may cost money, billed by OpenAI. This key is + stored securely for custom cards only and does not + change Agent Defaults. +

+ + + setKeyDraft(event.target.value) + } + placeholder="sk-…" + type="password" + value={keyDraft} + /> +
+
+ + +
+
+ ) : ( + <> +