Files
buzz/desktop/src/shared/api/tauriPersonas.ts
Will Pfleger f810a2f49e fix(desktop): make OpenAI key re-enterable after first save in card mint dialog (#4140)
Fixes a write-once dead-end in the card mint dialog where a user with an
expired OpenAI key had no way to replace it.

**Source-aware key status (Rust + TypeScript).** `card_mint_key_status`
returns a layer discriminant (`"none" | "global" | "persona" | "agent" |
"process"`) instead of a boolean. A pure `resolve_key_layer()` helper in
`card.rs` owns the classification logic; `card_mint_key_status`
delegates to it, so the production path is under direct test with no
duplicate logic.

**Mint form always reachable.** The key panel replaces the mint form
only for `none` (first-time setup) or when the user explicitly opens the
edit panel (`editingKey`). Keys from agent/persona/process layers show
an inline provenance row on the mint form with a "Why?" affordance;
clicking it shows the read-only redirect in a panel with a Cancel button
that returns to the mint form — never a terminal state.

**Precise auth-error matching.** The 401 handling in `cardMintStore.ts`
matches `startsWith("Card mint failed (HTTP 401 ")` plus the specific
`Incorrect API key` text, so avatar-fetch 401 errors pass through
unchanged.

**Tri-state key status row.** "Using your saved OpenAI key · Update"
renders only when `keyLayer === "global"` (confirmed writable key).
Query pending or errored hides the row without asserting key existence.

**Real tests.** Panel visibility derivations live in
`cardMintKeyUtils.ts`, which `AgentCardMintDialog.tsx` imports directly.
Tests cover all layers including the mint-reachability invariant (Mint
reachable for every resolved layer; only `none` gates setup).

- `card.rs` — new `resolve_key_layer()` pure helper;
`card_mint_key_status` delegates to it; 999 lines (under the 1000-line
ratchet)
- `card/tests.rs` — precedence test calls `resolve_key_layer()` directly
(no test-local closure); adds process-layer and blank-value cases
- `tauriPersonas.ts` — `CardMintKeyLayer` type; updated
`cardMintKeyStatus` signature
- `cardMintKeyUtils.ts` — `showKeyPanel`, `showReadOnlyRow`,
`showCancelButton`, `keyPanelTitle`, and helpers; component imports all
of them
- `AgentCardMintDialog.tsx` — inline provenance rows for all key
sources; key panel only for setup/edit; no unused variables
- `cardMintStore.ts` — precise 401 prefix matching
- `e2eBridge.ts` — `card_mint_key_status` stub returns `"global"` (not
boolean)
- Tests: 3959 JS passing, 2089 Rust passing, `tsc --noEmit` clean

Related: [block/buzz#4406](https://github.com/block/buzz/pull/4406)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg <9a232143a0336e34814a6a0f4947e11bc50bc641f21572c886fd0ae1f7a4e867@buzz.block.builderlab.xyz>
2026-08-03 11:12:09 -04:00

460 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { invokeTauri } from "@/shared/api/tauri";
import type {
AgentPersona,
CreatePersonaInput,
RespondToMode,
UpdatePersonaInput,
} from "@/shared/api/types";
export type RawPersona = {
id: string;
display_name: string;
avatar_url: string | null;
system_prompt: string;
runtime?: string | null;
model?: string | null;
provider?: string | null;
name_pool?: string[];
is_builtin: boolean;
is_active?: boolean;
shared?: boolean;
source_team?: string | null;
/**
* Provenance of a local copy of another owner's catalog entry. Serialized by
* the backend `CatalogSource` in snake_case; the create payload sends the
* camelCase aliases it accepts.
*/
catalog_source?: { owner_pubkey: string; persona_id: string } | null;
env_vars?: Record<string, string>;
respond_to?: string | null;
respond_to_allowlist?: string[];
parallelism?: number | null;
created_at: string;
updated_at: string;
/** Non-null when the pack `.persona.md` write-back failed (non-fatal). */
writeback_warning?: string | null;
};
export function fromRawPersona(persona: RawPersona): AgentPersona {
return {
id: persona.id,
displayName: persona.display_name,
avatarUrl: persona.avatar_url,
systemPrompt: persona.system_prompt,
runtime: persona.runtime ?? null,
model: persona.model ?? null,
provider: persona.provider ?? null,
namePool: persona.name_pool ?? [],
isBuiltIn: persona.is_builtin,
isActive: persona.is_active ?? true,
shared: persona.shared ?? false,
sourceTeam: persona.source_team ?? null,
catalogSource: persona.catalog_source
? {
ownerPubkey: persona.catalog_source.owner_pubkey,
personaId: persona.catalog_source.persona_id,
}
: null,
envVars: persona.env_vars ?? {},
respondTo: (persona.respond_to as RespondToMode | undefined) ?? null,
respondToAllowlist: persona.respond_to_allowlist ?? [],
parallelism: persona.parallelism ?? null,
createdAt: persona.created_at,
updatedAt: persona.updated_at,
};
}
export async function listPersonas(): Promise<AgentPersona[]> {
return (await invokeTauri<RawPersona[]>("list_personas")).map(fromRawPersona);
}
export async function createPersona(
input: CreatePersonaInput,
): Promise<AgentPersona> {
return fromRawPersona(
await invokeTauri<RawPersona>("create_persona", {
input: {
displayName: input.displayName,
avatarUrl: input.avatarUrl,
systemPrompt: input.systemPrompt,
runtime: input.runtime,
model: input.model,
provider: input.provider,
namePool: input.namePool ?? [],
envVars: input.envVars ?? {},
behavior: input.behavior,
catalogSource: input.catalogSource,
},
}),
);
}
/** The `UpdatePersonaRequest` payload shared by both edit commands. */
function updatePersonaPayload(input: UpdatePersonaInput) {
return {
id: input.id,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
systemPrompt: input.systemPrompt,
runtime: input.runtime,
model: input.model,
provider: input.provider,
namePool: input.namePool ?? [],
// Send envVars only when caller explicitly provided it; omitting
// tells the backend "don't touch the stored env vars" so editing
// unrelated fields can't silently wipe saved credentials.
envVars: input.envVars,
// Same absent-vs-present contract as envVars for the behavioral quad.
behavior: input.behavior,
};
}
export async function updatePersona(
input: UpdatePersonaInput,
): Promise<AgentPersona> {
const raw = await invokeTauri<RawPersona>("update_persona", {
input: updatePersonaPayload(input),
});
if (raw.writeback_warning) {
console.warn(
`[updatePersona] pack write-back failed (edit saved locally): ${raw.writeback_warning}`,
);
}
return fromRawPersona(raw);
}
/**
* Save an edit AND publish the persona's catalog head, reporting whether the
* relay accepted it.
*
* `updatePersona` only enqueues the head best-effort, so it cannot tell the UI
* whether the community catalog actually received the change. Use this for the
* "Save and publish" affordance, which promises exactly that.
*/
export async function updatePersonaAndPublish(
input: UpdatePersonaInput,
): Promise<PersonaSharePublicationResult> {
return fromRawPublicationResult(
await invokeTauri<RawPersonaSharePublicationResult>(
"update_persona_and_publish",
{ input: updatePersonaPayload(input) },
),
);
}
type RawPersonaSharePublicationResult = {
persona: RawPersona;
publicationStatus: "published" | "queued";
relayMessage?: string;
};
function fromRawPublicationResult(
raw: RawPersonaSharePublicationResult,
): PersonaSharePublicationResult {
return {
persona: fromRawPersona(raw.persona),
publicationStatus: raw.publicationStatus,
relayMessage: raw.relayMessage ?? null,
};
}
export async function deletePersona(id: string): Promise<void> {
await invokeTauri("delete_persona", { id });
}
export async function setPersonaActive(
id: string,
active: boolean,
): Promise<AgentPersona> {
return fromRawPersona(
await invokeTauri<RawPersona>("set_persona_active", { id, active }),
);
}
export async function setPersonaShared(
id: string,
shared: boolean,
): Promise<PersonaSharePublicationResult> {
return fromRawPublicationResult(
await invokeTauri<RawPersonaSharePublicationResult>("set_persona_shared", {
id,
shared,
}),
);
}
export type PersonaSharePublicationResult = {
persona: AgentPersona;
publicationStatus: "published" | "queued";
relayMessage: string | null;
};
export type SnapshotMemoryLevel = "none" | "core" | "everything";
export type SnapshotFormat = "json" | "png";
export async function exportAgentSnapshot(
id: string,
memoryLevel: SnapshotMemoryLevel,
format: SnapshotFormat,
memorySourcePubkey?: string | null,
avatarPngDataUrl?: string,
): Promise<boolean> {
return invokeTauri<boolean>("export_agent_snapshot", {
id,
memorySourcePubkey: memorySourcePubkey ?? null,
memoryLevel,
format,
avatarPngDataUrl: avatarPngDataUrl ?? null,
});
}
/** The byte payload returned by `encode_agent_snapshot_for_send`. */
export type EncodedSnapshotPayload = {
/** Raw snapshot bytes — pass directly to `uploadMediaBytes`. */
fileBytes: number[];
/** Suggested filename (e.g. `my-agent.agent.json`). */
fileName: string;
};
/**
* Encode a snapshot in memory and return the bytes to the frontend without
* opening any file dialog. Use this for the native-send path; use
* `exportAgentSnapshot` for the local save-to-disk path.
*
* Both commands call the same shared Rust encoder, so byte output is
* identical for identical inputs.
*/
export async function encodeAgentSnapshotForSend(
id: string,
memoryLevel: SnapshotMemoryLevel,
format: SnapshotFormat,
memorySourcePubkey?: string | null,
avatarPngDataUrl?: string,
): Promise<EncodedSnapshotPayload> {
return invokeTauri<EncodedSnapshotPayload>("encode_agent_snapshot_for_send", {
id,
memorySourcePubkey: memorySourcePubkey ?? null,
memoryLevel,
format,
avatarPngDataUrl: avatarPngDataUrl ?? null,
});
}
// ── 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;
/** True when the embedded manifest is encrypted to the (owner, agent) pair. */
locked: boolean;
/** How much memory is embedded in the card's snapshot. */
memoryLevel: SnapshotMemoryLevel;
};
/** Error prefix Rust returns when no OpenAI key is configured. */
export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:";
/**
* Which env layer resolves the OpenAI key for a card mint of this agent.
*
* - `"none"` — no key configured anywhere; show the first-time setup panel.
* - `"global"` — key comes from global Agent Defaults env; writable from the
* dialog via `cardMintSaveOpenaiKey`.
* - `"persona"` — key is set on the linked persona; cannot be updated from
* the mint dialog (would be shadowed by the higher-priority layer).
* - `"agent"` — key is set directly on the agent record; same restriction.
* - `"process"` — key comes from the process environment (dev fallback);
* same restriction.
*/
export type CardMintKeyLayer =
| "none"
| "global"
| "persona"
| "agent"
| "process";
/**
* Report which env layer resolves the OpenAI key for a card mint of this agent
* (same layering as the mint itself). Returns a layer discriminant — never the
* key value itself. Use to decide whether to show a writable key input (none /
* global) or a read-only redirect (persona / agent / process).
*/
export async function cardMintKeyStatus(id: string): Promise<CardMintKeyLayer> {
return invokeTauri<CardMintKeyLayer>("card_mint_key_status", { id });
}
/**
* Save an OPENAI_API_KEY into the global Agent Defaults env for card minting.
*
* Narrow seam: validated read-modify-write of the single key against the
* latest on-disk config. Unlike the general set_global_agent_config, this
* NEVER restarts running agents — the mint re-reads config per call, and a
* card setup must not disrupt unrelated agents.
*/
export async function cardMintSaveOpenaiKey(key: string): Promise<void> {
return invokeTauri<void>("card_mint_save_openai_key", { key });
}
/**
* Mint a trading card for an agent. One long API call (~2–3 minutes).
* Reroll = call again; the backend holds no session state.
*
* When `lock` is true, the embedded manifest is NIP-44-encrypted to the
* (owner, agent) pair: only those two keys can import the card. Requires a
* linked agent instance.
*
* `memoryLevel` (default `"none"`) embeds the owner's decrypted memory in
* the card's snapshot — same levels as snapshot export. Levels other than
* `"none"` require a linked agent instance; Rust derives the memory source
* from the resolved instance itself.
*/
export async function mintAgentCard(
id: string,
styleNotes?: string,
lock?: boolean,
memoryLevel?: SnapshotMemoryLevel,
): Promise<MintedAgentCard> {
return invokeTauri<MintedAgentCard>("mint_agent_card", {
id,
styleNotes: styleNotes || null,
lock: lock ?? null,
memoryLevel: memoryLevel ?? 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 });
}
/** One archived (previously minted) card, as listed by `list_agent_cards`. */
export type ArchivedAgentCard = {
/** On-disk archive key — pass to `loadAgentCard`. */
storedFileName: string;
/** Suggested save-as name, e.g. `eva.agent.png`. */
fileName: string;
agentId: string;
agentName: string;
designerNotes: string;
locked: boolean;
/** Memory embedded in the card's snapshot ("none" for pre-field archives). */
memoryLevel: SnapshotMemoryLevel;
/** ISO-8601 mint timestamp. */
mintedAt: string;
/** Small JPEG grid preview, base64 (absent if the thumb write failed). */
thumbJpegBase64: string | null;
};
/** List all archived cards, newest first. */
export async function listAgentCards(): Promise<ArchivedAgentCard[]> {
return invokeTauri<ArchivedAgentCard[]>("list_agent_cards");
}
/** Load one archived card's full PNG bytes (base64) by its archive key. */
export async function loadAgentCard(storedFileName: string): Promise<string> {
return invokeTauri<string>("load_agent_card", { storedFileName });
}
// ── Snapshot import ───────────────────────────────────────────────────────────
/** Preview returned by `preview_agent_snapshot_import` before any write. */
export type AgentSnapshotImportPreview = {
displayName: string;
/** Source classification shown in the preview; imports remain custom. */
isBuiltIn: boolean;
model: string | null;
runtime: string | null;
systemPrompt: string | null;
/** Effective avatar: data URL if present, source URL fallback otherwise. */
avatarUrl: string | null;
/** "none" | "core" | "everything" */
memoryLevel: string;
memoryEntryCount: number;
/** True when the snapshot's respond_to_allowlist is non-empty. */
hasSourceAllowlist: boolean;
sourceAllowlistCount: number;
/** Full source pubkeys shown before import, not only their count. */
sourceAllowlist: string[];
/** Validated, pretty-printed manifest for full payload disclosure. */
manifestJson: string;
/**
* True when the snapshot came from a locked (encrypted) card this machine
* unlocked. Cards that cannot be unlocked fail with a refusal error and
* never reach a preview.
*/
locked: boolean;
};
/** Confirmation sent to `confirm_agent_snapshot_import`. */
export type AgentSnapshotImportConfirm = {
fileBytes: number[];
/** When true, copy source allowlist to the new agent. Default: false (Clear). */
keepAllowlist: boolean;
};
/** Structured result from a confirmed import. */
export type AgentSnapshotImportResult = {
displayName: string;
/** Hex pubkey of the newly minted agent. */
newPubkey: string;
/** Persona ID created for the agent. */
personaId: string;
memoryWritten: number;
memoryTotal: number;
/** Non-empty when some memory entries failed to publish. */
memoryErrors: string[];
/** Non-empty when profile sync encountered a non-fatal relay error. */
profileSyncError: string | null;
};
/**
* Decode and validate a snapshot file, returning a preview for the
* confirmation UI. No writes of any kind are performed.
*/
export async function previewAgentSnapshotImport(
fileBytes: number[],
fileName: string,
): Promise<AgentSnapshotImportPreview> {
return invokeTauri<AgentSnapshotImportPreview>(
"preview_agent_snapshot_import",
{ fileBytes, fileName },
);
}
/**
* Import a `buzz-agent-snapshot v1` file as a brand-new agent with fresh
* keys. Returns a structured result describing what was created and whether
* memory restoration was complete.
*/
export async function confirmAgentSnapshotImport(
input: AgentSnapshotImportConfirm,
): Promise<AgentSnapshotImportResult> {
return invokeTauri<AgentSnapshotImportResult>(
"confirm_agent_snapshot_import",
{ input },
);
}
// Patches a single inbound persona/team/agent projection event into the local
// store (personas.json). The backend resolves the match key and the
// pending-edit race; the frontend forwards the raw Nostr event JSON plus the
// relay it arrived on, so a workspace switch mid-flight cannot retain the event
// into the newly active community's scoped store.
export async function reconcileInboundPersonaEvent(
eventJson: string,
arrivalRelayUrl: string,
): Promise<void> {
await invokeTauri("reconcile_inbound_persona_event", {
eventJson,
arrivalRelayUrl,
});
}