Skip to content

Commit 9587e6b

Browse files
committed
Fix imported persona avatar handling
1 parent 9efe199 commit 9587e6b

11 files changed

Lines changed: 116 additions & 8 deletions

File tree

desktop/src-tauri/src/managed_agents/persona_card.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub struct ParsedPersonaPreview {
1313
pub display_name: String,
1414
pub system_prompt: String,
1515
pub avatar_data_url: Option<String>,
16+
pub avatar_ref: Option<String>,
1617
pub runtime: Option<String>,
1718
pub model: Option<String>,
1819
pub provider: Option<String>,
@@ -81,6 +82,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
8182
display_name: fields.display_name,
8283
system_prompt: fields.system_prompt,
8384
avatar_data_url,
85+
avatar_ref: None,
8486
runtime: fields.runtime,
8587
model: fields.model,
8688
provider: fields.provider,
@@ -240,6 +242,7 @@ pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, Str
240242
display_name: fields.display_name,
241243
system_prompt: fields.system_prompt,
242244
avatar_data_url: fields.avatar_url,
245+
avatar_ref: None,
243246
runtime: fields.runtime,
244247
model: fields.model,
245248
provider: fields.provider,
@@ -304,6 +307,7 @@ pub fn parse_md_persona(md_bytes: &[u8]) -> Result<ParsedPersonaPreview, String>
304307
display_name: config.display_name,
305308
system_prompt: config.prompt,
306309
avatar_data_url: None, // .persona.md avatars are paths, not data URIs
310+
avatar_ref: config.avatar,
307311
runtime: config.runtime,
308312
model,
309313
provider: None, // .persona.md format does not carry llmProvider
@@ -405,6 +409,7 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, Strin
405409
display_name: p.display_name.clone(),
406410
system_prompt: p.system_prompt.clone(),
407411
avatar_data_url: None,
412+
avatar_ref: p.avatar.clone(),
408413
runtime: p.runtime.clone(),
409414
model: p.model.clone(),
410415
provider: None, // persona packs do not carry llmProvider
@@ -834,6 +839,16 @@ mod tests {
834839
assert!(result.source_file.is_empty());
835840
}
836841

842+
#[test]
843+
fn parse_md_carries_avatar_ref() {
844+
let md = b"---\nname: goosey\ndisplay_name: Goosey\navatar: app-avatar:gloopies-19\ndescription: A goose persona.\n---\nYou are Goosey.\n";
845+
let result = parse_md_persona(md).unwrap();
846+
847+
assert_eq!(result.display_name, "Goosey");
848+
assert!(result.avatar_data_url.is_none());
849+
assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19"));
850+
}
851+
837852
#[test]
838853
fn parse_json_round_trip_no_avatar() {
839854
let bytes =

desktop/src/features/agents/ui/BatchImportDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function BatchImportDialog({
9393
try {
9494
await createPersona({
9595
displayName: persona.displayName,
96-
avatarUrl: persona.avatarDataUrl ?? undefined,
96+
avatarUrl: persona.avatarDataUrl ?? persona.avatarRef ?? undefined,
9797
systemPrompt: persona.systemPrompt,
9898
runtime: persona.runtime ?? undefined,
9999
model: persona.model ?? undefined,
@@ -178,7 +178,7 @@ export function BatchImportDialog({
178178
onClick={(e: React.MouseEvent) => e.stopPropagation()}
179179
/>
180180
<ProfileAvatar
181-
avatarUrl={persona.avatarDataUrl}
181+
avatarUrl={persona.avatarDataUrl ?? persona.avatarRef}
182182
className="h-8 w-8 rounded-lg text-xs"
183183
label={persona.displayName}
184184
/>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar.ts";
5+
6+
test("resolveManagedAgentAvatarUrl uploads data image URIs", async () => {
7+
const uploaded = await resolveManagedAgentAvatarUrl(
8+
"data:image/png;base64,aGVsbG8=",
9+
async (bytes) => {
10+
assert.deepEqual(bytes, [104, 101, 108, 108, 111]);
11+
return {
12+
url: "https://relay.example/avatar.png",
13+
sha256: "hash",
14+
size: bytes.length,
15+
type: "image/png",
16+
uploaded: 1,
17+
};
18+
},
19+
);
20+
21+
assert.equal(uploaded, "https://relay.example/avatar.png");
22+
});
23+
24+
test("resolveManagedAgentAvatarUrl passes non-data URLs through", async () => {
25+
const uploaded = await resolveManagedAgentAvatarUrl(
26+
" https://relay.example/already-hosted.png ",
27+
async () => {
28+
throw new Error("should not upload hosted avatars");
29+
},
30+
);
31+
32+
assert.equal(uploaded, "https://relay.example/already-hosted.png");
33+
});
34+
35+
test("resolveManagedAgentAvatarUrl omits invalid data image URIs", async () => {
36+
const uploaded = await resolveManagedAgentAvatarUrl(
37+
"data:image/png;base64,",
38+
async () => {
39+
throw new Error("should not upload invalid data URIs");
40+
},
41+
);
42+
43+
assert.equal(uploaded, undefined);
44+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { type BlobDescriptor, uploadMediaBytes } from "@/shared/api/tauri";
2+
3+
type UploadMediaBytes = (
4+
data: number[],
5+
filename?: string,
6+
) => Promise<BlobDescriptor>;
7+
8+
export async function resolveManagedAgentAvatarUrl(
9+
avatarUrl: string | null | undefined,
10+
upload: UploadMediaBytes = uploadMediaBytes,
11+
): Promise<string | undefined> {
12+
const resolvedAvatarUrl = avatarUrl?.trim() || undefined;
13+
if (!resolvedAvatarUrl?.startsWith("data:image/")) {
14+
return resolvedAvatarUrl;
15+
}
16+
17+
try {
18+
const [, b64] = resolvedAvatarUrl.split(",", 2);
19+
if (!b64) {
20+
throw new Error("empty data URI payload");
21+
}
22+
const bytes = Array.from(atob(b64), (char) => char.charCodeAt(0));
23+
const blob = await upload(bytes);
24+
return blob.url;
25+
} catch (err) {
26+
console.warn("Avatar upload failed, proceeding without avatar:", err);
27+
return undefined;
28+
}
29+
}

desktop/src/features/agents/ui/personaDialogState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function importPersonaDialogState(
9090
submitLabel: "Create agent",
9191
initialValues: {
9292
displayName: persona.displayName,
93-
avatarUrl: persona.avatarDataUrl ?? "",
93+
avatarUrl: persona.avatarDataUrl ?? persona.avatarRef ?? "",
9494
systemPrompt: persona.systemPrompt,
9595
runtime: persona.runtime ?? undefined,
9696
model: persona.model ?? undefined,

desktop/src/features/agents/ui/personaImportPlan.test.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function createPreview(overrides = {}) {
2828
displayName: "Alice",
2929
systemPrompt: "Be helpful.",
3030
avatarDataUrl: null,
31+
avatarRef: null,
3132
runtime: null,
3233
model: null,
3334
namePool: [],
@@ -84,6 +85,17 @@ test("buildPersonaImportPlan detects avatar change", () => {
8485
assert.equal(plan.fields[0]?.field, "avatarUrl");
8586
});
8687

88+
test("buildPersonaImportPlan detects avatar ref changes", () => {
89+
const plan = buildPersonaImportPlan({
90+
persona: createPersona({ avatarUrl: null }),
91+
preview: createPreview({ avatarRef: "app-avatar:gloopies-19" }),
92+
});
93+
94+
assert.equal(plan.fields.length, 1);
95+
assert.equal(plan.fields[0]?.field, "avatarUrl");
96+
assert.equal(plan.fields[0]?.importedValue, "app-avatar:gloopies-19");
97+
});
98+
8799
test("buildPersonaImportPlan detects runtime change", () => {
88100
const plan = buildPersonaImportPlan({
89101
persona: createPersona({ runtime: "goose" }),

desktop/src/features/agents/ui/personaImportPlan.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ export function buildPersonaImportPlan({
124124
}
125125

126126
const existingAvatar = normalizeOptionalText(persona.avatarUrl);
127-
const importedAvatar = normalizeOptionalText(preview.avatarDataUrl);
127+
const importedAvatar = normalizeOptionalText(
128+
preview.avatarDataUrl ?? preview.avatarRef,
129+
);
128130
if (existingAvatar !== importedAvatar) {
129131
fields.push({
130132
field: "avatarUrl",

desktop/src/features/agents/ui/usePersonaActions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
importPersonaDialogState,
3333
type PersonaDialogState,
3434
} from "./personaDialogState";
35+
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar";
3536
import { usePersonaImportActions } from "./usePersonaImportActions";
3637

3738
type PersonaFeedbackSurface = "catalog" | "library";
@@ -111,6 +112,7 @@ export function usePersonaActions() {
111112
}
112113

113114
const persona = await createPersonaMutation.mutateAsync(input);
115+
const avatarUrl = await resolveManagedAgentAvatarUrl(persona.avatarUrl);
114116
const agentInput: CreateManagedAgentInput = {
115117
name: persona.displayName,
116118
acpCommand: "buzz-acp",
@@ -119,9 +121,8 @@ export function usePersonaActions() {
119121
mcpCommand: runtime.mcpCommand ?? "",
120122
personaId: persona.id,
121123
systemPrompt: persona.systemPrompt,
122-
avatarUrl: persona.avatarUrl ?? undefined,
124+
avatarUrl,
123125
model: persona.model ?? undefined,
124-
envVars: persona.envVars,
125126
spawnAfterCreate: true,
126127
startOnAppLaunch: true,
127128
backend: { type: "local" },

desktop/src/features/agents/ui/usePersonaImportActions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function usePersonaImportActions(
119119
? preview.systemPrompt
120120
: existing.systemPrompt,
121121
avatarUrl: selectedFieldSet.has("avatarUrl")
122-
? (preview.avatarDataUrl ?? undefined)
122+
? (preview.avatarDataUrl ?? preview.avatarRef ?? undefined)
123123
: (existing.avatarUrl ?? undefined),
124124
runtime: selectedFieldSet.has("runtime")
125125
? (preview.runtime ?? undefined)

desktop/src/shared/api/tauriPersonas.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type RawParsedPersonaPreview = {
1010
display_name: string;
1111
system_prompt: string;
1212
avatar_data_url: string | null;
13+
avatar_ref: string | null;
1314
runtime: string | null;
1415
model: string | null;
1516
provider: string | null;
@@ -32,6 +33,7 @@ export type ParsedPersonaPreview = {
3233
displayName: string;
3334
systemPrompt: string;
3435
avatarDataUrl: string | null;
36+
avatarRef: string | null;
3537
runtime: string | null;
3638
model: string | null;
3739
provider: string | null;
@@ -154,7 +156,8 @@ export async function parsePersonaFiles(
154156
personas: raw.personas.map((p) => ({
155157
displayName: p.display_name,
156158
systemPrompt: p.system_prompt,
157-
avatarDataUrl: p.avatar_data_url,
159+
avatarDataUrl: p.avatar_data_url ?? null,
160+
avatarRef: p.avatar_ref ?? null,
158161
runtime: p.runtime,
159162
model: p.model,
160163
provider: p.provider,

0 commit comments

Comments
 (0)