mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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:
parent
64f819dc86
commit
3f3f0fc449
@@ -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]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user