feat(desktop): Create Agent Card action + mint dialog in agent panel

UI lane for Agent Trading Cards: a Sparkles 'Create card' quick action
in the agent panel's primary-action row (gated canManagePersona), opening
AgentCardMintDialog — optional style notes, one long Rust-side mint call
(~2-3 min) with progress copy, preview with reroll, save as .agent.png.
NO_OPENAI_KEY errors point the user at provider settings. tauriPersonas
gains mintAgentCard/saveAgentCard wrappers; no new backend state — reroll
is simply another mint call.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-27 22:32:11 -04:00
co-authored by Tyler Longwell
parent b8f9764943
commit eba5ab72bb
4 changed files with 228 additions and 0 deletions
@@ -0,0 +1,139 @@
import * as React from "react";
import { Download, RefreshCw, Sparkles } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {
mintAgentCard,
NO_OPENAI_KEY_PREFIX,
saveAgentCard,
type MintedAgentCard,
} from "@/shared/api/tauriPersonas";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Textarea } from "@/shared/ui/textarea";
/**
* Mint-a-trading-card dialog: optional style notes → one long Rust-side
* Responses API call → preview with reroll → save as `.agent.png`.
*
* The saved PNG carries the agent's `buzz_agent_snapshot` chunk, so sharing
* the card shares an importable agent (config only — never memory, never
* identity). All snapshot construction and verification happens in Rust.
*/
export function AgentCardMintDialog({
agentId,
agentName,
onOpenChange,
}: {
/** Instance pubkey or definition slug — same resolution as snapshot export. */
agentId: string;
agentName: string;
onOpenChange: (open: boolean) => void;
}) {
const [styleNotes, setStyleNotes] = React.useState("");
const [card, setCard] = React.useState<MintedAgentCard | null>(null);
const mintMutation = useMutation({
mutationFn: () => mintAgentCard(agentId, styleNotes.trim() || undefined),
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.",
);
} else {
toast.error(error.message);
}
},
});
const saveMutation = useMutation({
mutationFn: (minted: MintedAgentCard) =>
saveAgentCard(minted.cardPngBase64, minted.fileName),
onSuccess: (saved) => {
if (saved) {
toast.success(
`Saved ${agentName}'s card. Share it — the card IS the agent.`,
);
onOpenChange(false);
}
},
onError: (error: Error) => toast.error(error.message),
});
const isMinting = mintMutation.isPending;
return (
<Dialog onOpenChange={(open) => !isMinting && onOpenChange(open)} open>
<DialogContent className="max-w-md" data-testid="agent-card-mint-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
{card ? `${agentName}'s card` : `Create ${agentName}'s card`}
</DialogTitle>
<DialogDescription>
{card
? "The card carries the agent — anyone who imports this PNG gets a working copy (config only, fresh identity, no memories)."
: "Mint a collectible trading card that doubles as a shareable, importable copy of this agent."}
</DialogDescription>
</DialogHeader>
{card ? (
<div className="flex flex-col gap-4">
<img
alt={`${agentName} trading card`}
className="mx-auto max-h-[28rem] rounded-lg border shadow-lg"
data-testid="agent-card-preview"
src={`data:image/png;base64,${card.cardPngBase64}`}
/>
<div className="flex justify-end gap-2">
<Button
disabled={isMinting || saveMutation.isPending}
onClick={() => mintMutation.mutate()}
variant="outline"
>
<RefreshCw className="mr-2 h-4 w-4" />
{isMinting ? "Rerolling…" : "Reroll"}
</Button>
<Button
disabled={isMinting || saveMutation.isPending}
onClick={() => saveMutation.mutate(card)}
data-testid="agent-card-save"
>
<Download className="mr-2 h-4 w-4" />
Save card
</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."
rows={3}
value={styleNotes}
/>
<div className="flex justify-end">
<Button
disabled={isMinting}
onClick={() => mintMutation.mutate()}
data-testid="agent-card-mint"
>
<Sparkles className="mr-2 h-4 w-4" />
{isMinting ? "Minting… (takes a few minutes)" : "Mint card"}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -73,6 +73,7 @@ import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFi
import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit";
import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs";
import { UserProfileSnapshotExportDialog } from "@/features/profile/ui/UserProfileSnapshotExportDialog";
import { AgentCardMintDialog } from "@/features/agents/ui/AgentCardMintDialog";
import {
deriveProfileChannels,
type ProfilePanelTab,
@@ -181,6 +182,10 @@ export function UserProfilePanel({
React.useState<AgentPersona | null>(null);
const [personaToExportSnapshot, setPersonaToExportSnapshot] =
React.useState<AgentPersona | null>(null);
const [cardMintTarget, setCardMintTarget] = React.useState<{
id: string;
name: string;
} | null>(null);
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
@@ -824,6 +829,17 @@ export function UserProfilePanel({
onOpenInstructions={() => setView("instructions")}
onTabChange={setTab}
onOpenDm={onOpenDm}
onCreateCard={
canManagePersona && resolvedPersona
? () =>
setCardMintTarget({
// Prefer the live instance pubkey; fall back to the
// persona/definition id (same resolution as export).
id: managedAgent?.pubkey ?? resolvedPersona.id,
name: resolvedPersona.displayName,
})
: undefined
}
presenceStatus={presenceStatus}
profile={profile}
pubkey={effectivePubkey}
@@ -958,6 +974,15 @@ export function UserProfilePanel({
persona={personaToExportSnapshot}
/>
) : null}
{cardMintTarget ? (
<AgentCardMintDialog
agentId={cardMintTarget.id}
agentName={cardMintTarget.name}
onOpenChange={(open) => {
if (!open) setCardMintTarget(null);
}}
/>
) : null}
</>
);
return (
@@ -9,6 +9,7 @@ import {
Pencil,
Play,
RefreshCw,
Sparkles,
Square,
UserMinus,
UserPlus,
@@ -105,6 +106,8 @@ export type ProfileSummaryViewProps = {
onOpenInstructions: () => void;
onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void;
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
/** Mint an agent trading card. Present only for owner-managed personas. */
onCreateCard?: () => void;
presenceStatus: "online" | "away" | "offline" | undefined;
profile: ReturnType<typeof useUserProfileQuery>["data"];
pubkey: string | null;
@@ -218,6 +221,7 @@ export function ProfileSummaryView({
onOpenInstructions,
onTabChange,
onOpenDm,
onCreateCard,
presenceStatus,
profile,
pubkey,
@@ -349,6 +353,7 @@ export function ProfileSummaryView({
<ProfilePersonaPrimaryActions
canEditAgent={canEditAgent}
disabled={isAgentActionPending}
onCreateCard={onCreateCard}
onEditAgent={handleEditAgent}
onStartAgent={handleInstantiateAgent}
/>
@@ -356,6 +361,7 @@ export function ProfileSummaryView({
<ProfilePrimaryActions
canEditAgent={canEditAgent}
followMutation={followMutation}
onCreateCard={onCreateCard}
onEditAgent={handleEditAgent}
agentActionDisabled={isAgentActionPending}
agentActionLabel={
@@ -641,6 +647,7 @@ function ProfilePrimaryActions({
messagePending,
onAgentPrimaryAction,
onAgentRestart,
onCreateCard,
onEditAgent,
onMessage,
pubkey,
@@ -655,6 +662,7 @@ function ProfilePrimaryActions({
messagePending?: boolean;
onAgentPrimaryAction?: () => void;
onAgentRestart?: () => void;
onCreateCard?: () => void;
onEditAgent: () => void;
onMessage?: () => void;
pubkey: string;
@@ -720,6 +728,14 @@ function ProfilePrimaryActions({
testId="user-profile-agent-restart"
/>
) : null}
{onCreateCard ? (
<ProfileQuickAction
icon={Sparkles}
label="Create card"
onClick={onCreateCard}
testId="user-profile-create-card"
/>
) : null}
</div>
);
}
@@ -727,11 +743,13 @@ function ProfilePrimaryActions({
function ProfilePersonaPrimaryActions({
canEditAgent,
disabled,
onCreateCard,
onEditAgent,
onStartAgent,
}: {
canEditAgent: boolean;
disabled: boolean;
onCreateCard?: () => void;
onEditAgent: () => void;
onStartAgent: () => void;
}) {
@@ -753,6 +771,15 @@ function ProfilePersonaPrimaryActions({
testId="user-profile-edit-agent"
/>
) : null}
{onCreateCard ? (
<ProfileQuickAction
disabled={disabled}
icon={Sparkles}
label="Create card"
onClick={onCreateCard}
testId="user-profile-create-card"
/>
) : null}
</div>
);
}
+37
View File
@@ -167,6 +167,43 @@ export async function encodeAgentSnapshotForSend(
});
}
// ── Agent trading cards ───────────────────────────────────────────────────────
/** Result of `mint_agent_card`. */
export type MintedAgentCard = {
/** Final `.agent.png` bytes (chunk-injected, verified), base64-encoded. */
cardPngBase64: string;
/** Suggested filename, e.g. `eva.agent.png`. */
fileName: string;
/** Designer commentary emitted alongside the image (may be empty). */
designerNotes: string;
};
/** Error prefix Rust returns when no OpenAI key is configured. */
export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:";
/**
* Mint a trading card for an agent. One long API call (~2–3 minutes).
* Reroll = call again; the backend holds no session state.
*/
export async function mintAgentCard(
id: string,
styleNotes?: string,
): Promise<MintedAgentCard> {
return invokeTauri<MintedAgentCard>("mint_agent_card", {
id,
styleNotes: styleNotes || null,
});
}
/** Save minted card bytes to disk via the OS save dialog. */
export async function saveAgentCard(
cardPngBase64: string,
fileName: string,
): Promise<boolean> {
return invokeTauri<boolean>("save_agent_card", { cardPngBase64, fileName });
}
// ── Snapshot import ───────────────────────────────────────────────────────────
/** Preview returned by `preview_agent_snapshot_import` before any write. */