mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
b8f9764943
commit
eba5ab72bb
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user