Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified desktop/src-tauri/assets/card_template.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
293 changes: 129 additions & 164 deletions desktop/src-tauri/src/commands/personas/card.rs

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions desktop/src-tauri/src/commands/personas/card/avatar.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
record_avatar_url: Option<String>,
) -> Option<String> {
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<Vec<u8>, 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<Vec<u8>> {
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)
}
Original file line number Diff line number Diff line change
@@ -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<Option<Vec<u8>>, 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))
}
113 changes: 113 additions & 0 deletions desktop/src-tauri/src/commands/personas/card/response.rs
Original file line number Diff line number Diff line change
@@ -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<String>) {
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::<Vec<_>>().join(" ");
if compact.chars().count() <= max_chars {
return compact;
}
let mut truncated = compact.chars().take(max_chars).collect::<String>();
truncated.push('…');
truncated
}
Loading
Loading