feat(sagi): custom pets — Codex pet bundles, images, featured downloads

The mascot can wear a pet. Right-click it for Hide pet, Size (48-250
px), Choose pet (Sagi, featured, your own - deletable), Upload your
own..., Import .codex-pet bundle..., Find more on codex-pets.net and
Pet settings.... The worn pet and its size are viewer prefs
(agenthub.pet, agenthub.pet.size); the pets live on the server under
<data dir>/pets/<id>/ (server/lib/pets.js, /api/pets).

A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872
spritesheet.webp, 8x9 frames of 192x208, one row per state.
PetSprite.tsx plays the row of the mascot's mood; an uploaded image
bobs. Only the two bundle files are read, sizes are checked from the
zip directory before inflating, the sheet's WebP header must match,
uploads are raster only (no SVG), ids are made by the server, every
write is same-origin.

The four featured pets (Goku, Bac Ho, Border Collie, 11) are
codex-pets.net uploads with no redistribution licence, so none is
bundled: a pick downloads it from codex-pets.net by a fixed id.
This commit is contained in:
2026-10-03 16:25:42 +07:00
parent 8098f446b6
commit ed1bf20e6b
18 changed files with 1615 additions and 55 deletions
+65 -9
View File
@@ -71,10 +71,12 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble";
import { SagiPanel } from "./SagiPanel";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
import { useSagiPosition } from "./useSagiPosition";
import { matchIntent } from "./intents";
import { sagiPrefs } from "./prefs";
import { api } from "../../lib/api";
@@ -114,7 +116,16 @@ function usePrefersReducedMotion(): boolean {
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
* into view instead of being cropped.
*/
function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
function SagiFlyout({
anchor,
children,
front = false,
}: {
anchor: Anchor;
children: ReactNode;
front?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
@@ -160,6 +171,7 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
// Mirrors the avatar button: eased left/top so the flyout glides along
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
data-dragging={anchor.dragging ? "1" : "0"}
data-front={front ? "1" : undefined}
style={style}
>
{children}
@@ -169,14 +181,26 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
export function Sagi() {
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [pet, setPet] = useState(() => sagiPrefs.getPet());
const [size, setSize] = useState(() => sagiPrefs.getSize());
const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useSagiBrain();
const place = useSagiPosition();
const place = useSagiPosition(size);
// Keep enabled in sync with Settings / other tabs.
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
// Keep enabled, the pet and its size in sync with Settings / other tabs.
useEffect(
() =>
sagiPrefs.subscribe(() => {
setEnabled(sagiPrefs.getEnabled());
setPet(sagiPrefs.getPet());
setSize(sagiPrefs.getSize());
}),
[]
);
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
useEffect(() => {
@@ -263,7 +287,21 @@ export function Sagi() {
</SagiFlyout>
)}
{!open && brain.bubble && (
{!place.dragging && menu && (
<SagiFlyout anchor={anchor} front>
<PetMenu
pet={pet}
size={size}
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/general#sagi");
}}
/>
</SagiFlyout>
)}
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
</SagiFlyout>
@@ -272,7 +310,7 @@ export function Sagi() {
<button
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: SAGI_SIZE, height: SAGI_SIZE }}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
@@ -282,11 +320,29 @@ export function Sagi() {
if (place.consumeDrag()) return;
setOpen((v) => !v);
}}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
setMenu(true);
}}
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
aria-expanded={open}
title="Sagi - ⌘B · drag to move"
title="Sagi - ⌘B · drag to move · right-click for pets"
>
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
{pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
) : (
<PetSprite
id={pet.id}
kind={pet.kind}
mood={brain.mood}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
/>
)}
{brain.status.errorCount > 0 && (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}