Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/pet.tsx
T

277 lines
8.4 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.
/**
* The Pet tab: browse the petdex gallery and use a companion's first sprite
* frame as the bot's profile picture.
*/
import { Button, cn, GlyphSpinner, host, Input, LruCache, RowButton, useQuery } from '@hermes/plugin-sdk'
import { useEffect, useState } from 'react'
import { useBots } from './i18n'
import { ID } from './shared'
// ── pet tab: attach a petdex companion that lives beside the avatar ─────────
// A petdex "spritesheet" is the FULL animation sheet (1536×1872 webp, ~2MB;
// 8×9 grid of 192×208 frames). Using it as an <img> both downloads megabytes
// per tile and shows the whole sheet squashed. Extract frame 0 once per slug
// via canvas, downscale to 96px, and cache the data URL. Concurrency-capped
// so opening the tab doesn't fire dozens of 2MB fetches at once.
const PET_FRAME_W = 192
const PET_FRAME_H = 208
// The gallery is 4500+ pets browsed 24 at a time, and each entry is a decoded
// PNG data URL — an unbounded cache holds every pet the user ever scrolled
// past for the life of the window. Five pages' worth keeps scrolling back up
// instant; past that a revisit pays the fetch and crop again.
const PET_FRAME_CACHE_MAX = 120
const petFrameCache = new LruCache<string, Promise<null | string>>(PET_FRAME_CACHE_MAX)
let petFetchActive = 0
const petFetchQueue: Array<() => Promise<void>> = []
function pumpPetQueue() {
while (petFetchActive < 4 && petFetchQueue.length) {
const job = petFetchQueue.shift()!
petFetchActive++
job().finally(() => {
petFetchActive--
pumpPetQueue()
})
}
}
function petFrameIcon(spriteUrl: null | string | undefined): Promise<null | string> {
if (!spriteUrl) {
return Promise.resolve(null)
}
if (!petFrameCache.has(spriteUrl)) {
petFrameCache.set(
spriteUrl,
new Promise(resolve => {
petFetchQueue.push(async () => {
try {
const resp = await fetch(spriteUrl, {
signal: AbortSignal.timeout(15000)
})
const blob = await resp.blob()
// Crop frame 0 during decode — never materialize the full sheet.
const bitmap = await createImageBitmap(blob, 0, 0, PET_FRAME_W, PET_FRAME_H)
const canvas = document.createElement('canvas')
canvas.width = 96
canvas.height = 104
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, 96, 104)
bitmap.close()
resolve(canvas.toDataURL('image/png'))
} catch {
petFrameCache.delete(spriteUrl)
resolve(null)
}
})
pumpPetQueue()
})
)
}
return petFrameCache.get(spriteUrl)!
}
interface PetThumbProps {
size?: number
spriteUrl?: null | string
}
/** One pet tile image: frame 0 only, resolved lazily through the cache. */
function PetThumb({ spriteUrl, size = 40 }: PetThumbProps) {
const [icon, setIcon] = useState<null | string>(null)
useEffect(() => {
let alive = true
petFrameIcon(spriteUrl).then(url => {
if (alive) {
setIcon(url)
}
})
return () => {
alive = false
}
}, [spriteUrl])
if (!icon) {
return (
<div
style={{
width: size,
height: size,
borderRadius: 6,
background: 'var(--chrome-action-hover, rgba(255,255,255,0.06))'
}}
/>
)
}
return (
<img
alt=""
src={icon}
style={{
width: size,
height: size,
objectFit: 'contain',
imageRendering: 'pixelated',
borderRadius: 6
}}
/>
)
}
/** One petdex companion as `pet.gallery` reports it. */
interface PetGalleryEntry {
curated?: boolean
displayName?: string
installed?: boolean
slug: string
/** Full animation sheet (1536×1872 webp); frame 0 is cropped out of it. */
spritesheetUrl?: null | string
}
interface PetTabProps {
image: null | string
onImage: (image: null | string) => void
}
export function PetTab({ image, onImage }: PetTabProps) {
const b = useBots()
// Selection is dialog-local: committed by the dialog's Save like any
// uploaded/generated image (a direct meta write here gets clobbered by
// Save's own image state).
const [selectedSlug, setSelectedSlug] = useState<null | string>(null)
const { data, isLoading } = useQuery({
queryKey: [ID, 'pet-gallery'],
queryFn: () => host.request<{ pets?: PetGalleryEntry[] }>('pet.gallery', {}),
staleTime: 300000
})
const [query, setQuery] = useState('')
// Windowed rendering: the gallery is 4500+ pets — mounting an <img> per pet
// froze the dialog. Render `limit` at a time and grow on scroll-to-bottom.
const [limit, setLimit] = useState(24)
const pets = data?.pets ?? []
if (isLoading) {
return (
<div className="flex justify-center py-4">
<GlyphSpinner className="text-(--ui-text-tertiary)" spinner="breathe" />
</div>
)
}
if (!pets.length) {
return (
<div className="px-2 py-3 text-center text-xs text-(--ui-text-tertiary)">
No pets in the petdex gallery. Run `hermes pets` to explore.
</div>
)
}
const q = query.trim().toLowerCase()
const filtered = q
? pets.filter(pet => (pet.displayName || '').toLowerCase().includes(q) || (pet.slug || '').includes(q))
: pets
// Installed and curated pets surface first — they're the likeliest picks.
const ranked = filtered.slice().sort((a, b) => {
const rank = (pet: PetGalleryEntry) => (pet.installed ? 0 : pet.curated ? 1 : 2)
return rank(a) - rank(b)
})
const visible = ranked.slice(0, limit)
const onScroll = (event: { currentTarget: HTMLDivElement }) => {
const el = event.currentTarget
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && limit < ranked.length) {
setLimit(prev => Math.min(prev + 24, ranked.length))
}
}
return (
<div className="grid w-full gap-2">
<div className="text-center text-[0.65rem] text-(--ui-text-quaternary)">{b.avatar.pickPet}</div>
<Input
className="h-7 text-xs"
onChange={event => {
setQuery(event.target.value)
setLimit(24)
}}
placeholder={`Search ${pets.length} pets…`}
value={query}
/>
{image && selectedSlug ? (
<Button
className="justify-center"
onClick={() => {
setSelectedSlug(null)
onImage(null)
}}
size="sm"
type="button"
variant="ghost"
>
{b.avatar.removeBackToShape}
</Button>
) : null}
{filtered.length === 0 ? (
<div className="py-3 text-center text-xs text-(--ui-text-quaternary)">No pets match.</div>
) : (
<div
className="overflow-y-auto"
onScroll={onScroll}
style={{
maxHeight: 220
}}
>
<div className="grid grid-cols-3 gap-1.5">
{visible.map(pet => (
<RowButton
className={cn(
'grid justify-items-center gap-1 rounded-md p-1.5 transition-colors hover:bg-(--chrome-action-hover)',
selectedSlug === pet.slug && 'ring-1 ring-(--ui-accent)'
)}
key={pet.slug}
onClick={() => {
// The pet IS the profile picture: extract frame 0
// and hand it to the dialog as the avatar image.
// Persisted when the user hits Save.
setSelectedSlug(pet.slug)
void petFrameIcon(pet.spritesheetUrl).then(icon => {
if (icon) {
onImage(icon)
} else {
setSelectedSlug(null)
host.notify({
kind: 'error',
message: b.avatar.petLoadFailed
})
}
})
}}
>
<PetThumb size={40} spriteUrl={pet.spritesheetUrl} />
<span className="w-full truncate text-center text-[0.6rem] text-(--ui-text-tertiary)">
{pet.displayName}
</span>
</RowButton>
))}
</div>
{limit < ranked.length ? (
<div className="py-2 text-center text-[0.65rem] text-(--ui-text-quaternary)">{`Scroll for more (${limit} of ${ranked.length})`}</div>
) : null}
</div>
)}
</div>
)
}