feat(desktop): inline key setup, owner-direction primacy, and OPENAI_BASE_URL for card mints

Three follow-ups to the Agent Trading Cards mint flow (#3278):

1. Inline OPENAI_API_KEY capture: new card_mint_key_status command
   checks (never returns) whether a key resolves through the existing
   env layering. When none does, the mint dialog swaps to a key-setup
   panel with a cost disclosure, a pointer to platform.openai.com, and
   a pointer to the free Export sharing path. Saving writes the key to
   the global Agent Defaults env — the same single source of truth the
   mint already reads; no second key store. A NO_OPENAI_KEY mint error
   now flips into key-setup mode instead of a dead-end toast.

2. Owner-direction primacy: the direction box explicitly accepts card
   TEXT direction as well as art, and the Responses prompt is
   restructured so the owner's directions override the default
   art-style and copy guidance (but never the fixed frame/layout/
   text-fidelity contract). Dialog placeholder updated to match.

3. OPENAI_BASE_URL override resolved through the same env layering,
   unlocking Azure OpenAI and Responses-speaking proxies; trailing
   slashes normalized.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-28 09:45:43 -04:00
parent 64f819dc86
commit 3f3f0fc449
5 changed files with 231 additions and 34 deletions

View File

@@ -80,37 +80,53 @@ pub struct MintedCard {
/// Pure layering: global env < persona env < agent record env, then the
/// process environment as a development fallback. Returns the first
/// non-empty `OPENAI_API_KEY`.
pub(crate) fn resolve_openai_key_from_layers(
/// non-empty value for `key`.
pub(crate) fn resolve_env_from_layers(
key: &str,
global_env: &std::collections::BTreeMap<String, String>,
persona_env: &std::collections::BTreeMap<String, String>,
record_env: &std::collections::BTreeMap<String, String>,
process_key: Option<String>,
process_value: Option<String>,
) -> Option<String> {
for layer in [record_env, persona_env, global_env] {
if let Some(v) = layer.get("OPENAI_API_KEY") {
if let Some(v) = layer.get(key) {
let v = v.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
process_key.filter(|k| !k.trim().is_empty())
process_value.filter(|k| !k.trim().is_empty())
}
/// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env
/// layering as the key) overrides the default host — this is what makes
/// Azure OpenAI and Responses-speaking proxies work without code changes.
pub(crate) fn responses_url(base_url: Option<String>) -> String {
let base = base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_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, user style notes are additive.
/// 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 extra_style = if style_notes.trim().is_empty() {
let owner_directions = if style_notes.trim().is_empty() {
String::new()
} else {
format!("\nAdditional art direction from the owner: {style_notes}\n")
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}".
@@ -119,16 +135,17 @@ Input image 1 is the official Buzz card frame template (gold honeycomb border, d
Persona notes for the card copy:
{persona_notes}
{extra_style}
{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 (edited only for spelling); 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.
- The art window must 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.
- 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.
@@ -197,6 +214,44 @@ pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, S
// ── Commands ──────────────────────────────────────────────────────────────────
/// Report whether an OpenAI key would resolve for a card mint of agent `id`,
/// using exactly the same env layering as `mint_agent_card`. Lets the mint
/// dialog offer inline key setup BEFORE the user commits to a mint, instead
/// of failing after the fact. Never returns the key itself.
#[tauri::command]
pub fn card_mint_key_status(
id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let instances = load_managed_agents(&app)?;
let definitions = load_agent_definitions(&app)?;
let (record, _) = resolve_from_lists(&id, &instances, &definitions)?;
let global = load_global_agent_config(&app).unwrap_or_default();
let personas = load_personas(&app).unwrap_or_default();
let persona_env = record
.persona_id
.as_deref()
.and_then(|pid| personas.iter().find(|p| p.id == pid))
.map(|p| p.env_vars.clone())
.unwrap_or_default();
Ok(resolve_env_from_layers(
"OPENAI_API_KEY",
&global.env_vars,
&persona_env,
&record.env_vars,
std::env::var("OPENAI_API_KEY").ok(),
)
.is_some())
}
/// Mint a trading card for the agent identified by `id` (instance pubkey,
/// instance slug, or definition slug — same resolution as snapshot export).
///
@@ -217,7 +272,7 @@ pub async fn mint_agent_card(
) -> Result<MintedCard, String> {
let lock = lock.unwrap_or(false);
// ── Resolve the record + API key under lock ──────────────────────────────
let (record, is_definition, api_key) = {
let (record, is_definition, api_key, base_url) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -237,7 +292,8 @@ pub async fn mint_agent_card(
.map(|p| p.env_vars.clone())
.unwrap_or_default();
let api_key = resolve_openai_key_from_layers(
let api_key = resolve_env_from_layers(
"OPENAI_API_KEY",
&global.env_vars,
&persona_env,
&record.env_vars,
@@ -249,8 +305,15 @@ pub async fn mint_agent_card(
environment variables or global agent settings to mint cards."
)
})?;
let base_url = resolve_env_from_layers(
"OPENAI_BASE_URL",
&global.env_vars,
&persona_env,
&record.env_vars,
std::env::var("OPENAI_BASE_URL").ok(),
);
(record, is_definition, api_key)
(record, is_definition, api_key, base_url)
};
// ── Locking needs its two exact key endpoints up front, BEFORE the
@@ -358,7 +421,7 @@ pub async fn mint_agent_card(
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let resp = client
.post("https://api.openai.com/v1/responses")
.post(responses_url(base_url))
.bearer_auth(&api_key)
.json(&body)
.send()
@@ -557,26 +620,34 @@ mod tests {
record.insert("OPENAI_API_KEY".to_string(), "record".to_string());
assert_eq!(
resolve_openai_key_from_layers(&global, &persona, &record, None).as_deref(),
resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
Some("record")
);
record.clear();
assert_eq!(
resolve_openai_key_from_layers(&global, &persona, &record, None).as_deref(),
resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
Some("persona")
);
persona.clear();
assert_eq!(
resolve_openai_key_from_layers(&global, &persona, &record, None).as_deref(),
resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
Some("global")
);
global.clear();
assert_eq!(
resolve_openai_key_from_layers(&global, &persona, &record, Some("process".to_string()))
.as_deref(),
resolve_env_from_layers(
"OPENAI_API_KEY",
&global,
&persona,
&record,
Some("process".to_string())
)
.as_deref(),
Some("process")
);
assert!(resolve_openai_key_from_layers(&global, &persona, &record, None).is_none());
assert!(
resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none()
);
}
#[test]
@@ -586,22 +657,46 @@ mod tests {
let mut persona = BTreeMap::new();
persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string());
assert_eq!(
resolve_openai_key_from_layers(&BTreeMap::new(), &persona, &record, None).as_deref(),
resolve_env_from_layers("OPENAI_API_KEY", &BTreeMap::new(), &persona, &record, None)
.as_deref(),
Some("persona")
);
}
#[test]
fn instructions_pin_style_match_default_and_additive_notes() {
fn responses_url_default_and_override() {
assert_eq!(responses_url(None), "https://api.openai.com/v1/responses");
// Trailing slashes must not produce a double-slash path.
assert_eq!(
responses_url(Some("https://proxy.example/v1/".to_string())),
"https://proxy.example/v1/responses"
);
assert_eq!(
responses_url(Some("https://proxy.example/v1".to_string())),
"https://proxy.example/v1/responses"
);
}
#[test]
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("\"Eva\""));
assert!(!base.contains("Additional art direction"));
assert!(!base.contains("OWNER'S DIRECTIONS"));
let styled = build_card_instructions("Eva", "leads the team", "make it stormy");
assert!(styled.contains("Additional art direction from the owner: make it stormy"));
// Style notes are additive — the default style anchor must survive.
assert!(styled.contains("match input image 2's art style EXACTLY"));
let directed = build_card_instructions("Eva", "leads the team", "make it stormy");
// Owner directions take primacy over style defaults...
assert!(directed.contains("OWNER'S DIRECTIONS"));
assert!(directed.contains("make it stormy"));
assert!(directed.contains("override the default art-style and copy guidance"));
// ...but the fixed contract survives: frame, style anchor (as an
// overridable default), and text-fidelity requirements stay present.
assert!(directed.contains("match input image 2's art style EXACTLY"));
assert!(directed.contains("cannot change the frame, layout, or"));
assert!(directed.contains("Render all text with perfect fidelity"));
// Card-text direction is an explicitly named capability.
assert!(directed.contains("card text"));
assert!(directed.contains("use their wording"));
}
#[test]

View File

@@ -972,7 +972,7 @@ pub async fn set_persona_active(
pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47];
mod card;
mod snapshot;
pub use card::{mint_agent_card, save_agent_card};
pub use card::{card_mint_key_status, mint_agent_card, save_agent_card};
#[cfg(test)]
pub(crate) use snapshot::import::decode_snapshot_from_bytes;
pub(crate) use snapshot::import::{

View File

@@ -839,6 +839,7 @@ pub fn run() {
update_team,
delete_team,
export_agent_snapshot,
card_mint_key_status,
mint_agent_card,
save_agent_card,
preview_agent_snapshot_import,

View File

@@ -1,13 +1,26 @@
import * as React from "react";
import { Download, Lock, RefreshCw, Send, Sparkles } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import {
Download,
KeyRound,
Lock,
RefreshCw,
Send,
Sparkles,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
useOpenDmMutation,
useUpsertCachedChannel,
} from "@/features/channels/hooks";
import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig";
import {
getGlobalAgentConfig,
setGlobalAgentConfig,
} from "@/shared/api/tauriGlobalAgentConfig";
import {
cardMintKeyStatus,
mintAgentCard,
NO_OPENAI_KEY_PREFIX,
saveAgentCard,
@@ -22,6 +35,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
@@ -61,11 +75,43 @@ export function AgentCardMintDialog({
const [lockCard, setLockCard] = React.useState(false);
const [card, setCard] = React.useState<MintedAgentCard | null>(null);
const [recipients, setRecipients] = React.useState<UserSearchResult[]>([]);
const [keyDraft, setKeyDraft] = React.useState("");
const queryClient = useQueryClient();
const sendController = useSnapshotSendController(card !== null);
const openDmMutation = useOpenDmMutation();
const upsertCachedChannel = useUpsertCachedChannel();
// Whether a key already resolves through the agent's env layering. While
// unknown (loading/error) we show the normal mint form — the mint itself
// still fails cleanly if no key exists.
const keyStatusQuery = useQuery({
queryKey: ["cardMintKeyStatus", agentId],
queryFn: () => cardMintKeyStatus(agentId),
});
const needsKey = keyStatusQuery.data === false;
// Save the pasted key into the global Agent Defaults env — the same single
// source of truth every agent inherits. No card-specific key store exists.
const saveKeyMutation = useMutation({
mutationFn: async (key: string) => {
const config = await getGlobalAgentConfig();
const result = await setGlobalAgentConfig({
...config,
env_vars: { ...config.env_vars, OPENAI_API_KEY: key },
});
return result.config;
},
onSuccess: (savedConfig) => {
queryClient.setQueryData(globalAgentConfigQueryKey, savedConfig);
queryClient.setQueryData(["cardMintKeyStatus", agentId], true);
setKeyDraft("");
toast.success("API key saved to your agent defaults.");
},
onError: (error) =>
toast.error(typeof error === "string" ? error : "Couldn't save the key."),
});
const mintMutation = useMutation({
mutationFn: () =>
mintAgentCard(
@@ -76,9 +122,9 @@ export function AgentCardMintDialog({
onSuccess: (minted) => setCard(minted),
onError: (error: Error) => {
if (error.message.startsWith(NO_OPENAI_KEY_PREFIX)) {
toast.error(
"No OpenAI API key found. Add OPENAI_API_KEY in the agent's environment variables or global agent settings.",
);
// The pre-check missed (e.g. key removed since open) — flip the
// dialog into key-setup mode instead of leaving a dead-end toast.
queryClient.setQueryData(["cardMintKeyStatus", agentId], false);
} else {
toast.error(error.message);
}
@@ -197,12 +243,59 @@ export function AgentCardMintDialog({
) : null}
</div>
</div>
) : needsKey ? (
<div
className="flex flex-col gap-4"
data-testid="agent-card-key-setup"
>
<div className="flex flex-col gap-2 rounded-md border border-border p-3">
<span className="flex items-center gap-1.5 text-sm font-medium">
<KeyRound className="h-3.5 w-3.5" />
One-time setup: OpenAI API key
</span>
<p className="text-xs text-muted-foreground">
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 to your
agent defaults, so you only do this once.
</p>
<p className="text-xs text-muted-foreground">
Create a key at platform.openai.com under API keys.
</p>
<Input
autoFocus
data-testid="agent-card-key-input"
disabled={saveKeyMutation.isPending}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder="sk-…"
type="password"
value={keyDraft}
/>
</div>
<p className="text-xs text-muted-foreground">
Don’t want to spend money? The Export action on the agent shares
the same importable agent as a plain file — free, just without the
collectible card art.
</p>
<div className="flex justify-end">
<Button
data-testid="agent-card-key-save"
disabled={
saveKeyMutation.isPending || keyDraft.trim().length === 0
}
onClick={() => saveKeyMutation.mutate(keyDraft.trim())}
>
<KeyRound className="mr-2 h-4 w-4" />
{saveKeyMutation.isPending ? "Saving…" : "Save key & continue"}
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<Textarea
disabled={isMinting}
onChange={(e) => setStyleNotes(e.target.value)}
placeholder="Optional art direction — e.g. “stormy night, lightning motif”. The card always matches the agent's avatar style."
placeholder="Optional directions — art (“stormy night, lightning motif”) and/or card text (“ability: Verify — scry 2”). Your directions take priority; anything you leave open is designed for you."
rows={3}
value={styleNotes}
/>

View File

@@ -184,6 +184,14 @@ export type MintedAgentCard = {
/** Error prefix Rust returns when no OpenAI key is configured. */
export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:";
/**
* Check whether an OpenAI key would resolve for a card mint of this agent
* (same env layering as the mint itself). Never returns the key.
*/
export async function cardMintKeyStatus(id: string): Promise<boolean> {
return invokeTauri<boolean>("card_mint_key_status", { id });
}
/**
* Mint a trading card for an agent. One long API call (~2–3 minutes).
* Reroll = call again; the backend holds no session state.