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:
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @file PetMenu.tsx
|
||||
* @description The mascot's right-click menu: hide it, size it, choose the
|
||||
* pet it wears — Sagi, a featured pet (downloaded from codex-pets.net into
|
||||
* this install on the first pick, `POST /api/pets/featured/:id`), or one of
|
||||
* your own (deletable) — upload an image or import a `.codex-pet.zip`, browse
|
||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||
* (`server/lib/pets.js`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type PetList, type PetView } from "../../lib/api";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
|
||||
|
||||
export function PetMenu({
|
||||
pet,
|
||||
size,
|
||||
onClose,
|
||||
onSettings,
|
||||
}: {
|
||||
pet: PetChoice;
|
||||
size: number;
|
||||
onClose: () => void;
|
||||
onSettings: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const [list, setList] = useState<PetList | null>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = () =>
|
||||
api.pets
|
||||
.list()
|
||||
.then(setList)
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)));
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
// Closes on a press outside the menu, or Escape.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const run = async (key: string, work: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
const addFile = (file: File | undefined, bundle: boolean) =>
|
||||
file &&
|
||||
void run(bundle ? "bundle" : "image", async () => {
|
||||
const { pet: added } = bundle
|
||||
? await api.pets.importBundle(file)
|
||||
: await api.pets.upload(file);
|
||||
wear(added);
|
||||
await reload();
|
||||
});
|
||||
|
||||
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
|
||||
const installed = new Map(
|
||||
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
|
||||
);
|
||||
const mark = (id: string) =>
|
||||
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid="pet-menu"
|
||||
className="w-72 rounded-xl border border-border bg-surface-2 p-1.5 text-sm shadow-lg"
|
||||
>
|
||||
<div className="px-2.5 pb-1 pt-0.5 text-xs font-medium text-fg-muted">
|
||||
{t("sagi.pet.title")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
sagiPrefs.setEnabled(false);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
{t("sagi.pet.hide")}
|
||||
</button>
|
||||
<label className="block px-2.5 py-1.5">
|
||||
<span className="flex items-center justify-between text-sm text-fg-secondary">
|
||||
{t("sagi.pet.size")}
|
||||
<span className="tabular-nums text-xs text-fg-muted">{size}px</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
data-testid="pet-size"
|
||||
min={PET_SIZE.min}
|
||||
max={PET_SIZE.max}
|
||||
value={size}
|
||||
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
|
||||
className="mt-1 w-full accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
</label>
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-expanded={choosing}
|
||||
data-testid="pet-choose"
|
||||
className={ITEM}
|
||||
onClick={() => setChoosing((v) => !v)}
|
||||
>
|
||||
{choosing ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
{t("sagi.pet.choose")}
|
||||
</button>
|
||||
{choosing && (
|
||||
<div className="max-h-80 overflow-y-auto pl-2">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === "sagi"}
|
||||
className={ITEM}
|
||||
onClick={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
>
|
||||
Sagi
|
||||
{mark("sagi")}
|
||||
</button>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={!!have && pet.id === have.id}
|
||||
data-testid={`pet-featured-${f.id}`}
|
||||
disabled={busy !== null}
|
||||
title={t("sagi.pet.featuredBy", { by: f.by })}
|
||||
className={ITEM}
|
||||
onClick={() =>
|
||||
have
|
||||
? wear(have)
|
||||
: void run(f.id, async () => {
|
||||
wear((await api.pets.installFeatured(f.id)).pet);
|
||||
await reload();
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 truncate">{f.displayName}</span>
|
||||
{busy === f.id ? (
|
||||
<Loader2 className="ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : have ? (
|
||||
mark(have.id)
|
||||
) : (
|
||||
<Download
|
||||
aria-label={t("sagi.pet.download")}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{own.length > 0 && <div className="my-1.5 border-t border-border" />}
|
||||
{own.map((p) => (
|
||||
<div key={p.id} className="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === p.id}
|
||||
data-testid={`pet-own-${p.id}`}
|
||||
className={ITEM}
|
||||
onClick={() => wear(p)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||
{mark(p.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(p.id, async () => {
|
||||
await api.pets.remove(p.id);
|
||||
if (pet.id === p.id) sagiPrefs.setPet({ id: "sagi" });
|
||||
await reload();
|
||||
})
|
||||
}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-image-input"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], false);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={bundleInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-bundle-input"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], true);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="px-2.5 py-1 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("sagi.pet.settings")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const SHEET = { cols: 8, rows: 9 };
|
||||
/** Frames in each row of the Codex atlas. */
|
||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||||
/** Which row plays for each mood. */
|
||||
const ROW: Record<Mood, number> = {
|
||||
idle: 0,
|
||||
sleeping: 0,
|
||||
thinking: 7, // running — an agent is working
|
||||
watching: 8, // review
|
||||
happy: 4, // jumping
|
||||
stuck: 6, // waiting — for the user
|
||||
worried: 5, // failed
|
||||
disconnected: 5,
|
||||
};
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
}: {
|
||||
id: string;
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = ROW[mood] ?? 0;
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const timer = window.setInterval(
|
||||
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
|
||||
mood === "sleeping" ? 400 : 125
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [kind, row, mood, reducedMotion]);
|
||||
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
const probe = new Image();
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, kind]);
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
className="sagi-pet-image"
|
||||
width={size}
|
||||
height={size}
|
||||
draggable={false}
|
||||
onError={onBroken}
|
||||
style={{ width: size, height: size, objectFit: "contain" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const scale = size / CELL.h;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
height: size,
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file Pets.test.tsx
|
||||
* @description The mascot's custom pets: `PetSprite` plays the spritesheet
|
||||
* row of Sagi's mood and steps through its frames (a still image bobs instead),
|
||||
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
|
||||
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
|
||||
* back to Sagi when it was the one worn), and uploads an image.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const own = {
|
||||
id: "p-aaaaaaaaaaaa",
|
||||
displayName: "My cat",
|
||||
description: "",
|
||||
kind: "image",
|
||||
source: "upload",
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pets: {
|
||||
spriteUrl: (id: string) => `/api/pets/${id}/sprite`,
|
||||
list: vi.fn(async () => ({
|
||||
pets: [own],
|
||||
featured: [
|
||||
{ id: "goku", displayName: "Goku", by: "hackminit", installed: false, page: "" },
|
||||
],
|
||||
sheet: { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 },
|
||||
})),
|
||||
installFeatured: vi.fn(async () => ({
|
||||
pet: {
|
||||
id: "cp-goku",
|
||||
displayName: "Goku",
|
||||
description: "",
|
||||
kind: "codex",
|
||||
source: "featured",
|
||||
featuredId: "goku",
|
||||
},
|
||||
})),
|
||||
upload: vi.fn(async () => ({
|
||||
pet: {
|
||||
id: "p-bbbbbbbbbbbb",
|
||||
displayName: "Dog",
|
||||
description: "",
|
||||
kind: "image",
|
||||
source: "upload",
|
||||
},
|
||||
})),
|
||||
importBundle: vi.fn(),
|
||||
remove: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { PetSprite } from "../PetSprite";
|
||||
import { PetMenu } from "../PetMenu";
|
||||
import { sagiPrefs } from "../prefs";
|
||||
|
||||
describe("PetSprite", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("plays the row of the mood and steps through its frames", () => {
|
||||
const { rerender } = render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="thinking"
|
||||
size={104}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="worried"
|
||||
size={104}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
size={208}
|
||||
reducedMotion
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("shows an uploaded image still, and reports one that will not load", () => {
|
||||
const broken = vi.fn();
|
||||
const { container } = render(
|
||||
<PetSprite
|
||||
id="p-aaaaaaaaaaaa"
|
||||
kind="image"
|
||||
mood="idle"
|
||||
size={80}
|
||||
reducedMotion={false}
|
||||
onBroken={broken}
|
||||
/>
|
||||
);
|
||||
const img = container.querySelector("img") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("/api/pets/p-aaaaaaaaaaaa/sprite");
|
||||
fireEvent.error(img);
|
||||
expect(broken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PetMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const renderMenu = () =>
|
||||
render(<PetMenu pet={sagiPrefs.getPet()} size={68} onClose={() => {}} onSettings={() => {}} />);
|
||||
|
||||
it("hides the mascot and sizes it", () => {
|
||||
renderMenu();
|
||||
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
|
||||
expect(sagiPrefs.getSize()).toBe(250);
|
||||
fireEvent.click(screen.getByText("Hide pet"));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("downloads a featured pet on its first pick, then wears it", async () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
it("deleting the pet being worn puts Sagi back", async () => {
|
||||
sagiPrefs.setPet({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("uploads an image and wears it", async () => {
|
||||
renderMenu();
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "Dog.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
);
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file prefs.ts
|
||||
* @description Tiny localStorage-backed preference store for Sagi (enabled +
|
||||
* muted). Broadcasts changes via a window CustomEvent so the Settings toggle
|
||||
* and the live widget stay in sync within the same tab without a reload.
|
||||
* @description Tiny localStorage-backed preference store for Sagi (enabled,
|
||||
* muted, the pet it wears and its size). Broadcasts changes via a window
|
||||
* CustomEvent so the Settings toggle and the live widget stay in sync within
|
||||
* the same tab without a reload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -53,6 +54,54 @@
|
||||
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
|
||||
const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const PET_KEY = "agenthub.pet";
|
||||
const SIZE_KEY = "agenthub.pet.size";
|
||||
/** Sagi's own size, and the slider's range. */
|
||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||
|
||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||
* The kind rides along so the mascot renders without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
typeof p.id === "string" &&
|
||||
p.id !== "sagi" &&
|
||||
(p.kind === "codex" || p.kind === "image")
|
||||
)
|
||||
return { id: p.id, kind: p.kind };
|
||||
} catch {
|
||||
/* unreadable: Sagi */
|
||||
}
|
||||
return SAGI;
|
||||
}
|
||||
|
||||
function readSize(): number {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return Number.isFinite(n) && n > 0
|
||||
? Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, n)))
|
||||
: PET_SIZE.default;
|
||||
}
|
||||
|
||||
function writeAndNotify(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Best-effort, like every pref here.
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(EVENT));
|
||||
} catch {
|
||||
// SSR / non-DOM contexts: nothing to notify.
|
||||
}
|
||||
}
|
||||
const EVENT = "sagi:prefs";
|
||||
|
||||
/**
|
||||
@@ -118,6 +167,20 @@ export const sagiPrefs = {
|
||||
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
|
||||
getPos: readPos,
|
||||
setPos: writePos,
|
||||
getPet: readPet,
|
||||
setPet: (pet: PetChoice) => writeAndNotify(PET_KEY, JSON.stringify(pet)),
|
||||
getSize: () => {
|
||||
try {
|
||||
return readSize();
|
||||
} catch {
|
||||
return PET_SIZE.default;
|
||||
}
|
||||
},
|
||||
setSize: (px: number) =>
|
||||
writeAndNotify(
|
||||
SIZE_KEY,
|
||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
||||
),
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -369,3 +369,37 @@
|
||||
.sagi-bubble.sagi-no-anim {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ════════ a custom pet (PetSprite.tsx) ════════ */
|
||||
|
||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
||||
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||
.sagi-pet-image {
|
||||
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.sagi-pet-image[data-mood="thinking"] {
|
||||
animation-duration: 0.9s;
|
||||
}
|
||||
.sagi-pet-image[data-mood="sleeping"] {
|
||||
animation-duration: 5s;
|
||||
}
|
||||
@keyframes sagi-pet-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sagi-pet-image {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The pet menu may overlap the mascot; it sits above it while open. */
|
||||
.sagi-flyout[data-front="1"] {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ function defaultPos(): SagiPos {
|
||||
}
|
||||
|
||||
/** Resting top-left screen coords for a docked position. */
|
||||
function restingScreen(pos: SagiPos) {
|
||||
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
|
||||
function restingScreen(pos: SagiPos, size: number) {
|
||||
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
|
||||
const top = SAGI_MARGIN + pos.y * avail;
|
||||
return { left, top };
|
||||
}
|
||||
@@ -108,7 +108,8 @@ export interface SagiPlacement {
|
||||
consumeDrag: () => boolean;
|
||||
}
|
||||
|
||||
export function useSagiPosition(): SagiPlacement {
|
||||
/** `size`: the avatar's current footprint (the pet size pref), in px. */
|
||||
export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||
const [, force] = useState(0); // re-derive resting coords on resize
|
||||
@@ -127,7 +128,7 @@ export function useSagiPosition(): SagiPlacement {
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const resting = restingScreen(pos);
|
||||
const resting = restingScreen(pos, size);
|
||||
const screen = drag ?? resting;
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
@@ -146,40 +147,46 @@ export function useSagiPosition(): SagiPlacement {
|
||||
[screen.left, screen.top]
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((e: ReactPointerEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.px;
|
||||
const dy = e.clientY - start.py;
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
}, []);
|
||||
const onPointerMove = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.px;
|
||||
const dy = e.clientY - start.py;
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(vh() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback((e: ReactPointerEvent) => {
|
||||
try {
|
||||
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const live = liveRef.current;
|
||||
if (live) {
|
||||
draggedRef.current = true;
|
||||
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
}, []);
|
||||
const onPointerUp = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
try {
|
||||
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const live = liveRef.current;
|
||||
if (live) {
|
||||
draggedRef.current = true;
|
||||
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
const was = draggedRef.current;
|
||||
@@ -190,9 +197,9 @@ export function useSagiPosition(): SagiPlacement {
|
||||
return {
|
||||
left: screen.left,
|
||||
top: screen.top,
|
||||
size: SAGI_SIZE,
|
||||
size,
|
||||
side: pos.side,
|
||||
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
|
||||
openUp: screen.top + size / 2 > vh() / 2,
|
||||
dragging: drag !== null,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
|
||||
@@ -8,7 +8,21 @@
|
||||
"title": "Sagi companion",
|
||||
"description": "A floating mascot that reacts to your live sessions.",
|
||||
"enable": "Show Sagi",
|
||||
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)"
|
||||
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)",
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"hide": "Hide pet",
|
||||
"size": "Size",
|
||||
"choose": "Choose pet",
|
||||
"featuredBy": "By {{by}} on codex-pets.net — downloaded into this install on first pick",
|
||||
"download": "Download",
|
||||
"delete": "Delete {{name}}",
|
||||
"upload": "Upload your own…",
|
||||
"import": "Import .codex-pet bundle…",
|
||||
"browse": "Find more on codex-pets.net",
|
||||
"settings": "Pet settings…"
|
||||
},
|
||||
"petHint": "Right-click the mascot to choose its pet — Sagi, a featured or imported Codex pet, or your own image — and its size."
|
||||
},
|
||||
"pricing": {
|
||||
"vendor": {
|
||||
|
||||
@@ -8,7 +8,21 @@
|
||||
"title": "Bạn đồng hành Sagi",
|
||||
"description": "Linh vật nổi phản ứng với các phiên trực tiếp của bạn.",
|
||||
"enable": "Hiện Sagi",
|
||||
"enableDesc": "Hiển thị bạn đồng hành ở góc trên toàn bảng điều khiển (⌘B để mở)"
|
||||
"enableDesc": "Hiển thị bạn đồng hành ở góc trên toàn bảng điều khiển (⌘B để mở)",
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"hide": "Ẩn pet",
|
||||
"size": "Kích thước",
|
||||
"choose": "Chọn pet",
|
||||
"featuredBy": "Của {{by}} trên codex-pets.net — tải về máy này ở lần chọn đầu",
|
||||
"download": "Tải về",
|
||||
"delete": "Xoá {{name}}",
|
||||
"upload": "Tải ảnh của bạn lên…",
|
||||
"import": "Nhập bundle .codex-pet…",
|
||||
"browse": "Tìm thêm trên codex-pets.net",
|
||||
"settings": "Cài đặt pet…"
|
||||
},
|
||||
"petHint": "Nhấp chuột phải vào mascot để chọn pet — Sagi, pet Codex có sẵn hoặc tự nhập, hay ảnh của bạn — và kích thước."
|
||||
},
|
||||
"pricing": {
|
||||
"vendor": {
|
||||
|
||||
@@ -2010,6 +2010,31 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
||||
pets: {
|
||||
list: () => request<PetList>("/pets"),
|
||||
/** The pet's spritesheet (a Codex pet) or image, served raster-only. */
|
||||
spriteUrl: (id: string) => `${BASE}/pets/${encodeURIComponent(id)}/sprite`,
|
||||
/** POST /api/pets/import — a `.codex-pet.zip` (pet.json + spritesheet.webp). */
|
||||
importBundle: (file: File) =>
|
||||
request<{ pet: PetView }>("/pets/import", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** POST /api/pets/upload — one PNG, JPEG, GIF or WebP image. */
|
||||
upload: (file: File) =>
|
||||
request<{ pet: PetView }>(`/pets/upload?name=${encodeURIComponent(file.name)}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** POST /api/pets/featured/:id — download a featured pet from codex-pets.net. */
|
||||
installFeatured: (id: string) =>
|
||||
request<{ pet: PetView }>(`/pets/featured/${encodeURIComponent(id)}`, { method: "POST" }),
|
||||
remove: (id: string) =>
|
||||
request<{ ok: true }>(`/pets/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
},
|
||||
aiTools: {
|
||||
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
|
||||
list: () => request<AiToolsView>("/ai-tools"),
|
||||
@@ -4127,3 +4152,28 @@ export interface ImportBackupResult {
|
||||
/** Bundle entries that could not be restored (e.g. a session with no id). */
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** A custom pet (`GET /api/pets`). `codex` is a Codex spritesheet, `image` one still image. */
|
||||
export interface PetView {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
kind: "codex" | "image";
|
||||
source: "upload" | "featured";
|
||||
featuredId?: string;
|
||||
by?: string;
|
||||
}
|
||||
|
||||
export interface FeaturedPet {
|
||||
id: string;
|
||||
displayName: string;
|
||||
by: string;
|
||||
installed: boolean;
|
||||
page: string;
|
||||
}
|
||||
|
||||
export interface PetList {
|
||||
pets: PetView[];
|
||||
featured: FeaturedPet[];
|
||||
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
|
||||
}
|
||||
|
||||
@@ -496,6 +496,9 @@ export function Settings({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p data-testid="sagi-pet-hint" className="mt-3 text-xs text-fg-muted">
|
||||
{t("sagi.petHint")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -7753,6 +7753,12 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="mt-3 text-xs text-fg-muted"
|
||||
data-testid="sagi-pet-hint"
|
||||
>
|
||||
Right-click the mascot to choose its pet — Sagi, a featured or imported Codex pet, or your own image — and its size.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
|
||||
+17
@@ -2267,6 +2267,23 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
|
||||
|
||||
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
|
||||
|
||||
### Pets (the mascot's custom pets)
|
||||
|
||||
```
|
||||
GET /api/pets { pets, featured, sheet } — your pets, the featured list (with `installed`), the atlas geometry
|
||||
GET /api/pets/:id/sprite A pet's spritesheet (image/webp) or image, raster only, `nosniff`
|
||||
POST /api/pets/import A `.codex-pet.zip` — raw body (application/octet-stream), ≤ 8 MB
|
||||
POST /api/pets/upload?name=<file> One PNG, JPEG, GIF or WebP image — raw body, ≤ 4 MB
|
||||
POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.net into this install
|
||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||
```
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…** (Settings → General → Sagi). Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
**Featured pets are downloaded, never bundled.** The four offered (Goku, Bac Ho, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket API
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @file pets.test.js
|
||||
* @description `/api/pets` and `server/lib/pets.js`: importing a
|
||||
* `.codex-pet.zip` (root or one folder deep; refused when a file is missing,
|
||||
* the sheet is not a 1536×1872 WebP, or an entry is too large BEFORE it is
|
||||
* inflated), uploading an image (raster only — SVG refused), installing a
|
||||
* featured pet only from codex-pets.net by its fixed id, deleting, serving the
|
||||
* sprite with nosniff, ids made by the server, and every write same-origin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const AdmZip = require("adm-zip");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pets-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const pets = require("../lib/pets");
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
/** A WebP header (VP8X) claiming `w`×`h` — the server never decodes pixels. */
|
||||
function webp(w = 1536, h = 1872, pad = 64) {
|
||||
const b = Buffer.alloc(30 + pad);
|
||||
b.write("RIFF", 0);
|
||||
b.writeUInt32LE(b.length - 8, 4);
|
||||
b.write("WEBP", 8);
|
||||
b.write("VP8X", 12);
|
||||
b.writeUInt32LE(10, 16);
|
||||
b.writeUIntLE(w - 1, 24, 3);
|
||||
b.writeUIntLE(h - 1, 27, 3);
|
||||
return b;
|
||||
}
|
||||
const PNG = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.alloc(32),
|
||||
]);
|
||||
|
||||
function bundle(files) {
|
||||
const zip = new AdmZip();
|
||||
for (const [name, data] of Object.entries(files))
|
||||
zip.addFile(name, Buffer.isBuffer(data) ? data : Buffer.from(JSON.stringify(data)));
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
function request(method, url, { body, headers = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
`${base}${url}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { "content-type": "application/octet-stream" } : {}),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(buf.toString("utf8"));
|
||||
} catch {
|
||||
/* a sprite */
|
||||
}
|
||||
resolve({ status: res.statusCode, headers: res.headers, json, buf });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const app = createApp();
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
pets.__setFetch(null);
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(ROOT, "data", "pets"), { recursive: true, force: true });
|
||||
pets.__setFetch(() => {
|
||||
throw new Error("no network in tests");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importing a .codex-pet bundle", () => {
|
||||
it("stores pet.json + spritesheet.webp under an id the server makes, and serves the sheet", async () => {
|
||||
const r = await request("POST", "/api/pets/import", {
|
||||
body: bundle({
|
||||
"pet.json": { id: "../../evil", displayName: " Corgi\nByte ", description: "A corgi." },
|
||||
"spritesheet.webp": webp(),
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
assert.match(r.json.pet.id, /^p-[0-9a-f]{12}$/);
|
||||
assert.equal(r.json.pet.displayName, "Corgi Byte");
|
||||
assert.equal(r.json.pet.kind, "codex");
|
||||
const list = await request("GET", "/api/pets");
|
||||
assert.deepEqual(
|
||||
list.json.pets.map((p) => p.id),
|
||||
[r.json.pet.id]
|
||||
);
|
||||
assert.deepEqual(list.json.sheet, {
|
||||
width: 1536,
|
||||
height: 1872,
|
||||
cellWidth: 192,
|
||||
cellHeight: 208,
|
||||
});
|
||||
const sprite = await request("GET", `/api/pets/${r.json.pet.id}/sprite`);
|
||||
assert.equal(sprite.status, 200);
|
||||
assert.equal(sprite.headers["content-type"], "image/webp");
|
||||
assert.equal(sprite.headers["x-content-type-options"], "nosniff");
|
||||
});
|
||||
|
||||
it("accepts the two files one folder deep", async () => {
|
||||
const r = await request("POST", "/api/pets/import", {
|
||||
body: bundle({
|
||||
"corgi/pet.json": { displayName: "Corgi" },
|
||||
"corgi/spritesheet.webp": webp(),
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
});
|
||||
|
||||
it("refuses a bundle missing a file, a sheet of the wrong size, and a non-zip", async () => {
|
||||
const missing = await request("POST", "/api/pets/import", { body: bundle({ "pet.json": {} }) });
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal(missing.json.error.code, "EBADPET");
|
||||
const wrong = await request("POST", "/api/pets/import", {
|
||||
body: bundle({ "pet.json": {}, "spritesheet.webp": webp(512, 512) }),
|
||||
});
|
||||
assert.equal(wrong.status, 400);
|
||||
assert.match(wrong.json.error.message, /512×512/);
|
||||
const notZip = await request("POST", "/api/pets/import", { body: Buffer.from("hello") });
|
||||
assert.equal(notZip.json.error.code, "EBADPET");
|
||||
assert.deepEqual((await request("GET", "/api/pets")).json.pets, []);
|
||||
});
|
||||
|
||||
it("refuses an oversized entry from its header, before inflating it", async () => {
|
||||
const r = await request("POST", "/api/pets/import", {
|
||||
body: bundle({
|
||||
"pet.json": {},
|
||||
"spritesheet.webp": Buffer.concat([webp(), Buffer.alloc(7 * 1024 * 1024)]),
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 413);
|
||||
assert.equal(r.json.error.code, "ETOOBIG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploading an image", () => {
|
||||
it("keeps a raster image as a still pet, named after its file", async () => {
|
||||
const r = await request("POST", "/api/pets/upload?name=My%20cat.png", { body: PNG });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.json.pet.kind, "image");
|
||||
assert.equal(r.json.pet.displayName, "My cat");
|
||||
const sprite = await request("GET", `/api/pets/${r.json.pet.id}/sprite`);
|
||||
assert.equal(sprite.headers["content-type"], "image/png");
|
||||
});
|
||||
|
||||
it("refuses SVG and anything that is not a known raster", async () => {
|
||||
const svg = await request("POST", "/api/pets/upload?name=x.svg", {
|
||||
body: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
|
||||
});
|
||||
assert.equal(svg.status, 400);
|
||||
assert.equal(svg.json.error.code, "EBADPET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("featured pets", () => {
|
||||
it("installs one only from codex-pets.net by its fixed id, once per id", async () => {
|
||||
const urls = [];
|
||||
pets.__setFetch(async (url) => {
|
||||
urls.push(url);
|
||||
const body = url.endsWith("/spritesheet")
|
||||
? webp()
|
||||
: Buffer.from(JSON.stringify({ pet: { description: "A martial artist." } }));
|
||||
return { ok: true, status: 200, headers: { get: () => null }, arrayBuffer: async () => body };
|
||||
});
|
||||
const r = await request("POST", "/api/pets/featured/goku");
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.json.pet.id, "cp-goku");
|
||||
assert.equal(r.json.pet.source, "featured");
|
||||
assert.equal(r.json.pet.description, "A martial artist.");
|
||||
assert.deepEqual(urls, [
|
||||
"https://codex-pets.net/api/pets/goku",
|
||||
"https://codex-pets.net/api/pets/goku/spritesheet",
|
||||
]);
|
||||
await request("POST", "/api/pets/featured/goku");
|
||||
const list = await request("GET", "/api/pets");
|
||||
assert.equal(list.json.pets.length, 1);
|
||||
assert.equal(list.json.featured.find((f) => f.id === "goku").installed, true);
|
||||
});
|
||||
|
||||
it("refuses an id that is not featured without any request", async () => {
|
||||
let called = false;
|
||||
pets.__setFetch(async () => {
|
||||
called = true;
|
||||
});
|
||||
const r = await request("POST", "/api/pets/featured/some-other-pet");
|
||||
assert.equal(r.status, 404);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
it("reports a failed download as EFETCH and a bad sheet as EBADPET", async () => {
|
||||
pets.__setFetch(async () => ({ ok: false, status: 503, headers: { get: () => null } }));
|
||||
const down = await request("POST", "/api/pets/featured/goku");
|
||||
assert.equal(down.status, 502);
|
||||
assert.equal(down.json.error.code, "EFETCH");
|
||||
pets.__setFetch(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
arrayBuffer: async () => webp(100, 100),
|
||||
}));
|
||||
const bad = await request("POST", "/api/pets/featured/goku");
|
||||
assert.equal(bad.json.error.code, "EBADPET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleting and serving", () => {
|
||||
it("deletes a pet, and answers 404 for an unknown or path-shaped id", async () => {
|
||||
const r = await request("POST", "/api/pets/upload?name=a.png", { body: PNG });
|
||||
const del = await request("DELETE", `/api/pets/${r.json.pet.id}`);
|
||||
assert.equal(del.status, 200);
|
||||
assert.deepEqual((await request("GET", "/api/pets")).json.pets, []);
|
||||
assert.equal((await request("DELETE", `/api/pets/${r.json.pet.id}`)).status, 404);
|
||||
assert.equal((await request("GET", "/api/pets/..%2F..%2Ft.db/sprite")).status, 404);
|
||||
assert.equal((await request("DELETE", "/api/pets/p-nothere00000")).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-origin", () => {
|
||||
it("refuses every write from another origin", async () => {
|
||||
const headers = { origin: "https://evil.example" };
|
||||
assert.equal(
|
||||
(await request("POST", "/api/pets/upload?name=a.png", { body: PNG, headers })).status,
|
||||
403
|
||||
);
|
||||
assert.equal((await request("POST", "/api/pets/import", { body: PNG, headers })).status, 403);
|
||||
assert.equal((await request("POST", "/api/pets/featured/goku", { headers })).status, 403);
|
||||
assert.equal((await request("DELETE", "/api/pets/p-000000000000", { headers })).status, 403);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,7 @@ const lanesRouter = require("./routes/lanes");
|
||||
const projectsRouter = require("./routes/projects");
|
||||
const laneSourcesRouter = require("./routes/lane-sources");
|
||||
const locksRouter = require("./routes/locks");
|
||||
const petsRouter = require("./routes/pets");
|
||||
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
@@ -134,6 +135,7 @@ function createApp() {
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/projects", projectsRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
app.use("/api/pets", petsRouter);
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
app.use("/api/webhooks", webhooksRouter);
|
||||
app.use("/api/remote-sources", remoteSourcesRouter);
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* @file pets.js
|
||||
* @description Custom pets for the dashboard mascot (Sagi). A pet is a folder
|
||||
* `<data dir>/pets/<id>/` holding `meta.json` (written here) and either a
|
||||
* Codex pet's `spritesheet.webp` — the `.codex-pet.zip` format: an 8×9 grid of
|
||||
* 192×208 frames, one row per animation state — or one uploaded image. Pets
|
||||
* come from exactly three places: an uploaded `.codex-pet.zip`, an uploaded
|
||||
* image, or one of the FEATURED pets, downloaded from codex-pets.net by a fixed
|
||||
* id — never a URL or a path from a request. None is bundled with AgentHub:
|
||||
* the featured pets are other people's uploads with no redistribution
|
||||
* licence, so each install fetches its own copy from the site that hosts
|
||||
* them. An id is always made here; SVG is refused, because an SVG served from
|
||||
* the dashboard's origin runs script when opened.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const AdmZip = require("adm-zip");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const MAX_BUNDLE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_SHEET_BYTES = 6 * 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_MANIFEST_BYTES = 16 * 1024;
|
||||
const MAX_PETS = 50;
|
||||
/** The Codex atlas: 8 columns × 9 rows of 192×208 frames. */
|
||||
const SHEET = { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 };
|
||||
const FEATURED_HOST = "https://codex-pets.net";
|
||||
/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */
|
||||
const FEATURED = [
|
||||
{ id: "goku", displayName: "Goku", by: "hackminit" },
|
||||
{ id: "bac-ho", displayName: "Bac Ho", by: "k0nkupa" },
|
||||
{ id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" },
|
||||
{ id: "11", displayName: "11", by: "ct" },
|
||||
];
|
||||
const ID_RE = /^(?:p-[0-9a-f]{12}|cp-[a-z0-9-]{1,40})$/;
|
||||
const IMAGE_TYPES = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
};
|
||||
|
||||
let fetchImpl = (...args) => fetch(...args);
|
||||
/** Test seam: replace the HTTP client the featured install uses. */
|
||||
function __setFetch(fn) {
|
||||
fetchImpl = fn || ((...args) => fetch(...args));
|
||||
}
|
||||
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
function petsRoot() {
|
||||
return path.join(getDataDir(), "pets");
|
||||
}
|
||||
|
||||
function petDir(id) {
|
||||
if (!ID_RE.test(String(id))) throw fail("ENOPET", "pet not found");
|
||||
return path.join(petsRoot(), id);
|
||||
}
|
||||
|
||||
/** Width and height from a WebP header (VP8X, VP8 or VP8L), else null. */
|
||||
function webpSize(buf) {
|
||||
if (buf.length < 30) return null;
|
||||
if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP")
|
||||
return null;
|
||||
const chunk = buf.toString("ascii", 12, 16);
|
||||
if (chunk === "VP8X")
|
||||
return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
|
||||
if (chunk === "VP8 ")
|
||||
return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
||||
if (chunk === "VP8L") {
|
||||
const bits = buf.readUInt32LE(21);
|
||||
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The raster type of an uploaded image by its magic bytes, else null. */
|
||||
function sniffImage(buf) {
|
||||
if (
|
||||
buf.length >= 8 &&
|
||||
buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
)
|
||||
return "png";
|
||||
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg";
|
||||
if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.toString("ascii", 0, 6))) return "gif";
|
||||
if (
|
||||
webpSize(buf) ||
|
||||
(buf.length >= 12 &&
|
||||
buf.toString("ascii", 8, 12) === "WEBP" &&
|
||||
buf.toString("ascii", 0, 4) === "RIFF")
|
||||
)
|
||||
return "webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A display string from untrusted input: trimmed, single-line, capped. */
|
||||
function cleanText(value, max) {
|
||||
return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, max) : "";
|
||||
}
|
||||
|
||||
function checkSheet(buf) {
|
||||
if (buf.length > MAX_SHEET_BYTES) throw fail("ETOOBIG", "the spritesheet is larger than 6 MB");
|
||||
const size = webpSize(buf);
|
||||
if (!size) throw fail("EBADPET", "the spritesheet is not a WebP image");
|
||||
if (size.width !== SHEET.width || size.height !== SHEET.height)
|
||||
throw fail(
|
||||
"EBADPET",
|
||||
`the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height}`
|
||||
);
|
||||
}
|
||||
|
||||
function readMeta(id) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(petDir(id), "meta.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function view(id, meta) {
|
||||
return {
|
||||
id,
|
||||
displayName: meta.displayName,
|
||||
description: meta.description || "",
|
||||
kind: meta.kind,
|
||||
source: meta.source,
|
||||
...(meta.featuredId ? { featuredId: meta.featuredId } : {}),
|
||||
...(meta.by ? { by: meta.by } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function listPets() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(petsRoot());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.filter((n) => ID_RE.test(n))
|
||||
.map((n) => [n, readMeta(n)])
|
||||
.filter(([, meta]) => meta && (meta.kind === "codex" || meta.kind === "image"))
|
||||
.sort((a, b) => String(a[1].addedAt).localeCompare(String(b[1].addedAt)))
|
||||
.map(([n, meta]) => view(n, meta));
|
||||
}
|
||||
|
||||
/** Writes a pet atomically: a temp folder renamed into place. */
|
||||
function store(id, meta, file, body) {
|
||||
const existing = listPets();
|
||||
if (!existing.some((p) => p.id === id) && existing.length >= MAX_PETS)
|
||||
throw fail("ETOOMANY", `at most ${MAX_PETS} pets`);
|
||||
const root = petsRoot();
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const tmp = fs.mkdtempSync(path.join(root, ".new-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmp, file), body);
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "meta.json"),
|
||||
JSON.stringify({ ...meta, file, addedAt: new Date().toISOString() })
|
||||
);
|
||||
fs.rmSync(petDir(id), { recursive: true, force: true });
|
||||
fs.renameSync(tmp, petDir(id));
|
||||
} catch (err) {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
return view(id, readMeta(id));
|
||||
}
|
||||
|
||||
const newId = () => `p-${crypto.randomBytes(6).toString("hex")}`;
|
||||
|
||||
/** Imports a `.codex-pet.zip`: `pet.json` + `spritesheet.webp`, at the root
|
||||
* or in one top-level folder. Nothing else in the archive is read. */
|
||||
function importBundle(buf) {
|
||||
if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file");
|
||||
if (buf.length > MAX_BUNDLE_BYTES) throw fail("ETOOBIG", "the bundle is larger than 8 MB");
|
||||
let entries;
|
||||
try {
|
||||
entries = new AdmZip(buf).getEntries();
|
||||
} catch {
|
||||
throw fail("EBADPET", "not a zip archive");
|
||||
}
|
||||
const pick = (name) =>
|
||||
entries.find(
|
||||
(e) =>
|
||||
!e.isDirectory &&
|
||||
/^(?:[^/]+\/)?[^/]+$/.test(e.entryName) &&
|
||||
path.posix.basename(e.entryName) === name
|
||||
);
|
||||
const manifest = pick("pet.json");
|
||||
const sheet = pick("spritesheet.webp");
|
||||
if (!manifest || !sheet)
|
||||
throw fail("EBADPET", "a .codex-pet bundle holds pet.json and spritesheet.webp");
|
||||
// Sizes from the central directory, checked BEFORE inflating (zip bombs).
|
||||
if (manifest.header.size > MAX_MANIFEST_BYTES) throw fail("EBADPET", "pet.json is too large");
|
||||
if (sheet.header.size > MAX_SHEET_BYTES)
|
||||
throw fail("ETOOBIG", "the spritesheet is larger than 6 MB");
|
||||
let pet;
|
||||
try {
|
||||
pet = JSON.parse(manifest.getData().toString("utf8"));
|
||||
} catch {
|
||||
throw fail("EBADPET", "pet.json is not valid JSON");
|
||||
}
|
||||
const body = sheet.getData();
|
||||
checkSheet(body);
|
||||
const displayName =
|
||||
cleanText(pet && pet.displayName, 60) || cleanText(pet && pet.id, 60) || "Pet";
|
||||
return store(
|
||||
newId(),
|
||||
{
|
||||
kind: "codex",
|
||||
source: "upload",
|
||||
displayName,
|
||||
description: cleanText(pet && pet.description, 300),
|
||||
},
|
||||
"spritesheet.webp",
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds one uploaded image (PNG, JPEG, GIF or WebP) as a still pet. */
|
||||
function addImage(buf, name) {
|
||||
if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file");
|
||||
if (buf.length > MAX_IMAGE_BYTES) throw fail("ETOOBIG", "the image is larger than 4 MB");
|
||||
const ext = sniffImage(buf);
|
||||
if (!ext) throw fail("EBADPET", "only PNG, JPEG, GIF or WebP images");
|
||||
const displayName = cleanText(String(name || "").replace(/\.[^.]+$/, ""), 60) || "Pet";
|
||||
return store(
|
||||
newId(),
|
||||
{ kind: "image", source: "upload", displayName, type: ext },
|
||||
`image.${ext}`,
|
||||
buf
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchCapped(url, max) {
|
||||
const res = await fetchImpl(url, { signal: AbortSignal.timeout(20_000), redirect: "error" });
|
||||
if (!res.ok) throw fail("EFETCH", `codex-pets.net answered ${res.status}`);
|
||||
const len = Number(res.headers?.get?.("content-length"));
|
||||
if (len > max) throw fail("ETOOBIG", "the download is too large");
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (body.length > max) throw fail("ETOOBIG", "the download is too large");
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Downloads one FEATURED pet from codex-pets.net into this install. */
|
||||
async function installFeatured(featuredId) {
|
||||
const f = FEATURED.find((x) => x.id === featuredId);
|
||||
if (!f) throw fail("ENOPET", "not a featured pet");
|
||||
const base = `${FEATURED_HOST}/api/pets/${encodeURIComponent(f.id)}`;
|
||||
let description = "";
|
||||
try {
|
||||
const meta = JSON.parse((await fetchCapped(base, MAX_MANIFEST_BYTES * 4)).toString("utf8"));
|
||||
description = cleanText(meta && meta.pet && meta.pet.description, 300);
|
||||
} catch (err) {
|
||||
if (err.code === "ETOOBIG") throw err;
|
||||
/* the description is optional; the spritesheet is not */
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await fetchCapped(`${base}/spritesheet`, MAX_SHEET_BYTES);
|
||||
} catch (err) {
|
||||
if (err.code) throw err;
|
||||
throw fail("EFETCH", `could not reach codex-pets.net: ${err.message}`);
|
||||
}
|
||||
checkSheet(body);
|
||||
return store(
|
||||
`cp-${f.id}`,
|
||||
{
|
||||
kind: "codex",
|
||||
source: "featured",
|
||||
featuredId: f.id,
|
||||
by: f.by,
|
||||
displayName: f.displayName,
|
||||
description,
|
||||
},
|
||||
"spritesheet.webp",
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
function removePet(id) {
|
||||
const dir = petDir(id);
|
||||
if (!readMeta(id)) throw fail("ENOPET", "pet not found");
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** The file to serve for a pet and its content type. */
|
||||
function spriteFile(id) {
|
||||
const meta = readMeta(id);
|
||||
if (!meta || (meta.file !== "spritesheet.webp" && !/^image\.(png|jpg|gif|webp)$/.test(meta.file)))
|
||||
throw fail("ENOPET", "pet not found");
|
||||
const ext = meta.file.split(".").pop();
|
||||
return { path: path.join(petDir(id), meta.file), type: IMAGE_TYPES[ext] };
|
||||
}
|
||||
|
||||
function featured() {
|
||||
const installed = new Set(
|
||||
listPets()
|
||||
.map((p) => p.featuredId)
|
||||
.filter(Boolean)
|
||||
);
|
||||
return FEATURED.map((f) => ({
|
||||
...f,
|
||||
installed: installed.has(f.id),
|
||||
page: `${FEATURED_HOST}/#/pets/${f.id}`,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SHEET,
|
||||
FEATURED,
|
||||
MAX_BUNDLE_BYTES,
|
||||
MAX_IMAGE_BYTES,
|
||||
listPets,
|
||||
featured,
|
||||
importBundle,
|
||||
addImage,
|
||||
installFeatured,
|
||||
removePet,
|
||||
spriteFile,
|
||||
webpSize,
|
||||
__setFetch,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @file pets.js (routes)
|
||||
* @description `/api/pets` — the mascot's custom pets (`server/lib/pets.js`):
|
||||
* the list plus the featured ones, a pet's sprite, importing a `.codex-pet.zip`,
|
||||
* uploading an image, installing a featured pet from codex-pets.net by its
|
||||
* fixed id, and deleting one. Every write is same-origin; no route takes a
|
||||
* URL or a path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const express = require("express");
|
||||
const pets = require("../lib/pets");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const STATUS = {
|
||||
ENOPET: 404,
|
||||
ETOOBIG: 413,
|
||||
ETOOMANY: 409,
|
||||
EFETCH: 502,
|
||||
EBADORIGIN: 403,
|
||||
};
|
||||
|
||||
function sendError(res, err) {
|
||||
if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) {
|
||||
return res
|
||||
.status(STATUS[err.code] || 400)
|
||||
.json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
}
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
try {
|
||||
res.json({ pets: pets.listPets(), featured: pets.featured(), sheet: pets.SHEET });
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id/sprite", (req, res) => {
|
||||
try {
|
||||
const file = pets.spriteFile(req.params.id);
|
||||
res.set("Content-Type", file.type);
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
res.set("Cache-Control", "private, max-age=3600");
|
||||
res.sendFile(file.path);
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const raw = (limit) => express.raw({ type: () => true, limit });
|
||||
|
||||
router.post("/import", sameOriginGuard, raw(pets.MAX_BUNDLE_BYTES), (req, res) => {
|
||||
try {
|
||||
res.status(201).json({ pet: pets.importBundle(req.body) });
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/upload", sameOriginGuard, raw(pets.MAX_IMAGE_BYTES), (req, res) => {
|
||||
try {
|
||||
res.status(201).json({ pet: pets.addImage(req.body, req.query.name) });
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/featured/:id", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
res.status(201).json({ pet: await pets.installFeatured(req.params.id) });
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
pets.removePet(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// An upload past its limit reaches here as express.raw's 413.
|
||||
router.use((err, _req, res, next) => {
|
||||
if (err && err.type === "entity.too.large")
|
||||
return res.status(413).json({ error: { code: "ETOOBIG", message: "the file is too large" } });
|
||||
return next(err);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user