From ee44f55aa78151ea0af10ae6b75b9d231b3981d9 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:56:33 -0700 Subject: [PATCH 1/3] feat(avatars): make custom gloopies reusable --- src-tauri/src/commands/avatars.rs | 150 +++++++++++++++++- .../hooks/__tests__/usePersonas.test.ts | 21 +-- src/features/agents/hooks/useAvatarLibrary.ts | 42 ++++- src/features/agents/hooks/usePersonas.ts | 29 +--- src/features/agents/lib/avatarLibraryView.ts | 98 ++++++++++++ src/features/agents/ui/AgentBuilderRail.tsx | 34 ++-- src/features/agents/ui/AgentDetailPage.tsx | 23 ++- .../agents/ui/AvatarCollectionOverlay.tsx | 95 +++++------ .../agents/ui/AvatarLibraryPicker.tsx | 114 ++++++------- .../ui/__tests__/AgentBuilderRail.test.tsx | 3 + .../ui/__tests__/AgentsView.entry.test.tsx | 1 + .../AvatarCollectionOverlay.test.tsx | 3 +- .../ui/__tests__/AvatarLibraryPicker.test.tsx | 48 +++++- src/shared/avatars/catalog.ts | 19 +++ src/shared/i18n/locales/en/agents.json | 1 + src/shared/i18n/locales/es/agents.json | 1 + 16 files changed, 487 insertions(+), 195 deletions(-) create mode 100644 src/features/agents/lib/avatarLibraryView.ts diff --git a/src-tauri/src/commands/avatars.rs b/src-tauri/src/commands/avatars.rs index 51c5454e0..de5a1fb74 100644 --- a/src-tauri/src/commands/avatars.rs +++ b/src-tauri/src/commands/avatars.rs @@ -361,7 +361,14 @@ pub async fn get_avatar_library_snapshot( // Reading cached collections only inspects atomically-placed files, so it // does not need the catalog lock. - let cached_collections = cached_collections_for_catalog(&paths, &catalog)?; + let mut cached_collections = cached_collections_for_catalog(&paths, &catalog)?; + // User-generated gloopies are local library citizens: they ride the same + // snapshot so the picker and the collection gallery see one source of + // truth, stamped with the user catalog version instead of the published + // catalog's version. + if let Some(user_collection) = user_avatar_cached_collection(&app) { + cached_collections.push(user_collection); + } let (media_refreshing, media_refresh_completed, media_error_code) = avatar_refresh_status().lock().unwrap().snapshot(); Ok(AvatarLibrarySnapshot { @@ -1771,6 +1778,75 @@ fn rollback_user_avatar_files(paths: &[&Path]) { } } +/// Enumerates every user-generated gloopie on this machine as a cached +/// collection, so the avatar library snapshot can surface them alongside the +/// bundled catalog. Enumeration is best-effort: a corrupt or half-written +/// manifest skips that avatar instead of hiding the whole collection. A +/// machine with no gloopies yields an empty collection; browsing surfaces omit +/// it entirely. +fn user_avatar_cached_collection(app: &AppHandle) -> Option { + let paths = user_avatar_paths(app).ok()?; + user_avatar_cached_collection_at(&paths) +} + +fn user_avatar_cached_collection_at(paths: &UserAvatarPaths) -> Option { + let mut manifests: Vec = Vec::new(); + if paths.meta.exists() { + let entries = fs::read_dir(&paths.meta).ok()?; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(avatar_id) = file_name + .to_str() + .and_then(|name| name.strip_suffix(".json")) + else { + continue; + }; + let Ok(manifest) = read_user_avatar_manifest(paths, avatar_id) else { + log::warn!("Skipping unreadable user avatar manifest: {avatar_id}"); + continue; + }; + let Ok(media_path) = user_avatar_media_path(paths, &manifest) else { + continue; + }; + if media_path.exists() { + manifests.push(manifest); + } + } + } + // Newest first, so the collection reads as a timeline of what the user + // made most recently; the id tiebreak keeps the order deterministic. + manifests.sort_by(|left, right| { + right + .created_at_ms + .cmp(&left.created_at_ms) + .then_with(|| left.id.cmp(&right.id)) + }); + let assets = manifests + .into_iter() + .map(|manifest| CachedAvatarAsset { + path: paths + .media + .join(&manifest.path) + .to_string_lossy() + .into_owned(), + poster_path: manifest + .poster_path + .as_deref() + .map(|poster| paths.media.join(poster).to_string_lossy().into_owned()), + id: manifest.id, + mime_type: manifest.mime_type, + alpha_mode: manifest.alpha_mode, + }) + .collect(); + Some(CachedAvatarCollection { + catalog_version: USER_AVATAR_CATALOG_VERSION.to_string(), + collection_id: USER_AVATAR_COLLECTION_ID.to_string(), + assets, + failed_asset_ids: Vec::new(), + error_code: None, + }) +} + fn cached_user_avatar_for_id( app: &AppHandle, avatar_id: &str, @@ -2653,6 +2729,78 @@ mod tests { ); } + #[test] + fn user_avatar_collection_lists_gloopies_newest_first_and_skips_broken_entries() { + let (_dir, paths) = temp_user_avatar_paths(); + + // Older avatar. + seed_user_avatar(&paths, "gloopie-old"); + // Newer avatar with a poster and stacked alpha. + let newer_media = paths.media.join("gloopie-new.webm"); + fs::write(&newer_media, b"webm-bytes").unwrap(); + let poster = paths.media.join("gloopie-new.poster.png"); + fs::write(&poster, b"poster").unwrap(); + let manifest = UserAvatarManifest { + id: "gloopie-new".to_string(), + path: "gloopie-new.webm".to_string(), + mime_type: "video/webm".to_string(), + alpha_mode: Some("stacked".to_string()), + poster_path: Some("gloopie-new.poster.png".to_string()), + byte_size: 10, + created_at_ms: 5, + }; + fs::write( + paths.meta.join("gloopie-new.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + // Manifest without media on disk: excluded. + let manifest = UserAvatarManifest { + id: "gloopie-missing".to_string(), + path: "gloopie-missing.png".to_string(), + mime_type: "image/png".to_string(), + alpha_mode: None, + poster_path: None, + byte_size: 1, + created_at_ms: 9, + }; + fs::write( + paths.meta.join("gloopie-missing.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + // Corrupt manifest: skipped, does not fail the collection. + fs::write(paths.meta.join("gloopie-corrupt.json"), b"not json").unwrap(); + + let collection = user_avatar_cached_collection_at(&paths).unwrap(); + + assert_eq!(collection.collection_id, USER_AVATAR_COLLECTION_ID); + assert_eq!(collection.catalog_version, USER_AVATAR_CATALOG_VERSION); + assert!(collection.failed_asset_ids.is_empty()); + let ids: Vec<&str> = collection + .assets + .iter() + .map(|asset| asset.id.as_str()) + .collect(); + assert_eq!(ids, vec!["gloopie-new", "gloopie-old"]); + let newest = &collection.assets[0]; + assert_eq!(newest.mime_type, "video/webm"); + assert_eq!(newest.alpha_mode.as_deref(), Some("stacked")); + assert!(newest + .poster_path + .as_deref() + .unwrap() + .ends_with("gloopie-new.poster.png")); + } + + #[test] + fn user_avatar_collection_is_empty_when_no_gloopies_exist() { + let (_dir, paths) = temp_user_avatar_paths(); + let collection = user_avatar_cached_collection_at(&paths).unwrap(); + assert!(collection.assets.is_empty()); + assert!(collection.failed_asset_ids.is_empty()); + } + #[test] fn delete_user_avatar_removes_media_and_manifest() { let (_dir, paths) = temp_user_avatar_paths(); diff --git a/src/features/agents/hooks/__tests__/usePersonas.test.ts b/src/features/agents/hooks/__tests__/usePersonas.test.ts index 5ec0fa248..4b81a875f 100644 --- a/src/features/agents/hooks/__tests__/usePersonas.test.ts +++ b/src/features/agents/hooks/__tests__/usePersonas.test.ts @@ -220,7 +220,7 @@ describe("usePersonas", () => { ).toBe("Updated"); }); - it("reclaims a replaced user avatar after the final reference changes", async () => { + it("preserves a replaced gloopie in the library after its final agent reference changes", async () => { const existing = makePersona({ id: "test-id", avatar: "user-avatar:shared", @@ -251,12 +251,10 @@ describe("usePersonas", () => { await act(async () => { await result.current.updatePersona(shared, { avatar: null }); }); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:shared", - ); + expect(avatarApiMocks.deleteUserAvatar).not.toHaveBeenCalled(); }); - it("reclaims avatars displaced by overlapping updates", async () => { + it("preserves gloopies displaced by overlapping updates", async () => { const existing = makePersona({ id: "test-id", avatar: "user-avatar:a" }); vi.mocked(api.listPersonas).mockResolvedValueOnce([existing]); const first = makePersona({ id: "test-id", avatar: "user-avatar:b" }); @@ -290,12 +288,7 @@ describe("usePersonas", () => { await updateTwo; }); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:a", - ); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:b", - ); + expect(avatarApiMocks.deleteUserAvatar).not.toHaveBeenCalled(); }); it("deletePersona calls API and removes from store", async () => { @@ -320,7 +313,7 @@ describe("usePersonas", () => { ).toBeUndefined(); }); - it("reclaims a deleted user avatar only after its final reference", async () => { + it("preserves a gloopie in the library after its final agent is deleted", async () => { const first = makePersona({ id: "first", avatar: "user-avatar:shared", @@ -341,9 +334,7 @@ describe("usePersonas", () => { await act(async () => { await result.current.deletePersona("second"); }); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:shared", - ); + expect(avatarApiMocks.deleteUserAvatar).not.toHaveBeenCalled(); }); }); diff --git a/src/features/agents/hooks/useAvatarLibrary.ts b/src/features/agents/hooks/useAvatarLibrary.ts index ab0b719b6..70c9a4781 100644 --- a/src/features/agents/hooks/useAvatarLibrary.ts +++ b/src/features/agents/hooks/useAvatarLibrary.ts @@ -7,10 +7,12 @@ import { refreshAvatarCache, type AvatarLibraryErrorCode, } from "@/shared/api/avatars"; -import type { - AvatarCatalog, - CachedAvatarCollection, - ResolvedAvatarMedia, +import { + USER_AVATAR_CATALOG_VERSION, + USER_AVATAR_COLLECTION_ID, + type AvatarCatalog, + type CachedAvatarCollection, + type ResolvedAvatarMedia, } from "@/shared/avatars/catalog"; interface CachedAvatarMediaEntry { @@ -20,6 +22,13 @@ interface CachedAvatarMediaEntry { export interface AvatarLibraryState { catalog: AvatarCatalog | null; + /** + * Ids of the user's generated gloopies (newest first). They form the + * "Your gloopies" collection: local library citizens outside the published + * catalog, persisted on agents as `user-avatar:`. Their media lives in + * `cachedAvatarMediaById` under `USER_AVATAR_CATALOG_VERSION`. + */ + userAvatarIds: string[]; cachedAvatarMediaById: Record; loading: boolean; cacheChecking: boolean; @@ -37,12 +46,16 @@ function cachedMediaForCatalog( ): Record { const mediaById: Record = {}; for (const collection of collections) { - if (collection.catalogVersion !== catalogVersion) { + const expectedVersion = + collection.collectionId === USER_AVATAR_COLLECTION_ID + ? USER_AVATAR_CATALOG_VERSION + : catalogVersion; + if (collection.catalogVersion !== expectedVersion) { continue; } for (const asset of collection.assets) { mediaById[asset.id] = { - catalogVersion, + catalogVersion: collection.catalogVersion, media: cachedAssetToMedia(asset), }; } @@ -50,6 +63,18 @@ function cachedMediaForCatalog( return mediaById; } +function userAvatarIdsForCollections( + collections: CachedAvatarCollection[], +): string[] { + return ( + collections + .find( + (collection) => collection.collectionId === USER_AVATAR_COLLECTION_ID, + ) + ?.assets.map((asset) => asset.id) ?? [] + ); +} + export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { const [catalog, setCatalog] = useState(null); const [reloadToken, setReloadToken] = useState(0); @@ -66,6 +91,7 @@ export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { const [cachedAvatarMediaById, setCachedAvatarMediaById] = useState< Record >({}); + const [userAvatarIds, setUserAvatarIds] = useState([]); useEffect(() => { if (!enabled) { @@ -105,6 +131,9 @@ export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { ); setCatalog(snapshot.catalog); setCachedAvatarMediaById(cachedMedia); + setUserAvatarIds( + userAvatarIdsForCollections(snapshot.cachedCollections), + ); setBackendMediaRefreshing(snapshot.mediaRefreshing); const hasIncompleteMedia = snapshot.cachedCollections.some( (collection) => collection.failedAssetIds.length > 0, @@ -167,6 +196,7 @@ export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { return { catalog, + userAvatarIds, cachedAvatarMediaById, loading, cacheChecking: loading || mediaRefreshing || backendMediaRefreshing, diff --git a/src/features/agents/hooks/usePersonas.ts b/src/features/agents/hooks/usePersonas.ts index 393096959..08a220eed 100644 --- a/src/features/agents/hooks/usePersonas.ts +++ b/src/features/agents/hooks/usePersonas.ts @@ -10,23 +10,9 @@ import type { Persona, } from "@/shared/types/agents"; import * as api from "@/shared/api/agents"; -import { deleteUserAvatar } from "@/shared/api/avatars"; -import { isUserAvatarRef } from "@/shared/avatars/catalog"; const REFRESH_INTERVAL_MS = 60_000; -function deleteUnreferencedUserAvatar(avatar: string | null | undefined) { - if (!avatar || !isUserAvatarRef(avatar)) return; - const stillReferenced = useAgentStore - .getState() - .personas.some((persona) => persona.avatar === avatar); - if (!stillReferenced) { - void deleteUserAvatar(avatar).catch((error) => { - console.warn("Failed to clean up unreferenced agent avatar:", error); - }); - } -} - export function usePersonas() { const personas = useAgentStore(selectPersonas); const personasLoading = useAgentStore(selectPersonasLoading); @@ -130,18 +116,17 @@ export function usePersonas() { [addPersona, trackMutation], ); + // Custom gloopies are library citizens, not per-agent attachments: a + // displaced or orphaned `user-avatar:` stays in "Your gloopies" so any + // agent can wear it again. Library-level delete is a deliberate later + // feature (alongside export), so no reference-count garbage collection + // happens here. const updatePersona = useCallback( async (existing: Persona, req: UpdatePersonaRequest) => { const persona = await trackMutation(() => api.updatePersona(existing, req), ); - const displacedAvatar = useAgentStore - .getState() - .personas.find((candidate) => candidate.id === existing.id)?.avatar; updatePersonaInStore(existing.id, persona); - if (displacedAvatar !== persona.avatar) { - deleteUnreferencedUserAvatar(displacedAvatar); - } return persona; }, [trackMutation, updatePersonaInStore], @@ -149,12 +134,8 @@ export function usePersonas() { const deletePersona = useCallback( async (id: string) => { - const deletedAvatar = useAgentStore - .getState() - .personas.find((persona) => persona.id === id)?.avatar; await trackMutation(() => api.deletePersona(id)); removePersona(id); - deleteUnreferencedUserAvatar(deletedAvatar); }, [removePersona, trackMutation], ); diff --git a/src/features/agents/lib/avatarLibraryView.ts b/src/features/agents/lib/avatarLibraryView.ts new file mode 100644 index 000000000..aefaa3877 --- /dev/null +++ b/src/features/agents/lib/avatarLibraryView.ts @@ -0,0 +1,98 @@ +import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; +import { + USER_AVATAR_CATALOG_VERSION, + avatarRef, + getAvatarCatalogEntry, + mediaTypeFromMimeType, + userAvatarRef, +} from "@/shared/avatars/catalog"; +import type { + AvatarMediaType, + ResolvedAvatarMedia, +} from "@/shared/avatars/catalog"; + +const GLOOPIES_COLLECTION_ID = "gloopies"; + +/** One selectable avatar in a browsing surface. */ +export interface AvatarDisplayEntry { + id: string; + ref: string; + label?: string; + media?: ResolvedAvatarMedia; + /** Media type of the source asset even when the media is not cached yet. */ + fallbackMediaType: AvatarMediaType; + /** Custom gloopies have no catalog-authored label. */ + isUserAvatar: boolean; +} + +/** One collection in a browsing surface. */ +export interface AvatarDisplayCollection { + id: string; + label?: string; + entries: AvatarDisplayEntry[]; +} + +function cachedMedia( + library: AvatarLibraryState, + expectedCatalogVersion: string | undefined, + avatarId: string, +): ResolvedAvatarMedia | undefined { + const entry = library.cachedAvatarMediaById[avatarId]; + return entry?.catalogVersion === expectedCatalogVersion + ? entry.media + : undefined; +} + +function userGloopieEntries(library: AvatarLibraryState): AvatarDisplayEntry[] { + return library.userAvatarIds.map((avatarId) => ({ + id: avatarId, + ref: userAvatarRef(avatarId), + media: cachedMedia(library, USER_AVATAR_CATALOG_VERSION, avatarId), + fallbackMediaType: "video", + isUserAvatar: true, + })); +} + +/** + * Builds the single browsing model shared by the inline picker and full-screen + * gallery. Custom gloopies are prepended to the existing Gloopies collection; + * they remain a separate local storage source and are never written into the + * published catalog. + */ +export function buildAvatarDisplayCollections( + library: AvatarLibraryState, +): AvatarDisplayCollection[] { + const catalogVersion = library.catalog?.catalogVersion; + const customGloopies = userGloopieEntries(library); + + return (library.catalog?.collections ?? []).map((collection) => { + const bundledEntries = collection.avatarIds.flatMap((avatarId) => { + const entry = getAvatarCatalogEntry(library.catalog, avatarId); + if (!entry) { + return []; + } + const fallbackVariant = entry.variants.webm ?? entry.variants.hevc; + return [ + { + id: entry.id, + ref: avatarRef(entry.id), + label: entry.label, + media: cachedMedia(library, catalogVersion, entry.id), + fallbackMediaType: fallbackVariant + ? mediaTypeFromMimeType(fallbackVariant.mimeType) + : "image", + isUserAvatar: false, + }, + ]; + }); + + return { + id: collection.id, + label: collection.label, + entries: + collection.id === GLOOPIES_COLLECTION_ID + ? [...customGloopies, ...bundledEntries] + : bundledEntries, + }; + }); +} diff --git a/src/features/agents/ui/AgentBuilderRail.tsx b/src/features/agents/ui/AgentBuilderRail.tsx index 0da52cb8a..4f982c258 100644 --- a/src/features/agents/ui/AgentBuilderRail.tsx +++ b/src/features/agents/ui/AgentBuilderRail.tsx @@ -14,7 +14,7 @@ import { IconSparkles, IconX, } from "@tabler/icons-react"; -import { avatarRef, parseAvatarRef } from "@/shared/avatars/catalog"; +import { avatarRef, isLibraryAvatarRef } from "@/shared/avatars/catalog"; import { normalizeAvatarUrl } from "@/shared/lib/avatarUrl"; import { cn } from "@/shared/lib/cn"; import type { AgentSourceEntry } from "@/shared/api/agents"; @@ -200,13 +200,16 @@ export function AgentBuilderRail({ const [selectedCollectionId, setSelectedCollectionId] = useState< string | null >(null); - const selectedCollection = useMemo( - () => + const selectedCollectionLabel = useMemo(() => { + if (!selectedCollectionId) { + return null; + } + return ( avatarLibrary.catalog?.collections.find( (collection) => collection.id === selectedCollectionId, - ) ?? null, - [avatarLibrary.catalog, selectedCollectionId], - ); + )?.label ?? null + ); + }, [avatarLibrary.catalog, selectedCollectionId]); const provider = (data?.properties?.provider as string | undefined) ?? ""; const modelProviderId = @@ -228,8 +231,8 @@ export function AgentBuilderRail({ ); const onSelectAvatar = useCallback( - (avatarId: string) => { - writeProperty("avatar", avatarRef(avatarId)); + (selectedAvatarRef: string) => { + writeProperty("avatar", selectedAvatarRef); setSelectedCollectionId(null); setAvatarPanel("closed"); }, @@ -258,11 +261,10 @@ export function AgentBuilderRail({ : null; const effectiveAvatar = normalizedAvatar ?? (defaultAvatarId ? avatarRef(defaultAvatarId) : null); - const selectedAvatarRefValue = effectiveAvatar - ? parseAvatarRef(effectiveAvatar) + const selectedAvatarRefValue = + effectiveAvatar && isLibraryAvatarRef(effectiveAvatar) ? effectiveAvatar - : null - : null; + : null; const selectedAvatarMediaState = useAvatarMediaState(effectiveAvatar); const onChangeProvider = useCallback( @@ -564,9 +566,7 @@ export function AgentBuilderRail({