Merge pull request #47959 from NousResearch/bb/pets-gen

Pet generation: frame-perfect hatch flow, backend picker, CPU-safe chroma, and CI-hardening
This commit is contained in:
brooklyn! 2026-06-24 19:41:34 -05:00 committed by GitHub
commit 7157b213f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 7138 additions and 96 deletions

View file

@ -0,0 +1,29 @@
"""Pet generation — base-draft → hatch pipeline.
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
command, and tests:
- :func:`generate_base_drafts` / :func:`hatch_pet` the two-step flow.
- :class:`HatchResult`, :class:`GenerationError`.
- :mod:`atlas` deterministic frame extraction + atlas composition/validation.
Image generation is delegated to the active reference-capable
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
atlas assembly is fully deterministic so it's testable without any API calls.
"""
from __future__ import annotations
from agent.pet.generate.imagegen import GenerationError
from agent.pet.generate.orchestrate import (
HatchResult,
generate_base_drafts,
hatch_pet,
)
__all__ = [
"GenerationError",
"HatchResult",
"generate_base_drafts",
"hatch_pet",
]

781
agent/pet/generate/atlas.py Normal file
View file

@ -0,0 +1,781 @@
"""Deterministic spritesheet assembly — generated row strips → Hermes atlas.
Image-generation models are good at *drawing* a row of poses but bad at exact
grid geometry, so the model never owns the atlas layout: it produces one loose
horizontal strip per state, and these deterministic ops slice that strip into
clean, centered, transparent ``192x208`` cells and pack them into the sheet our
renderer reads.
The atlas follows the **petdex/Codex standard**: 8 columns x 9 rows of
``192x208`` cells (``1536x1872``), with the row order + per-row frame counts
from OpenAI's ``hatch-pet`` skill. Our renderer (:mod:`agent.pet.render`) keys
frames as ``rows = states, cols = frames`` via
:data:`agent.pet.constants.CODEX_STATE_ROWS`, and a pet built here is a valid
``petdex submit`` spritesheet. Rows shorter than 8 columns leave the trailing
cells fully transparent.
Note ``running`` is the *working* state (in-place processing), NOT locomotion
``running-right`` / ``running-left`` are the actual directional walk cycles.
The frame-segmentation, fit-to-cell, and transparency-residue logic is adapted
from OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0).
"""
from __future__ import annotations
import io
import logging
import math
from pathlib import Path
from agent.pet.constants import FRAME_H, FRAME_W
logger = logging.getLogger(__name__)
CELL_WIDTH = FRAME_W
CELL_HEIGHT = FRAME_H
# (state, row index, frame count). Order/row indices MUST match
# ``constants.CODEX_STATE_ROWS`` so the renderer crops the right row for each
# driven state, and the per-row frame counts mirror the petdex/Codex
# ``hatch-pet`` ``animation-rows`` spec. The renderer trims trailing blank
# columns, so rows shorter than ``COLUMNS`` (8) just leave the tail transparent.
ROW_SPECS: list[tuple[str, int, int]] = [
("idle", 0, 6),
("running-right", 1, 8),
("running-left", 2, 8),
("waving", 3, 4),
("jumping", 4, 5),
("failed", 5, 8),
("waiting", 6, 6),
("running", 7, 6),
("review", 8, 6),
]
ROWS = len(ROW_SPECS)
COLUMNS = max(count for _, _, count in ROW_SPECS)
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
FRAME_COUNTS: dict[str, int] = {state: count for state, _, count in ROW_SPECS}
# Alpha at/below which a pixel is "background" for component detection.
_ALPHA_FLOOR = 16
# Cell padding kept around a fitted sprite so poses never touch the edge.
_CELL_PAD = 10
# Margin for the normalized pass — small, to fill the cell like real petdex pets
# (they sit ~5px from the edges); the width clamp, not the pad, prevents clipping.
_NORMALIZE_PAD = 14
# Side-lobe cutoff for fitted frames. Adjacent-pose bleed usually appears as a
# small separated horizontal lobe beside the real subject; keep sizeable lobes so
# we don't punish a legitimate wide pose.
_SIDE_LOBE_RATIO = 0.18
# ───────────────────────── background removal ─────────────────────────
def _color_distance(r: int, g: int, b: int, key: tuple[int, int, int]) -> float:
return math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2)
def _has_transparency(image) -> bool:
"""True if the strip already carries a real alpha background."""
extrema = image.getchannel("A").getextrema()
# Min alpha 0 somewhere and a meaningful share of fully-transparent pixels.
if extrema[0] > _ALPHA_FLOOR:
return False
hist = image.getchannel("A").histogram()
transparent = sum(hist[: _ALPHA_FLOOR + 1])
total = image.width * image.height
return transparent > total * 0.05
def _dominant_corner_color(image) -> tuple[int, int, int]:
"""Sample the four corners and return the most common opaque color."""
from collections import Counter
w, h = image.width, image.height
px = image.load()
counter: Counter = Counter()
for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
r, g, b, a = px[x, y]
if a > _ALPHA_FLOOR:
counter[(r, g, b)] += 1
if not counter:
return (0, 255, 0)
return counter.most_common(1)[0][0]
def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48):
"""An ``L`` mask, 255 where a pixel is within *tol* per-channel of *key*.
Tight on purpose: it only marks near-pure backdrop so trapped chroma pockets
seed the flood, while chroma-*tinted* character pixels stay outside it. Built
with channel point-ops (fast C), no per-pixel Python.
"""
from PIL import ImageChops
r, g, b, _a = image.split()
kr, kg, kb = key
return ImageChops.darker(
ImageChops.darker(
r.point(lambda v: 255 if abs(v - kr) <= tol else 0),
g.point(lambda v: 255 if abs(v - kg) <= tol else 0),
),
b.point(lambda v: 255 if abs(v - kb) <= tol else 0),
)
def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 90.0):
"""Return *image* (RGBA) with its flat background keyed out to transparent.
If the strip already has a transparent background we leave it alone; else we
key out *chroma_key* (or the dominant corner color when not given) via a
**border flood-fill**: only background-coloured pixels *connected to an edge*
are removed. A global color match (the old approach) punched holes in the pet
wherever an interior highlight happened to match the backdrop e.g. a pug's
light belly against a near-white background which then showed through as the
window behind. Flood-fill keeps those interior pixels because they aren't
reachable from the border without crossing the (non-background) pet.
"""
from collections import deque
from PIL import Image, ImageChops
rgba = image.convert("RGBA")
if _has_transparency(rgba):
return _repair_internal_alpha_holes(rgba)
key = chroma_key or _dominant_corner_color(rgba)
w, h = rgba.width, rgba.height
px = rgba.load()
def _is_bg(x: int, y: int) -> bool:
r, g, b, a = px[x, y]
return a > _ALPHA_FLOOR and _color_distance(r, g, b, key) <= threshold
# Fast path for strongly-saturated chroma keys (our normal sprite prompts use
# hot magenta): remove all near-key opaque pixels with C-level channel ops.
# This clears both border-connected backdrop and enclosed triangular pockets
# between connected limbs/capes, without a Python flood over ~1.5M pixels.
if max(key) - min(key) >= 120:
near = _near_key_mask(rgba, key) # L mask, 255 where near key
opaque = rgba.getchannel("A").point(lambda a: 255 if a > _ALPHA_FLOOR else 0)
remove_mask = ImageChops.darker(near, opaque)
return Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, remove_mask)
visited = bytearray(w * h)
# Mark removals in a flat mask and apply them in one C composite at the end —
# writing `px[x, y] = (0,0,0,0)` per pixel was ~3M PixelAccess calls (84% of
# the whole pipeline) and pegged a core in pure Python, stalling the gateway.
remove = bytearray(w * h)
queue: deque[tuple[int, int]] = deque()
# Seed from every border pixel that looks like background.
for x in range(w):
for y in (0, h - 1):
if _is_bg(x, y) and not visited[y * w + x]:
visited[y * w + x] = 1
queue.append((x, y))
for y in range(h):
for x in (0, w - 1):
if _is_bg(x, y) and not visited[y * w + x]:
visited[y * w + x] = 1
queue.append((x, y))
# Trapped pockets: background enclosed by the character (the magenta between
# an arm and the body) isn't border-reachable, so also seed the flood from
# interior near-key pixels. Gated to a *saturated* key (our magenta backdrop)
# so we never seed from a character sharing a desaturated near-white/gray key
# — that's the hole-punching the border-only flood exists to avoid.
if max(key) - min(key) >= 120:
for i, near in enumerate(_near_key_mask(rgba, key).getdata()):
if near and not visited[i]:
visited[i] = 1
queue.append((i % w, i // w))
while queue:
x, y = queue.popleft()
remove[y * w + x] = 1
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx]:
visited[idx] = 1
if _is_bg(nx, ny):
queue.append((nx, ny))
# One C-level composite instead of millions of per-pixel writes: paint the
# flooded pixels to (0,0,0,0) wherever the mask is set.
mask = Image.frombytes("L", (w, h), bytes(remove)).point(lambda v: 255 if v else 0)
return Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, mask)
def _repair_internal_alpha_holes(image):
"""Fill transparent islands fully enclosed by opaque sprite pixels.
Some providers return "transparent" PNGs with swiss-cheese alpha inside the
character. Border flood-fill cannot see those because there is no opaque
backdrop to key, so repair the alpha mask itself: transparent components that
touch an image edge remain background; transparent components enclosed by
the sprite are filled with the average color of their opaque neighbours.
"""
from collections import deque
rgba = image.convert("RGBA")
w, h = rgba.size
px = rgba.load()
visited = bytearray(w * h)
def _is_transparent(x: int, y: int) -> bool:
return px[x, y][3] <= _ALPHA_FLOOR
def _mark_border_component(sx: int, sy: int) -> None:
queue: deque[tuple[int, int]] = deque([(sx, sy)])
visited[sy * w + sx] = 1
while queue:
x, y = queue.popleft()
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx] and _is_transparent(nx, ny):
visited[idx] = 1
queue.append((nx, ny))
# First mark true background: all transparent pixels reachable from the edge.
for x in range(w):
for y in (0, h - 1):
if _is_transparent(x, y) and not visited[y * w + x]:
_mark_border_component(x, y)
for y in range(h):
for x in (0, w - 1):
if _is_transparent(x, y) and not visited[y * w + x]:
_mark_border_component(x, y)
def _collect_hole(sx: int, sy: int) -> list[tuple[int, int]]:
queue: deque[tuple[int, int]] = deque([(sx, sy)])
visited[sy * w + sx] = 1
pixels: list[tuple[int, int]] = []
while queue:
x, y = queue.popleft()
pixels.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx] and _is_transparent(nx, ny):
visited[idx] = 1
queue.append((nx, ny))
return pixels
def _fill_color(hole: list[tuple[int, int]]) -> tuple[int, int, int, int]:
samples: list[tuple[int, int, int]] = []
seen = set(hole)
for x, y in hole:
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen:
r, g, b, a = px[nx, ny]
if a > _ALPHA_FLOOR:
samples.append((r, g, b))
if not samples:
return (0, 0, 0, 255)
return (
round(sum(c[0] for c in samples) / len(samples)),
round(sum(c[1] for c in samples) / len(samples)),
round(sum(c[2] for c in samples) / len(samples)),
255,
)
for start, _ in enumerate(visited):
if visited[start]:
continue
x = start % w
y = start // w
if not _is_transparent(x, y):
continue
hole = _collect_hole(x, y)
color = _fill_color(hole)
for hx, hy in hole:
px[hx, hy] = color
return rgba
# ───────────────────────── frame extraction ─────────────────────────
def _fit_to_cell(image):
"""Crop to content, scale to fit a padded cell, and center on transparent."""
from PIL import Image
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
image = _drop_side_bleed(image)
bbox = image.getbbox()
if bbox is None:
return target
sprite = image.crop(bbox)
max_w = CELL_WIDTH - _CELL_PAD
max_h = CELL_HEIGHT - _CELL_PAD
scale = min(max_w / sprite.width, max_h / sprite.height, 1.0)
if scale != 1.0:
# NEAREST, not LANCZOS: the generated "pixel art" has hard edges, and any
# interpolating resample anti-aliases them into a blurry, washed-out
# sprite once the renderer upscales the cell. Crisp blocky downscale reads
# as real pixel art.
sprite = sprite.resize(
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
Image.Resampling.NEAREST,
)
left = (CELL_WIDTH - sprite.width) // 2
top = (CELL_HEIGHT - sprite.height) // 2
target.alpha_composite(sprite, (left, top))
return target
def _drop_side_bleed(image):
"""Remove tiny separated left/right lobes before fitting a frame.
Frogger showed the failure mode: a good centered pose plus a thin vertical
sliver from the neighbouring pose. By the time it reaches a cell, that sliver
may be close enough to the subject that component extraction already grouped
it. A horizontal alpha projection still reveals it as a small side lobe with
a low mass compared to the main silhouette. Drop only those low-mass lobes;
keep large lobes so wide poses and real limbs survive.
"""
from PIL import Image
rgba = image.convert("RGBA")
w, h = rgba.size
profile = _column_profile(rgba) # mean alpha per column (fast C resize)
runs = _content_runs(profile)
if len(runs) < 2:
return rgba
masses = [sum(profile[l:r]) for l, r in runs]
keep_mass = max(masses) * _SIDE_LOBE_RATIO
keep = [run for run, m in zip(runs, masses) if m >= keep_mass]
if len(keep) == len(runs):
return rgba
# Zero every column band that isn't a kept segment (box paste, not per-pixel).
rgba = rgba.copy()
cut, prev = Image.new("RGBA", (w, h), (0, 0, 0, 0)), 0
for left, right in keep:
if left > prev:
rgba.paste(cut.crop((prev, 0, left, h)), (prev, 0))
prev = right
if prev < w:
rgba.paste(cut.crop((prev, 0, w, h)), (prev, 0))
return rgba
def _sever_expected_gutters(strip, frame_count: int):
"""Cut thin vertical gutters at expected frame boundaries before labeling.
Generated rows often have a shared shadow, glow, motion smear, or 1px bridge
that connects neighbouring poses. Component detection then sees one giant
blob and either fails or falls back to slot slicing. We know the requested
frame count, so cut a very narrow transparent band at each expected boundary
before connected-component labeling. If a pose truly overlaps the boundary,
losing a few pixels is better than exporting merged frames.
"""
if frame_count <= 1:
return strip
out = strip.copy()
px = out.load()
slot = out.width / frame_count
half = max(3, min(18, round(slot * 0.06)))
for i in range(1, frame_count):
x = round(i * slot)
left = max(0, x - half)
right = min(out.width, x + half + 1)
for gx in range(left, right):
for gy in range(out.height):
r, g, b, _a = px[gx, gy]
px[gx, gy] = (r, g, b, 0)
return out
def _slot_crops(strip, frame_count: int) -> list:
"""Slice *strip* into *frame_count* uniform columns (one coordinate space).
Equal-width columns keep every frame in a single shared coordinate frame, so
a later union-crop + shared placement (:func:`normalize_cells`) preserves the
row's real motion without the per-frame re-centering that makes a pet visibly
slide. Neighbour side-bleed is trimmed per column.
"""
w0 = max(1, strip.width // frame_count)
h = strip.height
return [_drop_side_bleed(strip.crop((i * w0, 0, i * w0 + w0, h))) for i in range(frame_count)]
def _content_runs(profile: list[int], *, threshold: int = 2) -> list[tuple[int, int]]:
"""Contiguous column spans whose alpha mass exceeds *threshold*.
A column-projection of the alpha mask: empty (background) columns separate
one pose from the next, so the runs ARE the candidate frames.
"""
runs: list[tuple[int, int]] = []
start: int | None = None
for x, v in enumerate(list(profile) + [0]):
if v > threshold:
if start is None:
start = x
elif start is not None:
runs.append((start, x))
start = None
return runs
def _frame_x_ranges(strip, frame_count: int) -> list[tuple[int, int]] | None:
"""Per-frame ``(left, right)`` column ranges from the row's empty gutters.
The standard sprite-sheet slice once poses are separated by real gaps
(which generation now enforces), splitting is just "find the empty columns":
* spans == frames one span per frame.
* spans > frames merge across the smallest gaps. A detached halo/ear sits
a tiny gap from its body, while the inter-pose gutter is the big gap that
survives so over-segmentation (and any over-eager gutter sever) repairs
itself by collapsing only the small internal gaps.
* spans < frames poses are touching; not separable by gutters (the caller
raises for ``components`` or falls back to even slots for ``auto``).
Ranges span content only; the caller crops full cell height, so tall ears /
halos are never cut.
"""
profile = _column_profile(strip)
runs = _content_runs(profile)
if not runs:
return None
# Drop trivial specks so stray noise never counts as a pose.
masses = [sum(profile[l:r]) for l, r in runs]
floor = max(masses) * 0.02
runs = [run for run, m in zip(runs, masses) if m >= floor]
if len(runs) < frame_count:
return None
groups = [[l, r] for l, r in runs]
while len(groups) > frame_count:
gi = min(range(len(groups) - 1), key=lambda i: groups[i + 1][0] - groups[i][1])
groups[gi][1] = groups[gi + 1][1]
del groups[gi + 1]
return [(l, r) for l, r in groups]
def extract_strip_frames(
strip,
frame_count: int,
*,
chroma_key: tuple[int, int, int] | None = None,
method: str = "auto",
fit: bool = True,
) -> list:
"""Turn one generated row strip into *frame_count* frames.
The background is keyed out, thin connecting bridges at the expected
boundaries are severed, then the strip is sliced at its empty chroma gutters
(:func:`_frame_x_ranges`) the plain "find each object, make a frame" cut
that works once poses are spaced apart (which generation now enforces).
Each frame is cropped at full cell height so tall ears / halos are never
clipped; :func:`_drop_side_bleed` trims any faint neighbour sliver. When the
poses are touching (fewer gutters than frames) ``components`` raises and
``auto`` falls back to equal-width slots.
*fit* (default) fits+centers each frame into a 192x208 cell the standalone
contract for callers that don't normalize. Hatching passes ``fit=False`` to
keep raw, coordinate-aligned columns for :func:`normalize_cells`, which lays
one shared scale + baseline across the whole pet (no slide, no size pulse).
"""
from PIL import Image
if isinstance(strip, (str, Path)):
with Image.open(strip) as opened:
strip = opened.convert("RGBA")
else:
strip = strip.convert("RGBA")
strip = remove_background(strip, chroma_key=chroma_key)
# Prefer the real gutters as-is: when poses are already spaced (generation
# enforces this), slicing the strip untouched keeps each pose's own bounds and
# never cuts through an unevenly-placed silhouette. Only fall back to severing
# the expected boundaries when gaps alone can't separate the row — i.e. poses
# are bridged by a shared shadow/glow/1px line and read as one blob.
source = strip
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
source = _sever_expected_gutters(strip, frame_count)
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
if method == "components":
raise ValueError(f"could not segment {frame_count} sprites from strip")
frames = _slot_crops(source, frame_count)
else:
h = source.height
pad = max(2, min(16, round((source.width / max(1, frame_count)) * 0.04)))
frames = [
_drop_side_bleed(source.crop((max(0, left - pad), 0, min(source.width, right + pad), h)))
for left, right in ranges
]
return [_fit_to_cell(f) for f in frames] if fit else frames
def _column_profile(image) -> list[int]:
"""Per-column alpha mass — collapse the frame to a 1px-tall strip (fast in C)."""
from PIL import Image
return list(image.getchannel("A").resize((image.width, 1), Image.BILINEAR).getdata())
def _best_shift(ref: list[int], prof: list[int], window: int) -> int:
"""Integer dx that best aligns *prof* onto *ref* by cross-correlation.
This is 1-D phase correlation: the body is the dominant mass in the column
profile, so the peak overlap locks onto the body and a flipping arm/cape (a
small secondary bump) doesn't move the match. Proven on the jitter case to
cut body drift from ~9px to ~1px where a centroid/bbox anchor cannot.
"""
n = len(ref)
best_score: float | None = None
best = 0
for d in range(-window, window + 1):
score = 0
for x in range(max(0, d), min(n, n + d)):
score += ref[x] * prof[x - d]
if best_score is None or score > best_score:
best_score = score
best = d
return best
def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_PAD) -> dict[str, list]:
"""Register every frame into a 192x208 cell — the deterministic anti-jitter math.
A per-frame "crop→scale→center" pipeline jitters because a moving limb/cape
shifts the bbox (or even the centroid) and a per-frame scale pulses the size.
The rigorous fix, matching image-registration practice (phase correlation)
and AI-sprite pipelines (perfectpixel-studio / sprite-gen):
1. **Cross-correlate** each frame's column profile against the per-state
*median* profile to find the integer shift that locks the **body** in
place robust to limbs/cape because the body dominates the profile.
2. **Union-crop** through one shared state window, then scale every state by a
single global factor keyed to its median pose height, so the character is
the same on-screen size in every row while a jump's lift still fits.
"""
from PIL import Image
blank = lambda: Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
med = lambda vs: sorted(vs)[len(vs) // 2] # robust center; ignores a limb/cape outlier
out: dict[str, list] = {}
prepared: dict[str, tuple[list, tuple[int, int, int, int], tuple[int, int]]] = {}
# Fill the cell — real petdex pets sit ~pad from the edges; the K cap below
# keeps a tall pose (a jump's lift) from clipping.
target_w = CELL_WIDTH - pad
target_h = CELL_HEIGHT - pad
for state, frames in frames_by_state.items():
rgba = [f.convert("RGBA") for f in frames]
if not any(f.getbbox() for f in rgba):
out[state] = [blank() for _ in frames]
continue
# Pad every frame to a common canvas so column profiles are comparable.
w0 = max(f.width for f in rgba)
h0 = max(f.height for f in rgba)
canvas = []
for f in rgba:
if f.size != (w0, h0):
c = Image.new("RGBA", (w0, h0), (0, 0, 0, 0))
c.alpha_composite(f, (0, 0))
f = c
canvas.append(f)
# Register horizontally: shift each frame to lock the body (xcorr).
profiles = [_column_profile(f) for f in canvas]
ref = [sorted(p[x] for p in profiles)[len(profiles) // 2] for x in range(w0)]
window = max(8, w0 // 5)
margin = window
aligned = []
for f, prof in zip(canvas, profiles):
shifted = Image.new("RGBA", (w0 + 2 * margin, h0), (0, 0, 0, 0))
shifted.alpha_composite(f, (margin + _best_shift(ref, prof, window), 0))
aligned.append(shifted)
# Shared window over the registered set; scale is resolved against a
# common apparent-character target below.
boxes = [b for b in (a.getbbox() for a in aligned) if b]
left = min(b[0] for b in boxes)
top = min(b[1] for b in boxes)
right = max(b[2] for b in boxes)
bottom = max(b[3] for b in boxes)
prepared[state] = (
aligned,
(left, top, right, bottom),
(med([b[2] - b[0] for b in boxes]), med([b[3] - b[1] for b in boxes])),
)
if not prepared:
return out
# Uniform apparent size: scale each state by K / pose_h, so a row the model
# drew small renders as big as one it drew large. K is the one global cap that
# keeps the tallest/widest motion envelope (a jump's lift) inside the cell —
# for a still row union ≈ pose so its term ≈ target_h (full fill).
K = target_h
for (_aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.values():
uw, uh = right - left, bottom - top
K = min(K, target_h * pose_h / max(1, uh), target_w * pose_h / max(1, uw))
for state, (aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.items():
uw, uh = right - left, bottom - top
scale = K / max(1, pose_h)
sw, sh = max(1, round(uw * scale)), max(1, round(uh * scale))
px, py = round((CELL_WIDTH - sw) / 2), round((CELL_HEIGHT - pad // 2) - sh)
cells = []
for a in aligned:
crop = a.crop((left, top, right, bottom))
if crop.size != (sw, sh):
# NEAREST keeps the pixel-art edges crisp; LANCZOS blurred them.
crop = crop.resize((sw, sh), Image.Resampling.NEAREST)
cell = blank()
cell.alpha_composite(crop, (px, py))
cells.append(cell)
out[state] = cells
return out
# ───────────────────────── atlas composition ─────────────────────────
def single_frame(image, *, fit: bool = True):
"""One frame from a standalone image (e.g. the base look).
Used as an idle fallback so a pet always renders even if the idle row
generation failed. *fit* yields a finished 192x208 cell; ``fit=False`` yields
the raw keyed sprite for :func:`normalize_cells` to place with the rest.
"""
from PIL import Image
if isinstance(image, (str, Path)):
with Image.open(image) as opened:
image = opened.convert("RGBA")
keyed = remove_background(image)
return _fit_to_cell(keyed) if fit else _drop_side_bleed(keyed)
def _clear_transparent_rgb(image):
"""Zero the RGB of fully-transparent pixels (no colored-halo residue)."""
from PIL import Image
rgba = image.convert("RGBA")
data = bytearray(rgba.tobytes())
for i in range(0, len(data), 4):
if data[i + 3] == 0:
data[i] = data[i + 1] = data[i + 2] = 0
return Image.frombytes("RGBA", rgba.size, bytes(data))
def mirror_frames(frames: list) -> list:
"""Horizontally flip each frame *in place* (RGBA-safe).
Used to derive ``running-left`` from an approved ``running-right`` row. The
flip is per-frame so the leftward loop preserves the rightward loop's frame
order and timing this is NOT a whole-strip reverse (which would play the
animation backwards), matching the petdex/Codex mirror rule.
"""
from PIL import Image
flip = getattr(Image, "Transpose", Image).FLIP_LEFT_RIGHT
return [frame.convert("RGBA").transpose(flip) for frame in frames]
def compose_atlas(frames_by_state: dict[str, list]):
"""Pack per-state frame lists into the Hermes atlas (RGBA, residue-cleared).
Missing/short states leave their trailing cells transparent; extra frames
beyond a state's spec are dropped.
"""
from PIL import Image
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
for state, row, count in ROW_SPECS:
frames = frames_by_state.get(state) or []
for col, frame in enumerate(frames[:count]):
cell = frame.convert("RGBA")
if cell.size != (CELL_WIDTH, CELL_HEIGHT):
cell = _fit_to_cell(cell)
atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT))
return _clear_transparent_rgb(atlas)
def atlas_to_webp_bytes(atlas) -> bytes:
"""Encode an atlas image to lossless WebP bytes (the on-disk pet format)."""
buf = io.BytesIO()
atlas.save(buf, format="WEBP", lossless=True, quality=100, method=6, exact=True)
return buf.getvalue()
def validate_atlas(atlas) -> dict:
"""Check geometry, per-cell occupancy, and transparency invariants.
Returns ``{ok, width, height, errors, warnings, filled_states}``. Errors are
blockers (wrong size, empty used cell, opaque/dirty transparency); warnings
are soft (a whole state row blank generation likely dropped a row).
"""
from PIL import Image
if isinstance(atlas, (str, Path)):
with Image.open(atlas) as opened:
atlas = opened.convert("RGBA")
else:
atlas = atlas.convert("RGBA")
errors: list[str] = []
warnings: list[str] = []
if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}")
return {"ok": False, "width": atlas.width, "height": atlas.height, "errors": errors, "warnings": warnings, "filled_states": []}
filled_states: list[str] = []
for state, row, count in ROW_SPECS:
row_pixels = 0
for col in range(count):
left = col * CELL_WIDTH
top = row * CELL_HEIGHT
cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
nonblank = sum(cell.getchannel("A").histogram()[1:])
row_pixels += nonblank
if row_pixels > 0:
filled_states.append(state)
else:
warnings.append(f"state '{state}' has no frames")
if not filled_states:
errors.append("atlas is empty — no state produced any frames")
# Transparent pixels must carry zero RGB (no halo residue).
data = atlas.tobytes()
residue = 0
for i in range(0, len(data), 4):
if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]):
residue += 1
if residue:
errors.append(f"{residue} transparent pixels retain RGB residue")
return {
"ok": not errors,
"width": atlas.width,
"height": atlas.height,
"errors": errors,
"warnings": warnings,
"filled_states": filled_states,
}

View file

@ -0,0 +1,233 @@
"""Thin image-generation layer for pet sprites.
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
two things sprite generation needs that the agent-facing ``image_generate`` tool
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
animation row stays the same character as the chosen base).
Reference grounding only works on providers that support it currently OpenAI
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
those and surface a clear, actionable error otherwise rather than silently
producing an ungrounded, drifting pet.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# Providers that can ground generation on a reference image.
# openrouter / nous reach Gemini Flash Image (and friends) over the
# OpenRouter-compatible chat-completions image protocol, which accepts
# reference images for grounding. Nous Portal proxies OpenRouter, so both
# qualify.
_REF_CAPABLE = ("openai", "openai-codex", "krea", "openrouter", "nous")
# Friendly label + one-line speed/quality note per reference-capable provider,
# surfaced in the desktop pet-gen picker so users can trade speed for fidelity.
_PROVIDER_META: dict[str, dict[str, str]] = {
"nous": {"label": "Nous Portal", "note": "Fast, balanced quality"},
"openrouter": {"label": "OpenRouter", "note": "Fastest — Gemini Flash Image"},
"openai": {"label": "OpenAI", "note": "Highest fidelity, slower"},
"openai-codex": {"label": "OpenAI (Codex)", "note": "Highest fidelity, slower"},
"krea": {"label": "Krea", "note": "Stylized, style-reference grounding"},
}
class GenerationError(RuntimeError):
"""Raised on any image-generation failure (no provider, API error, IO)."""
@dataclass(frozen=True)
class SpriteProvider:
"""Resolved provider plus whether it can take reference images."""
name: str
provider: object
supports_references: bool
def _discover() -> None:
try:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
except Exception as exc: # noqa: BLE001 - discovery is best-effort
logger.debug("image-gen plugin discovery failed: %s", exc)
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
"""Pick the image provider to use for sprite work.
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
reference-capable and configured, then the configured/active provider when
it's reference-capable, else the first available reference-capable provider.
With *require_references* off we fall back to any available provider (used for
prompt-only base drafts).
"""
_discover()
from agent.image_gen_registry import get_active_provider, get_provider
# An explicit user pick wins when it's reference-capable and has credentials;
# otherwise we ignore it and fall through to the normal resolution.
if prefer:
chosen = get_provider(prefer)
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
# Configured / active provider first.
active = None
try:
active = get_active_provider()
except Exception: # noqa: BLE001
active = None
if active is not None:
name = getattr(active, "name", "")
if name in _REF_CAPABLE and active.is_available():
return SpriteProvider(name=name, provider=active, supports_references=True)
# Any available reference-capable provider.
for name in _REF_CAPABLE:
provider = get_provider(name)
if provider is not None and provider.is_available():
return SpriteProvider(name=name, provider=provider, supports_references=True)
if not require_references and active is not None and active.is_available():
return SpriteProvider(
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
)
raise GenerationError(
"Pet generation needs an image backend that supports reference images. "
"Open `hermes tools` → Image Generation and configure Nous Portal, "
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
)
def list_sprite_providers() -> list[dict]:
"""The reference-capable providers available to pick for pet generation.
Returns ``[{name, label, note, default}]`` for every ref-capable provider the
user actually has credentials for, marking the one :func:`resolve_provider`
would choose with no explicit preference. Empty when none is configured (the
picker hides itself). Best-effort: discovery hiccups yield an empty list.
"""
_discover()
from agent.image_gen_registry import get_provider
try:
default_name = resolve_provider(require_references=True).name
except GenerationError:
default_name = ""
out: list[dict] = []
for name in _REF_CAPABLE:
provider = get_provider(name)
if provider is None or not provider.is_available():
continue
meta = _PROVIDER_META.get(name, {})
out.append(
{
"name": name,
"label": meta.get("label", name),
"note": meta.get("note", ""),
"default": name == default_name,
}
)
return out
def _save_local(image_ref: str, *, prefix: str) -> Path:
"""Return a local path for *image_ref*, downloading it if it's a URL."""
if image_ref.startswith(("http://", "https://")):
from agent.image_gen_provider import save_url_image
return Path(save_url_image(image_ref, prefix=prefix))
return Path(image_ref)
def _rejected_background(error: str) -> bool:
"""True when a provider error is specifically about the ``background`` param.
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
reject ``background=transparent`` outright). We detect that one rejection so
we can retry without the flag rather than failing the whole pet our chroma
key pass makes the result transparent regardless.
"""
lowered = (error or "").lower()
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
def generate(
prompt: str,
*,
n: int = 1,
reference_images: list[Path] | None = None,
provider: SpriteProvider | None = None,
prefix: str = "pet_gen",
aspect_ratio: str = "square",
) -> list[Path]:
"""Generate *n* sprite images and return their local paths.
*reference_images* grounds the output on a base image (required for rows).
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
gives every frame real horizontal room so winged poses don't have to be
shrunk to avoid touching their neighbors).
We *ask* for a transparent background, but fall back to an opaque generation
(cleaned up downstream by the chroma-key pass) on models that reject the
flag. Raises :class:`GenerationError` if nothing usable comes back.
"""
sprite = provider or resolve_provider(require_references=bool(reference_images))
if reference_images and not sprite.supports_references:
raise GenerationError(
f"image backend '{sprite.name}' cannot use reference images; "
"configure OpenAI gpt-image-2 or Krea for pet generation"
)
refs = [str(p) for p in (reference_images or [])]
def _run(extra: dict) -> tuple[Path | None, str]:
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
if refs:
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
# ``reference_image_urls``. Send both; each ignores the other.
kwargs["reference_images"] = refs
kwargs["reference_image_urls"] = refs
try:
result = sprite.provider.generate(prompt, **kwargs)
except Exception as exc: # noqa: BLE001 - normalize provider crashes
logger.debug("provider.generate crashed: %s", exc)
return None, str(exc)
if not isinstance(result, dict) or not result.get("success"):
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
image_ref = result.get("image")
if not image_ref:
return None, "provider returned no image"
try:
return _save_local(str(image_ref), prefix=prefix), ""
except Exception as exc: # noqa: BLE001
return None, f"could not save generated image: {exc}"
out: list[Path] = []
last_error = ""
allow_transparent = True
for _ in range(max(1, n)):
path, err = _run({"background": "transparent"} if allow_transparent else {})
# Model doesn't support the transparent flag → drop it for this and every
# remaining variant (no point re-probing a capability we just disproved).
if path is None and allow_transparent and _rejected_background(err):
allow_transparent = False
path, err = _run({})
if path is not None:
out.append(path)
else:
last_error = err
if not out:
raise GenerationError(last_error or "image generation produced no output")
return out

View file

@ -0,0 +1,358 @@
"""Pet generation orchestration — the base-draft → hatch flow.
Two steps, mirroring the UX across every surface:
1. :func:`generate_base_drafts` a handful of prompt-only "what should this pet
look like" variants. Cheap; the user picks one (or retries for a fresh set).
2. :func:`hatch_pet` takes the chosen base and generates one grounded row
strip per Hermes state, slices each into frames, composes the atlas, validates
it, and writes the pet into the store.
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
calls happen once, on the pet you actually keep) and gives each UI a natural
preview/loading point.
"""
from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from agent.pet.generate import atlas, imagegen, prompts
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
logger = logging.getLogger(__name__)
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
ProgressFn = Callable[[str, str], None]
# Image generations are independent network calls, so we fan them out instead of
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
# don't hammer the provider's rate limit (one cold call can still be slow).
_MAX_PARALLEL_GENERATIONS = 4
# How many times to (re)generate a single row before accepting a best-effort
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
# stubborn row still yields frames instead of dropping out entirely.
_ROW_GEN_ATTEMPTS = 2
_MIN_FILLED_STATES = 6
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
@dataclass(frozen=True)
class HatchResult:
"""Outcome of a successful :func:`hatch_pet`."""
slug: str
display_name: str
spritesheet: Path
states: list[str]
validation: dict
def _harden_transparency(path: Path) -> Path:
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
``background=transparent`` is requested on every call, but image models honor
it inconsistently some still paint a flat (often near-white) backdrop. We
run the same chroma-key pass the row extractor uses so every base draft the
user picks between (and the reference the rows are grounded on) is a clean
cutout. Best-effort: a decode failure leaves the original untouched.
"""
from PIL import Image
try:
with Image.open(path) as opened:
keyed = atlas.remove_background(opened.convert("RGBA"))
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
# draft has no colored halo when composited on the dark UI.
keyed = atlas._clear_transparent_rgb(keyed)
out = path.with_suffix(".png")
keyed.save(out, format="PNG")
return out
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
return path
def generate_base_drafts(
concept: str,
*,
n: int = 4,
style: str = "auto",
reference_images: list[Path] | None = None,
provider: SpriteProvider | None = None,
on_draft: Callable[[int, Path], None] | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> list[Path]:
"""Generate *n* candidate base looks for *concept*; returns image paths.
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
Drafts are generated concurrently and *on_draft(index, path)* fires as each
one finishes (not at the end) so callers can stream previews to the UI
instead of leaving it blank until the whole batch is done.
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
started yet is skipped, and once it trips we stop staging/streaming further
drafts and cancel any queued work (already-in-flight provider calls can't be
hard-killed, but their results are dropped).
"""
# A user reference image (e.g. their own pet) grounds every draft, so it
# needs a reference-capable provider — same requirement as the row passes.
refs = reference_images or None
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
cancelled = is_cancelled or (lambda: False)
# Each draft is its own one-shot generation, run concurrently so the user
# waits for one image, not N. A single draft failing must not sink the set.
# Each gets a distinct variation nudge so the options aren't near-duplicates.
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
def _one(index: int) -> tuple[int, Path | None, str | None]:
if cancelled():
return index, None, None
t0 = time.monotonic()
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
try:
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
return index, None, str(exc)
if not out:
logger.warning("pet generate: draft %d produced no image", index)
return index, None, "the image provider returned no image"
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
return index, _harden_transparency(out[0]), None
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
results: dict[int, Path] = {}
errors: list[str] = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_one, i) for i in range(n)]
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
# gateway event it emits — inherits the request's bound transport, unlike
# the worker threads above.
for fut in as_completed(futures):
if cancelled():
logger.info("pet generate: cancelled — dropping remaining drafts")
for pending in futures:
pending.cancel()
break
index, path, err = fut.result()
if path is None:
if err:
errors.append(err)
continue
results[index] = path
if on_draft is not None:
try:
on_draft(index, path)
except Exception as exc: # noqa: BLE001 - progress is best-effort
logger.debug("on_draft callback failed: %s", exc)
drafts = [results[i] for i in sorted(results)]
if not drafts and not cancelled():
# Surface *why* — every draft failed for a reason (a content-policy refusal
# on a name like "minion", a provider/auth error, …); the most common one
# is the representative cause. Far more useful than "no usable drafts".
raise GenerationError(_drafts_failed_reason(errors))
return drafts
def _drafts_failed_reason(errors: list[str]) -> str:
"""The representative reason a draft round produced nothing, humanized."""
if not errors:
return "image generation produced no usable drafts"
from collections import Counter
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
def _humanize_image_error(error: str) -> str:
"""Turn a raw provider error into a friendly, actionable sentence.
The big one is moderation: image models refuse trademarked characters and
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
"""
low = error.lower()
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
return (
"The image provider blocked this prompt — its safety filter rejects "
"trademarked characters and real people. Try an original description."
)
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
return "The image provider rejected the request — check your API key in Settings → Providers."
if "rate limit" in low or "429" in low:
return "The image provider is rate-limiting — wait a moment and try again."
# Otherwise the first line, trimmed of the noisy provider envelope.
return error.splitlines()[0].strip()[:200]
def hatch_pet(
*,
base_image: str | Path,
slug: str,
display_name: str = "",
description: str = "",
concept: str = "",
style: str = "auto",
on_progress: ProgressFn | None = None,
provider: SpriteProvider | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> HatchResult:
"""Turn an approved base image into a full, installed Hermes pet.
Generates a grounded row strip per state, extracts frames, composes +
validates the atlas, and registers it. The idle row falls back to the base
look so the pet always renders. Raises :class:`GenerationError` on failure.
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
started are skipped, queued rows are cancelled, and once every row is done we
abort (raising :class:`GenerationError`) before composing/saving so a stopped
hatch never writes a half-built pet.
"""
base = Path(base_image)
if not base.is_file():
raise GenerationError(f"base image not found: {base}")
sprite = provider or imagegen.resolve_provider(require_references=True)
progress = on_progress or (lambda *_: None)
cancelled = is_cancelled or (lambda: False)
label = concept or display_name or slug
frames_by_state: dict[str, list] = {}
total_rows = len(atlas.ROW_SPECS)
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
# Generate every state's row strip concurrently — they're independent
# grounded calls, so the hatch waits for the slowest row, not their sum. A
# single row failing is tolerated (idle is guaranteed below).
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
state, _row, count = spec
if cancelled():
return state, None
t0 = time.monotonic()
last_exc: Exception | None = None
# Self-healing: a model occasionally returns a row whose poses are touching
# (no clean gutters), which slices badly. We retry such rolls; only the
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
# still yields *something* rather than dropping the whole row.
for attempt in range(_ROW_GEN_ATTEMPTS):
if cancelled():
return state, None
strict = attempt < _ROW_GEN_ATTEMPTS - 1
try:
strips = imagegen.generate(
prompts.build_row_prompt(state, count, label, style=style),
n=1,
reference_images=[base],
provider=sprite,
prefix=f"pet_row_{state}",
# Wider canvas → each frame gets real horizontal room, so winged
# poses keep a full, healthy size and still leave clean gutters.
aspect_ratio="landscape",
)
# ``components`` requires clean per-pose gutters (raises otherwise),
# so a touching roll is rejected and regenerated; the last attempt
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
# so normalize_cells registers the whole pet at once.
method = "components" if strict else "auto"
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
logger.info(
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
slug, state, time.monotonic() - t0, attempt + 1,
)
return state, frames
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
last_exc = exc
logger.warning(
"pet hatch %r: row %r attempt %d/%d failed: %s",
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
)
logger.warning(
"pet hatch %r: row %r gave up after %.1fs: %s",
slug, state, time.monotonic() - t0, last_exc,
)
return state, None
# running-left is derived by mirroring running-right (guaranteed-consistent
# and one fewer generation), so we don't generate it directly.
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
done = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
# as_completed runs on the caller (request) thread, so progress events
# emitted here inherit the request transport — unlike the worker threads.
for fut in as_completed(futures):
if cancelled():
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
for pending in futures:
pending.cancel()
break
state, frames = fut.result()
done += 1
progress("row", f"{state}:{done}:{total_rows}")
if frames:
frames_by_state[state] = frames
if cancelled():
raise GenerationError("hatch cancelled")
# Derive running-left from the approved running-right row (per-frame mirror,
# preserving order/timing). Missing running-right is rejected below; a pet
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
right = frames_by_state.get("running-right")
if right:
done += 1
progress("row", f"running-left:{done}:{total_rows}")
frames_by_state["running-left"] = atlas.mirror_frames(right)
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
else:
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
# Idle is the resting state the renderer falls back to — guarantee it.
if not frames_by_state.get("idle"):
progress("row", "idle-fallback")
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
progress("compose", "")
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
# One shared scale + baseline across every state so the pet never slides or
# pulses size between frames; compose just packs the normalized cells.
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
validation = atlas.validate_atlas(sheet)
if not validation["ok"]:
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
filled_states = set(validation["filled_states"])
missing_required = sorted(_REQUIRED_STATES - filled_states)
if missing_required:
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
if len(filled_states) < _MIN_FILLED_STATES:
raise GenerationError(
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
)
from agent.pet import store
progress("save", slug)
logger.info("pet hatch %r: saving pet", slug)
pet = store.register_local_pet(
sheet,
slug=slug,
display_name=display_name or slug,
description=description,
)
return HatchResult(
slug=pet.slug,
display_name=pet.display_name,
spritesheet=pet.spritesheet,
states=validation["filled_states"],
validation=validation,
)

View file

@ -0,0 +1,176 @@
"""Prompt builders for pet generation.
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
produce one horizontal strip of N poses). Prompts stay concise and
sprite-production oriented; the identity lock and "one transparent row" framing
matter more than flowery description.
We generate the full petdex/Codex nine-state set (see
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
``petdex submit`` spritesheet.
"""
from __future__ import annotations
# What each petdex/Codex state should depict (kept short — these go straight into
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
# effects, motion lines, shadows). Critical distinction: ``running`` is the
# *working* state (in place), while ``running-right`` / ``running-left`` are the
# actual directional walk/run cycles.
STATE_ACTIONS: dict[str, str] = {
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
"running-right": (
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
"faces and travels right with clear directional steps, a smooth gait loop"
),
"running-left": (
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
"faces and travels left with clear directional steps (the mirror of the "
"right-facing run)"
),
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
"waiting": (
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
"or approval — distinct from idle and review"
),
"running": (
"focused active work, staying IN PLACE (NOT walking or foot-running): "
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
),
"review": "careful inspection: a focused lean, head tilt, studying something intently",
}
_STYLE_HINTS: dict[str, str] = {
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
"auto": (
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
"limited palette, clean dark outline, flat cel shading, chunky chibi "
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
),
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
"plush": " Render as a soft plush toy.",
"clay": " Render as a claymation / soft 3D clay figure.",
"sticker": " Render as a glossy die-cut sticker.",
"flat-vector": " Render in flat vector mascot style.",
"3d-toy": " Render as a glossy 3D toy.",
"painterly": " Render in a soft painterly style.",
}
_BACKGROUND = (
"Center one full-body character on a flat, uniform, high-contrast chroma-key "
"background (prefer pure hot magenta #FF00FF unless that color appears on "
"the character). The background must completely surround the character: one "
"even color with NO gradient, vignette, texture, pattern, scenery, shadow, "
"ground line, frame, or border, so it keys out cleanly. The background color "
"must not appear anywhere on the character itself. No text, no labels."
)
def style_hint(style: str | None) -> str:
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
# orchestrate). The extra width is what lets each pose stay a healthy size AND
# leave a real gutter — used here only to cite concrete pixel numbers.
_ASSUMED_STRIP_WIDTH = 1536
def _spacing_spec(frame_count: int) -> tuple[int, int]:
"""(per-pose width px, gap px) for a row of *frame_count* poses.
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
the full wingspan, so neighbors touch even when bodies are spaced. The lever
that works is proportional containment on a wide canvas: give each pose its
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
generous gutter, so the pet stays a normal, good-looking size no shrinking.
"""
slots = max(1, frame_count)
slot_w = _ASSUMED_STRIP_WIDTH / slots
pose_px = round(slot_w * 0.7)
gap_px = max(48, round(slot_w * 0.3))
return pose_px, gap_px
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
# near-duplicates for a single prompt. We vary the *look* (palette, build,
# expression, accents), NOT the pose, so the chosen base still grounds clean,
# consistent animation rows.
BASE_VARIATIONS: tuple[str, ...] = (
"",
"a distinctly different colour palette and markings",
"a heavier, broader silhouette with sturdier proportions",
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
"a leaner, taller build and an alternate colour scheme",
"bolder, more saturated colours and a stronger expression matching the concept tone",
)
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
"""The base look: a single, clean, centered full-body mascot.
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
"""
concept = (concept or "a distinctive mascot creature").strip()
nudge = f" Make this design distinct: {variation}." if variation else ""
return (
f"A stylized mascot pet character: {concept}. "
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
"while staying non-graphic. "
"Compact, whole-body silhouette that reads clearly at small size, "
"clear readable facial features, simple consistent palette. "
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
"relaxed at the sides, feet together on the ground, any cape/accessories "
"hanging straight and still."
f"{nudge} "
f"{_BACKGROUND}{style_hint(style)}"
)
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
"""A row strip: *frame_count* poses of the SAME character, left→right.
The attached base image is the identity source of truth; the prompt locks
species, palette, face, and props to it.
"""
action = STATE_ACTIONS.get(state, "a simple idle pose")
concept = (concept or "the mascot").strip()
pose_px, gap_px = _spacing_spec(frame_count)
return (
f"Using the attached reference image as the exact same character "
f"(same species, face, colors, markings, proportions, and props), "
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
f"LAYOUT: split the wide strip into {frame_count} equal vertical cells, one "
"pose centered in each cell. "
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
f"the same empty margin before the first pose and after the last. If a wing, "
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
f"share a shadow, share a ground line, share motion trails, or merge into "
f"one connected shape. "
# Registration: a clean sprite sheet keeps the character locked in place
# so only the action moves — this is what stops the loop sliding/pulsing.
"REGISTRATION (critical): the character is the SAME height and SAME width "
"in every frame, drawn at the SAME scale, centered over the SAME point, "
"with all feet resting on ONE shared horizontal ground line across the "
"whole strip. Keep the body's center, size, and stance fixed frame to "
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
"bags, and scarves stay in the SAME place and shape every frame (no "
"swinging, flowing, or drifting) unless the action itself requires it. No "
"pose is cropped at the strip edges. "
f"{_BACKGROUND}{style_hint(style)}"
)

View file

@ -21,6 +21,7 @@ Read-only and unauthenticated; no credentials involved.
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
MANIFEST_URL = "https://petdex.dev/api/manifest"
_DEFAULT_TIMEOUT = 20.0
_DEFAULT_TIMEOUT = 10.0
# In-process cache for the (large, slow, identical-per-call) manifest. The list
# is a static CDN object that barely changes, yet a single session can ask for
@ -38,6 +39,9 @@ _DEFAULT_TIMEOUT = 20.0
_MANIFEST_TTL = 300.0
_cache: tuple[float, list[ManifestEntry]] | None = None
_prefetch_lock = threading.Lock()
_prefetching = False
def clear_cache() -> None:
"""Drop the cached manifest (forces the next fetch to hit the network)."""
@ -45,6 +49,39 @@ def clear_cache() -> None:
_cache = None
def _cache_is_warm() -> bool:
return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
The desktop picker calls this when it loads the (instant) local-only gallery
so the full petdex catalog is usually cached by the time it's requested,
without ever holding up the user's own pets on a network round-trip.
"""
global _prefetching
if _cache_is_warm():
return
with _prefetch_lock:
if _prefetching:
return
_prefetching = True
def _run() -> None:
global _prefetching
try:
fetch_manifest(timeout=timeout)
except Exception as exc: # noqa: BLE001 - best-effort warm
logger.debug("petdex manifest prefetch failed: %s", exc)
finally:
_prefetching = False
threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start()
@dataclass(frozen=True)
class ManifestEntry:
"""A single pet's row in the manifest."""

View file

@ -18,6 +18,7 @@ from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from pathlib import Path
@ -41,11 +42,16 @@ class InstalledPet:
description: str
directory: Path
spritesheet: Path
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
@property
def exists(self) -> bool:
return self.spritesheet.is_file()
@property
def generated(self) -> bool:
return self.created_by == "generator"
def pets_dir() -> Path:
"""Return the profile-scoped pets directory (created on demand)."""
@ -113,6 +119,7 @@ def load_pet(slug: str) -> InstalledPet | None:
description=str(meta.get("description", "") or ""),
directory=directory,
spritesheet=_resolve_spritesheet(directory, meta),
created_by=str(meta.get("createdBy", "") or ""),
)
@ -197,6 +204,101 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
return pet
def slugify(name: str) -> str:
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
return slug or "pet"
def unique_slug(name: str) -> str:
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
base = slugify(name)
slug = base
counter = 2
while (pets_dir() / slug).exists():
slug = f"{base}-{counter}"
counter += 1
return slug
def _write_spritesheet(source, dest: Path) -> None:
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
if isinstance(source, (bytes, bytearray)):
dest.write_bytes(bytes(source))
return
from PIL import Image
if isinstance(source, (str, Path)):
with Image.open(source) as opened:
image = opened.convert("RGBA")
else:
image = source.convert("RGBA")
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
def register_local_pet(
spritesheet,
*,
slug: str,
display_name: str = "",
description: str = "",
) -> InstalledPet:
"""Write a locally-generated pet into the store and return it.
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
returns an already-on-disk pet before consulting the manifest, it can be
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
"""
slug = slugify(slug)
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_path = directory / "spritesheet.webp"
try:
_write_spritesheet(spritesheet, sprite_path)
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
meta = {
"id": slug,
"displayName": display_name or slug,
"description": description or "",
"spritesheetPath": sprite_path.name,
"createdBy": "generator",
}
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
pet = load_pet(slug)
if pet is None or not pet.exists:
raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet")
return pet
def export_pet(slug: str) -> tuple[str, bytes]:
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
re-importable pet package. Raises :class:`PetStoreError` if not installed.
"""
import io
import zipfile
root = pets_dir()
directory = root / slug.strip()
# Guard against traversal: the target must be a direct child of pets_dir.
if directory.resolve().parent != root.resolve() or not directory.is_dir():
raise PetStoreError(f"pet '{slug}' is not installed")
name = directory.name
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(directory.iterdir()):
if path.is_file() and not path.name.startswith("."):
archive.write(path, f"{name}/{path.name}")
return f"{name}.zip", buf.getvalue()
_THUMB_FRAME_W = 192
_THUMB_FRAME_H = 208
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
@ -301,6 +403,15 @@ def remove_pet(slug: str) -> bool:
slug = _safe_slug(slug)
if not slug:
return False
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
# slug renders this one's stale thumbnail.
try:
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
except OSError:
pass
directory = pets_dir() / slug
if not directory.is_dir():
return False
@ -308,6 +419,55 @@ def remove_pet(slug: str) -> bool:
return not directory.exists()
def rename_pet(slug: str, display_name: str) -> str | None:
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
Generated pets are hatched under a provisional, prompt-derived slug; when
the user names the pet on the reveal screen we make that name the real
identity so lists/subtitles show what they typed, not the prompt. The dir is
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
whenever that yields a free, different slug otherwise the slug is left as
is. Returns the resulting slug on success, or ``None`` on failure.
"""
slug = _safe_slug(slug)
display_name = (display_name or "").strip()
if not slug or not display_name:
return None
directory = pets_dir() / slug
pet_json = directory / "pet.json"
if not pet_json.is_file():
return None
try:
meta = json.loads(pet_json.read_text(encoding="utf-8"))
except (OSError, ValueError):
meta = {}
if not isinstance(meta, dict):
meta = {}
meta["displayName"] = display_name
new_slug = slug
desired = slugify(display_name)
if desired and desired != slug and not (pets_dir() / desired).exists():
try:
directory.rename(pets_dir() / desired)
try:
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
except OSError:
pass
directory = pets_dir() / desired
pet_json = directory / "pet.json"
new_slug = desired
meta["id"] = new_slug
except OSError:
new_slug = slug # keep the provisional slug if the move fails
try:
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
except OSError:
return None
return new_slug
def _download(url: str, dest: Path, *, timeout: float) -> None:
import httpx

View file

@ -20,6 +20,7 @@ import {
Clock,
Cpu,
Download,
Egg,
Globe,
type IconComponent,
Info,
@ -43,6 +44,7 @@ import {
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { runGatewayRestart } from '@/store/system-actions'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@ -409,6 +411,13 @@ export function CommandPalette() {
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
label: cc.pets.title,
to: 'pets'
},
{
icon: Egg,
id: 'appearance-generate-pet',
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
label: cc.generatePet.title,
run: () => openPetGenerate()
}
]
},
@ -653,6 +662,8 @@ export function CommandPalette() {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
@ -663,7 +674,7 @@ export function CommandPalette() {
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage search={search} />
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (

View file

@ -15,7 +15,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Loader2, PawPrint } from '@/lib/icons'
import { Check, Egg, Loader2, PawPrint } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petBusy,
@ -31,9 +31,11 @@ import {
interface PetPalettePageProps {
search: string
/** Navigate to the "generate a pet" page (rendered as a header action). */
onGenerate?: () => void
}
export function PetPalettePage({ search }: PetPalettePageProps) {
export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
@ -72,6 +74,24 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
return (
<div role="listbox">
{onGenerate && (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
HUD_ITEM,
HUD_TEXT
)}
onClick={onGenerate}
onMouseDown={event => event.preventDefault()}
type="button"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
<Egg className="size-4" />
</span>
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
</button>
)}
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{shown.length === 0 ? (
@ -104,7 +124,14 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
url={pet.spritesheetUrl}
/>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{pet.displayName}</span>
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{pet.displayName}</span>
{pet.generated && (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
{copy.generatedTag}
</span>
)}
</span>
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
{pet.slug}
{pet.installed ? ` · ${copy.installed}` : ''}

View file

@ -108,6 +108,7 @@ import { useKeybinds } from './hooks/use-keybinds'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
@ -1028,6 +1029,7 @@ export function DesktopController() {
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
{settingsOpen && (

View file

@ -94,7 +94,7 @@ export function useGatewayRequest() {
}, [])
const requestGateway = useCallback(
async <T>(method: string, params: Record<string, unknown> = {}) => {
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
const gateway = gatewayRef.current
if (!gateway) {
@ -102,7 +102,7 @@ export function useGatewayRequest() {
}
try {
return await gateway.request<T>(method, params)
return await gateway.request<T>(method, params, timeoutMs, signal)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@ -128,7 +128,7 @@ export function useGatewayRequest() {
throw error
}
return recovered.request<T>(method, params)
return recovered.request<T>(method, params, timeoutMs, signal)
}
},
[ensureGatewayOpen]

View file

@ -0,0 +1,19 @@
import { useLocation } from 'react-router-dom'
import { appViewForPath, isOverlayView } from '@/app/routes'
/**
* True while a full-screen route overlay (settings, agents, command-center, )
* is showing.
*
* A portaled Radix modal sits above the app shell, so it would cover such a
* route. Any modal that sends the user to one (e.g. "set up image generation"
* `/settings`) can `if (useRouteOverlayActive()) return null` to *yield* the
* screen its open state lives in a store, so it stays open and reappear,
* re-running its mount effects (a free refresh), when the route overlay closes.
*/
export function useRouteOverlayActive(): boolean {
const { pathname } = useLocation()
return isOverlayView(appViewForPath(pathname))
}

View file

@ -0,0 +1,89 @@
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { PawPrint } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
const VARIANT_COUNT = 4
interface DraftGridProps {
drafts: { index: number; dataUri: string }[]
generating: boolean
hasDrafts: boolean
onCancel: () => void
onHatch: () => void
onSelect: (index: number) => void
selected: number | null
}
export function DraftGrid({ drafts, generating, hasDrafts, onCancel, onHatch, onSelect, selected }: DraftGridProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const slots = generating
? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null)
: drafts
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span className={cn(generating && 'shimmer shimmer-color-primary opacity-40', !generating && 'invisible')}>
{copy.generating}
</span>
<span className="tabular-nums">
{Math.min(drafts.length, VARIANT_COUNT)}/{VARIANT_COUNT}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{slots.map((draft, i) => {
// A streamed draft is selectable immediately — even mid-generation —
// so the user can commit to one without waiting for the rest.
const isSelected = draft != null && selected === draft.index
return (
<button
className={cn(
'relative flex aspect-[192/208] items-center justify-center overflow-hidden',
selectableCardClass({ active: isSelected, prominent: true })
)}
disabled={draft == null}
key={draft ? `draft-${draft.index}` : `slot-${i}`}
onClick={() => draft != null && onSelect(draft.index)}
type="button"
>
{draft != null ? (
// Hatches into place as each draft streams back.
<img
alt=""
className="pet-reveal size-full object-contain p-1.5"
draggable={false}
src={draft.dataUri}
/>
) : (
// Incubating: a creme egg bouncing on its contact shadow.
<div className="relative z-10 flex flex-col items-center">
<PixelEggSprite index={i} mode="bounce" size={48} />
<span className="pet-egg-shadow pet-egg-shadow--sm" style={{ marginTop: '-0.3rem' }} />
</div>
)}
</button>
)
})}
</div>
{/* Same abort/go-back text link in both states (sits right under the grid);
once drafts land, the full-width Hatch drops in below it. */}
<Button className="self-center" onClick={onCancel} size="xs" variant="text">
{t.common.cancel}
</Button>
{hasDrafts && (
<Button className="w-full" disabled={selected === null} onClick={onHatch}>
<PawPrint />
{copy.hatch}
</Button>
)}
</div>
)
}

View file

@ -0,0 +1,27 @@
import { Button } from '@/components/ui/button'
interface EmptyHintProps {
onExample: (prompt: string) => void
}
// Creative seed prompts — specifics make better pets (petdex's own advice).
// Short chips that wrap into a tight, centered cluster (capped width → 2 rows).
const EXAMPLE_PROMPTS = ['bubble-tea otter', 'sock elf', 'pixel dragon', 'office cat', 'neon axolotl', 'moss golem']
export function EmptyHint({ onExample }: EmptyHintProps) {
return (
<div className="flex max-w-[300px] flex-wrap place-content-center place-items-center gap-2">
{EXAMPLE_PROMPTS.map(example => (
<Button
className="h-auto w-fit rounded-full font-normal"
key={example}
onClick={() => onExample(`a ${example}`)}
size="xs"
variant="outline"
>
{example}
</Button>
))}
</div>
)
}

View file

@ -0,0 +1,52 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/external-link'
import { PawPrint, Settings2 } from '@/lib/icons'
interface GenerateUnavailableProps {
onSetup: () => void
}
// Shown when no reference-capable image backend is configured: generation is
// impossible, so we replace the prompt entirely with a friendly path to set one
// up (in-app) plus where to grab a key.
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
return (
<div className="flex flex-col items-center gap-4 text-center">
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
<PawPrint className="size-5" />
</span>
<div className="space-y-1.5">
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">Add an image backend to generate</p>
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
Hatching a custom pet needs a provider that can ground on a reference image.
</p>
</div>
<Button onClick={onSetup} size="sm">
<Settings2 className="size-4" />
Set up image generation
</Button>
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
<span>Grab a key from</span>
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
Nous Portal
</ExternalLink>
<span>·</span>
<ExternalLink
className="opacity-40 transition-opacity hover:opacity-100"
href="https://openrouter.ai/keys"
showExternalIcon={false}
>
OpenRouter
</ExternalLink>
<span>·</span>
<ExternalLink
className="opacity-40 transition-opacity hover:opacity-100"
href="https://platform.openai.com/api-keys"
showExternalIcon={false}
>
OpenAI
</ExternalLink>
</p>
</div>
)
}

View file

@ -0,0 +1,137 @@
import { useEffect, useState } from 'react'
import { PetSprite } from '@/components/pet/pet-sprite'
import { PetStarShower } from '@/components/pet/pet-star-shower'
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, RefreshCw } from '@/lib/icons'
import { type PetInfo } from '@/store/pet'
import { frameCountForRow } from '../lib/frame-count'
const PREVIEW_SCALE = 0.7
const PREVIEW_STATE_MS = 1400
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
interface HatchPreviewProps {
pet: PetInfo
adopting: boolean
error: string | null
onAdopt: (name: string) => void
onDiscard: () => void
}
export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
// Empty so the "Name your pet" placeholder shows; blank adopt keeps the
// provisional name from the prompt.
const [name, setName] = useState('')
// Play the egg's crack/hatch frames once before swapping in the live pet.
const [revealed, setRevealed] = useState(false)
// Right after the egg cracks the pet plays its "yay" jump a couple times, then
// hands off to the normal state-cycling preview.
const [celebrating, setCelebrating] = useState(false)
const [stateIndex, setStateIndex] = useState(0)
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
const rows = previewRows.length > 0 ? previewRows : ['idle']
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
const canJump = frameCountForRow(pet, 'jumping') > 0
const rowOverride = celebrating && canJump ? 'jumping' : activeRow
useEffect(() => {
const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS)
return () => clearInterval(id)
}, [rows.length])
// On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview.
useEffect(() => {
if (!revealed) {
return
}
setCelebrating(true)
const id = setTimeout(() => {
setCelebrating(false)
setStateIndex(0)
}, 2 * (pet.loopMs ?? 1100))
return () => clearTimeout(id)
}, [revealed, pet.loopMs])
useEffect(() => {
setStateIndex(0)
setName('')
setRevealed(false)
setCelebrating(false)
}, [pet.slug])
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
return (
<div className="flex flex-col items-center gap-2">
{/* Fills the (now narrow) dialog so the pet frame is the screen width. */}
<div className="relative flex aspect-[192/208] w-full items-center justify-center overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)">
{revealed ? (
<>
<div className="relative inline-block">
<span aria-hidden className="pet-contact-shadow" />
<div className="pet-reveal relative z-10">
<PetSprite info={previewInfo} rowOverride={rowOverride} />
</div>
</div>
<PetStarShower />
</>
) : (
// The egg cracks open, then we swap in the live pet.
<PixelEggSprite
mode="hatch"
onDone={() => {
setRevealed(true)
triggerHaptic('crisp')
}}
size={150}
/>
)}
</div>
<Input
autoFocus
className="w-full"
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
onAdopt(name)
}
}}
placeholder={copy.namePlaceholder}
value={name}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex w-full items-center gap-1.5">
<Button disabled={adopting} onClick={onDiscard} variant="ghost">
<RefreshCw />
{copy.startOver}
</Button>
<Button className="flex-1" disabled={adopting} onClick={() => onAdopt(name)}>
{adopting ? <Loader2 className="animate-spin" /> : <PawPrint />}
{copy.adopt}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,24 @@
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
import { useI18n } from '@/i18n'
import { cancelHatch, type PetHatchStage } from '@/store/pet-generate'
interface HatchingViewProps {
stage: PetHatchStage | null
}
// The hatch progress screen — a beating egg with a phase-tracking subtitle
// (per-row → composing → saving).
export function HatchingView({ stage }: HatchingViewProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const subtitle = stage
? stage.phase === 'row'
? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0)
: stage.phase === 'compose'
? copy.hatchComposing
: copy.hatchSaving
: copy.hatchingSub
return <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
}

View file

@ -0,0 +1,53 @@
import { useStore } from '@nanostores/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Check, ChevronDown } from '@/lib/icons'
import { $petGenProvider, $petGenProviders, setPetGenProvider } from '@/store/pet-generate'
// Image-backend picker for pet generation — the composer's model-pill pattern:
// a quiet trigger + a dropdown of options, each with a one-line speed/quality
// note. Hidden unless there are 2+ reference-capable backends (nothing to pick).
export function ProviderPicker() {
const providers = useStore($petGenProviders)
const picked = useStore($petGenProvider)
if (providers.length < 2) {
return null
}
const fallback = providers.find(p => p.default) ?? providers[0]
const current = providers.find(p => p.name === picked) ?? fallback
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Plain text affordance (matches "Add a reference"), not a padded pill. */}
<button
className="flex h-6 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
type="button"
>
{current?.label}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
{/* The picker lives inside the pet-gen Dialog (z-130) and portals to body,
so lift its menu above the dialog or it opens behind it. */}
<DropdownMenuContent align="start" className="z-[140] w-56">
{providers.map(provider => (
<DropdownMenuItem
className="flex-col items-start gap-0.5"
key={provider.name}
// Picking the default clears the override (no need to pin it).
onSelect={() => setPetGenProvider(provider.default ? '' : provider.name)}
>
<span className="flex w-full items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-foreground">{provider.label}</span>
{provider.name === current?.name && <Check className="size-3.5 text-primary" />}
</span>
{provider.note && <span className="text-[0.6875rem] text-(--ui-text-tertiary)">{provider.note}</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,48 @@
import { useState } from 'react'
import { ImageLightbox } from '@/components/chat/zoomable-image'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { X } from '@/lib/icons'
interface ReferenceChipProps {
name: string
onRemove: () => void
src: string
}
// The reference photo as an attachment chip: filename + thumbnail that opens
// the shared image viewer (lightbox), with a remove affordance.
export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
const { t } = useI18n()
const { download, saving } = useImageDownload(src)
const [viewing, setViewing] = useState(false)
return (
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
</button>
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
<button
aria-label="Remove reference"
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
onClick={onRemove}
type="button"
>
<X className="size-3" />
</button>
<ImageLightbox
alt={name}
copy={t.desktop}
onClick={download}
onOpenChange={setViewing}
open={viewing}
saving={saving}
src={src}
/>
</div>
)
}

View file

@ -0,0 +1,26 @@
import { type PetInfo } from '@/store/pet'
// Sprite row → the PetInfo frame-count key it resolves to (directional walks and
// aliases collapse onto their base state).
const ROW_TO_FRAME_KEY: Record<string, string> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
failed: 'failed',
review: 'review',
waiting: 'waiting'
}
// Real frame count for a row, preferring the concrete per-row count, then the
// per-state count, then the mapped base state, then the sheet-wide default.
export function frameCountForRow(pet: PetInfo, row: string): number {
const mapped = ROW_TO_FRAME_KEY[row]
return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
}

View file

@ -0,0 +1,49 @@
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024
function loadImage(url: string): Promise<HTMLImageElement> {
const img = new Image()
return new Promise((resolve, reject) => {
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('unreadable image'))
img.src = url
})
}
// Read an image file as a downscaled PNG data URL. We decode from an object URL
// (not readAsDataURL) so large files don't inflate into giant base64 strings
// before we scale them down for generation.
export async function readReferenceImage(
file: File,
max = 1024,
maxInputBytes = DEFAULT_MAX_INPUT_BYTES
): Promise<string> {
if (file.size > maxInputBytes) {
throw new Error('reference image too large')
}
const objectUrl = URL.createObjectURL(file)
try {
const img = await loadImage(objectUrl)
const scale = Math.min(1, max / Math.max(img.width, img.height))
const width = Math.max(1, Math.round(img.width * scale))
const height = Math.max(1, Math.round(img.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('could not create canvas context')
}
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/png')
} finally {
URL.revokeObjectURL(objectUrl)
}
}

View file

@ -0,0 +1,293 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { SETTINGS_ROUTE } from '@/app/routes'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Egg, ImageIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petGenAvailable,
$petGenDrafts,
$petGenError,
$petGenInput,
$petGenPreview,
$petGenRefImage,
$petGenRefName,
$petGenSelected,
$petGenStage,
$petGenStatus,
adoptHatched,
cancelGenerate,
checkPetGenAvailable,
cleanPetName,
closePetGenerate,
discardDrafts,
discardHatched,
generateDrafts,
hatchSelected
} from '@/store/pet-generate'
import { DraftGrid } from './components/draft-grid'
import { EmptyHint } from './components/empty-hint'
import { GenerateUnavailable } from './components/generate-unavailable'
import { HatchPreview } from './components/hatch-preview'
import { HatchingView } from './components/hatching-view'
import { ProviderPicker } from './components/provider-picker'
import { ReferenceChip } from './components/reference-chip'
import { readReferenceImage } from './lib/read-reference-image'
// The generate → hatch → adopt controller. A thin view over the `pet-generate`
// store; the store owns the steps and persists inputs across close/reopen.
export function PetGenerateContent() {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const { requestGateway } = useGatewayRequest()
const navigate = useNavigate()
const status = useStore($petGenStatus)
const error = useStore($petGenError)
const available = useStore($petGenAvailable)
// `null` = not yet probed → stay optimistic (show the prompt); only the
// confirmed-no-backend case swaps in the setup card.
const unavailable = available === false
const drafts = useStore($petGenDrafts)
const selected = useStore($petGenSelected)
const preview = useStore($petGenPreview)
const stage = useStore($petGenStage)
// Inputs live in atoms so they survive a close/reopen (and background runs).
const prompt = useStore($petGenInput)
const refImage = useStore($petGenRefImage)
const refName = useStore($petGenRefName)
const fileRef = useRef<HTMLInputElement>(null)
// Probe backend availability on open — and again whenever the content
// remounts (e.g. after returning from the providers settings), so adding a
// key flips the setup card to the prompt with no manual refresh.
useEffect(() => {
void checkPetGenAvailable(requestGateway)
}, [requestGateway])
const busy = status === 'generating' || status === 'hatching'
const hasDrafts = drafts.length > 0
const generating = status === 'generating'
// The idle "describe a pet" state — egg + suggestions get generous, equidistant
// breathing room (gap-4) from the prompt; the working states stay compact.
const isEmptyState =
!hasDrafts &&
!generating &&
status !== 'hatching' &&
status !== 'preview' &&
status !== 'adopting' &&
status !== 'stale'
const generate = () => {
if ((prompt.trim() || refImage) && !busy) {
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: refImage ?? undefined })
}
}
const clearReference = () => {
$petGenRefImage.set(null)
$petGenRefName.set('')
}
const pickReference = (file: File | undefined) => {
if (!file) {
return
}
const mapReferenceError = (reason: unknown): string => {
const message = reason instanceof Error ? reason.message.toLowerCase() : ''
return message.includes('too large') ? copy.referenceImageTooLarge : copy.referenceImageInvalid
}
void readReferenceImage(file)
.then(dataUrl => {
$petGenRefImage.set(dataUrl)
$petGenRefName.set(file.name)
// Clear picker-only errors once the reference is valid again.
if ($petGenStatus.get() === 'error' && $petGenDrafts.get().length === 0) {
$petGenStatus.set('idle')
$petGenError.set(null)
}
})
.catch(reason => {
$petGenRefImage.set(null)
$petGenRefName.set('')
$petGenError.set(mapReferenceError(reason))
if (!busy) {
$petGenStatus.set('error')
}
})
}
// One-click an example prompt straight into a draft round.
const runExample = (example: string) => {
$petGenInput.set(example)
void generateDrafts(requestGateway, { prompt: example })
}
// Hatch the selected draft. The user can pick one before the rest stream in —
// if so, abort the remaining generations first (keeping the drafts we have).
// The prompt is grounding text, not a label; the user names it on reveal.
const hatch = () => {
if (selected === null) {
return
}
if (generating) {
cancelGenerate()
}
void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() })
}
const adopt = (finalName: string) => {
void adoptHatched(requestGateway, finalName).then(out => {
if (out.ok) {
triggerHaptic('crisp')
closePetGenerate()
}
})
}
// The header title tracks the phase instead of sticking on "Generate a pet".
const headerTitle =
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
// Send the user to set up a key without closing — the overlay yields to the
// settings route (useRouteOverlayActive) and reappears + re-checks on return.
const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`)
// Prompt input only belongs on the describe/draft screens (and never when
// there's no backend to generate with).
const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting'
return (
<>
{unavailable ? (
<DialogTitle className="sr-only">{copy.title}</DialogTitle>
) : (
<DialogHeader>
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
</DialogHeader>
)}
<div className={cn('flex min-h-0 flex-1 flex-col', isEmptyState ? 'gap-4' : 'gap-2.5')}>
{/* Concept prompt with the inline sparkle generate/stop affordance (the
same primitive as the commit-message + project-idea fields). */}
{showPrompt && (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Input
autoFocus
className="pr-9"
onChange={event => $petGenInput.set(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
generate()
}
}}
placeholder={copy.placeholder}
value={prompt}
/>
<GenerateButton
className="absolute right-1 top-1/2 -translate-y-1/2"
disabled={!prompt.trim() && !refImage}
generating={generating}
generatingLabel={t.common.cancel}
label={copy.generate}
// Inline cancel should match step-2 cancel semantics: abort and
// return to step 1 (prompt retained for quick tweaks).
onCancel={discardDrafts}
onGenerate={generate}
/>
</div>
<div className="flex items-center gap-2">
<ProviderPicker />
{refImage ? (
<ReferenceChip name={refName} onRemove={clearReference} src={refImage} />
) : (
<button
className="ml-auto flex h-6 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
onClick={() => fileRef.current?.click()}
type="button"
>
<ImageIcon className="size-3" />
Add a reference
</button>
)}
</div>
{/* Optional reference photo make a pet from the user's own image.
Styled like the chat composer's attachment pill. */}
<Input
accept="image/*"
className="hidden"
onChange={event => {
pickReference(event.target.files?.[0])
event.target.value = ''
}}
ref={fileRef}
type="file"
/>
</div>
)}
{/* Hatch failed but the drafts are still here show why above the grid so
the user can re-pick and retry without losing their options. */}
{status === 'error' && hasDrafts && (
<Alert variant="destructive">
<AlertDescription>{error || copy.genericError}</AlertDescription>
</Alert>
)}
{unavailable ? (
<GenerateUnavailable onSetup={setupImageGen} />
) : status === 'stale' ? (
<Alert variant="destructive">
<AlertDescription>{copy.staleBackend}</AlertDescription>
</Alert>
) : status === 'hatching' ? (
<HatchingView stage={stage} />
) : (status === 'preview' || status === 'adopting') && preview ? (
<HatchPreview
adopting={status === 'adopting'}
error={error}
onAdopt={adopt}
onDiscard={() => void discardHatched(requestGateway)}
pet={preview}
/>
) : !hasDrafts && !generating ? (
// Doubles as the error-empty state — the failure reason rides the
// dialog's footer banner, so here we just offer the retry sparks.
<EmptyHint onExample={runExample} />
) : (
<DraftGrid
drafts={drafts}
generating={generating}
hasDrafts={hasDrafts}
onCancel={discardDrafts}
onHatch={hatch}
onSelect={index => $petGenSelected.set(index)}
selected={selected}
/>
)}
</div>
</>
)
}

View file

@ -0,0 +1,85 @@
/**
* "Hatch a Pet" a dedicated, Pokédex-style overlay for pet generation.
*
* Previously generation lived as a cramped nested page inside the Cmd-K command
* palette (~34rem popover). This is its own full Radix dialog with room to
* breathe: a device-framed header, its own concept prompt, a roomy draft grid
* that streams in live, and the egg-hatch + reveal flow. It's a thin view over
* the `pet-generate` store; the store owns the generate hatch adopt steps.
*
* This file is just the dialog shell + sizing; the flow lives in
* `PetGenerateContent`, and each screen is its own atomic component under
* `./components`.
*/
import { useStore } from '@nanostores/react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
$petGenDrafts,
$petGenerateOpen,
$petGenError,
$petGenStatus,
cleanupPetGenOnClose,
closePetGenerate
} from '@/store/pet-generate'
import { PetGenerateContent } from './pet-generate-content'
export function PetGenerateOverlay() {
const { t } = useI18n()
const { requestGateway } = useGatewayRequest()
const open = useStore($petGenerateOpen)
const status = useStore($petGenStatus)
const error = useStore($petGenError)
const drafts = useStore($petGenDrafts)
// Yield the screen to a full-screen route overlay (e.g. /settings while the
// user adds an image-gen key) without tearing down — the store keeps us open,
// and we reappear + re-check on return.
if (useRouteOverlayActive()) {
return null
}
const handleOpenChange = (next: boolean) => {
if (!next) {
cleanupPetGenOnClose(requestGateway)
// Never interrupt in-flight work. Generating/hatching continues in the
// background; only an unadopted finished preview is discarded on close.
closePetGenerate()
}
}
// The draft screen needs room for the 2×2 grid; the single-pet screens
// (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box.
// `fitContent` lets the dialog size to content; the `min-w` floors each phase.
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
const copy = t.commandCenter.generatePet
// The footer banner narrates the dialog's async state: the failure reason on a
// dead-end error, else the "you can close this, we'll notify you" reassurance
// while a generate/hatch runs in the background.
const working = status === 'generating' || status === 'hatching'
const errored = status === 'error' && drafts.length === 0
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : undefined
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
banner={banner}
bannerTone={errored ? 'error' : 'info'}
// Cap the width so a long banner (e.g. a provider refusal) wraps instead
// of stretching the dialog out; the min-w floors each phase.
className={cn('gap-4 text-center', single ? 'min-w-[17rem] max-w-[20rem]' : 'min-w-[19rem] max-w-[22rem]')}
fitContent
>
{open && <PetGenerateContent />}
</DialogContent>
</Dialog>
)
}

View file

@ -42,6 +42,7 @@ import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@ -1178,6 +1179,18 @@ export function usePromptActions({
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /hatch opens the pet generator overlay (the desktop's rich, multi-step
// generate→pick→hatch→adopt flow). A typed description seeds the prompt
// so `/hatch a cyber fox` lands on the composer step prefilled.
hatch: async ({ arg }) => {
const concept = arg.trim()
if (concept) {
$petGenInput.set(concept)
}
openPetGenerate()
},
pet: async ctx => {
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
const lower = sub.toLowerCase()

View file

@ -1,12 +1,16 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { type ReactNode, useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $petInfo } from '@/store/pet'
@ -16,13 +20,16 @@ import {
$petGalleryError,
$petGalleryStatus,
adoptPet,
exportPet as exportPetAction,
loadPetGallery,
loadPetThumb,
PET_SCALE_DEFAULT,
PET_SCALE_MAX,
PET_SCALE_MIN,
type GalleryPet,
rankedGalleryPets,
removePet as removePetAction,
renamePet as renamePetAction,
setPetEnabled,
setPetScale
} from '@/store/pet-gallery'
@ -48,6 +55,9 @@ export function PetSettings() {
const busySlug = useStore($petBusy)
const petInfo = useStore($petInfo)
const [query, setQuery] = useState('')
const [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
const [renameValue, setRenameValue] = useState('')
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
useEffect(() => {
@ -71,6 +81,23 @@ export function PetSettings() {
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const exportPet = (slug: string) => {
void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const saveRename = () => {
if (!renameTarget || !renameValue.trim()) {
return
}
// Optimistic: the rename paints instantly, so close now and let the RPC
// settle in the background (it rolls back + surfaces an error on failure).
const { slug } = renameTarget
setRenameTarget(null)
triggerHaptic('crisp')
void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug))
}
const toggle = (on: boolean) => {
void setPetEnabled(requestGateway, on, {
noneAvailable: copy.noneAvailable,
@ -142,8 +169,15 @@ export function PetSettings() {
url={pet.spritesheetUrl}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
<span className="flex items-center gap-1.5">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
</span>
{pet.generated && (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
{copy.generatedTag}
</span>
)}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{pet.slug}
@ -152,16 +186,36 @@ export function PetSettings() {
</span>
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
</button>
{pet.installed && !isBusy && (
<button
aria-label={copy.uninstall(pet.displayName)}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => void removePet(pet.slug)}
title={copy.uninstall(pet.displayName)}
type="button"
>
<Trash2 className="size-3.5" />
</button>
{!isBusy && (pet.installed || pet.generated) && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100">
{pet.generated && (
<PetAction
icon={<Pencil className="size-3.5" />}
label={copy.rename(pet.displayName)}
onClick={() => {
setRenameValue(pet.displayName)
setRenameTarget(pet)
}}
/>
)}
{pet.generated && (
<PetAction
icon={<Download className="size-3.5" />}
label={copy.exportPet(pet.displayName)}
onClick={() => exportPet(pet.slug)}
/>
)}
{pet.installed && (
// Generated pets have no remote source — deletion is
// permanent, so confirm; petdex pets just uninstall.
<PetAction
danger
icon={<Trash2 className="size-3.5" />}
label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)}
onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))}
/>
)}
</div>
)}
</div>
)
@ -226,6 +280,80 @@ export function PetSettings() {
/>
)}
</div>
<ConfirmDialog
confirmLabel={copy.deleteConfirm}
description={copy.deleteBody}
destructive
onClose={() => setConfirmDelete(null)}
onConfirm={async () => {
if (confirmDelete) {
const ok = await removePetAction(requestGateway, confirmDelete.slug, copy.uninstallFailed(confirmDelete.slug))
if (!ok) {
throw new Error(copy.uninstallFailed(confirmDelete.slug))
}
triggerHaptic('crisp')
}
}}
open={confirmDelete !== null}
title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''}
/>
<Dialog onOpenChange={open => !open && setRenameTarget(null)} open={renameTarget !== null}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{copy.renameTitle}</DialogTitle>
</DialogHeader>
<Input
autoFocus
onChange={event => setRenameValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
saveRename()
}
}}
placeholder={copy.renamePlaceholder}
value={renameValue}
/>
<DialogFooter>
<Button onClick={() => setRenameTarget(null)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={!renameValue.trim()} onClick={saveRename}>
{copy.renameSave}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/** A single hover-revealed icon action on a pet card (rename / export / delete). */
function PetAction({
danger,
icon,
label,
onClick
}: {
danger?: boolean
icon: ReactNode
label: string
onClick: () => void
}) {
return (
<button
aria-label={label}
className={cn(
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
)}
onClick={onClick}
title={label}
type="button"
>
{icon}
</button>
)
}

View file

@ -22,6 +22,25 @@ interface Point {
y: number
}
interface PetInfoMeta {
enabled: boolean
slug?: string
displayName?: string
scale?: number
spritesheetRevision?: string
}
function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean {
return (
info.enabled &&
Boolean(info.spritesheetBase64) &&
info.slug === meta.slug &&
info.displayName === meta.displayName &&
info.scale === meta.scale &&
info.spritesheetRevision === meta.spritesheetRevision
)
}
function clampToViewport({ x, y }: Point): Point {
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
@ -63,12 +82,15 @@ function loadPosition(): Point {
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
* while no pet is active and the mascot pops in within a few seconds no
* reload, no CLI. Once a pet is live we stop polling.
* reload, no CLI. Once a pet is live we still refresh more slowly so generated
* pets rewritten on disk (or renamed/rebuilt by the hatch flow) repaint without
* restarting the app.
*
* Promotion to a separate frameless OS-level window is a follow-up the
* sprite + state logic here is reused as-is, only the host changes.
*/
const PET_POLL_MS = 3000
const PET_ACTIVE_REFRESH_MS = 15000
export function FloatingPet() {
const { requestGateway } = useGatewayRequest()
@ -93,11 +115,12 @@ export function FloatingPet() {
// state is only committed on release.
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
// Fetch pet.info on connect, then keep polling while no pet is active so an
// in-app `/pet <slug>` shows up live. Stops polling once a pet is enabled.
// Fetch pet.info on connect. Poll quickly while inactive so an in-app
// `/pet <slug>` appears, then slowly while active so regenerated spritesheets
// and row-count metadata replace the cached base64 payload.
const active = info.enabled && Boolean(info.spritesheetBase64)
useEffect(() => {
if (gatewayState !== 'open' || active) {
if (gatewayState !== 'open') {
return
}
@ -105,9 +128,39 @@ export function FloatingPet() {
const pull = async () => {
try {
if (active) {
try {
const meta = await requestGateway<PetInfoMeta>('pet.info.meta', { profile: petProfile() })
if (cancelled || !meta) {
return
}
if (!meta.enabled) {
setPetInfo({ enabled: false })
return
}
if (samePetRevision($petInfo.get(), meta)) {
return
}
} catch {
// Older gateways may not have pet.info.meta yet; fall back to pet.info.
}
}
const next = await requestGateway<PetInfo>('pet.info', { profile: petProfile() })
if (!cancelled && next) {
const current = $petInfo.get()
if (
next.enabled &&
current.enabled &&
current.slug === next.slug &&
current.displayName === next.displayName &&
current.scale === next.scale &&
current.spritesheetRevision &&
current.spritesheetRevision === next.spritesheetRevision
) {
return
}
setPetInfo(next)
}
} catch {
@ -116,10 +169,12 @@ export function FloatingPet() {
}
void pull()
const timer = window.setInterval(() => void pull(), PET_POLL_MS)
const timer = window.setInterval(() => void pull(), active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS)
window.addEventListener('focus', pull)
return () => {
cancelled = true
window.removeEventListener('focus', pull)
window.clearInterval(timer)
}
}, [gatewayState, active, requestGateway])

View file

@ -0,0 +1,68 @@
/**
* Egg-hatch visuals for the pet generation flow (Cmd-K Pets Generate).
*
* `PetEggHatch` is the incubation beat shown while `pet.hatch` runs: a wobbling
* egg that reads as "something is about to hatch" instead of a bare spinner. The
* reveal celebration is the canvas `PetStarShower`. Motion is disabled under
* `prefers-reduced-motion`.
*/
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
interface PetEggHatchProps {
subtitle?: string
onCancel?: () => void
cancelLabel?: string
}
/**
* Thin progress bar. Determinate when given done/total (hatch rows stream one by
* one, so a real percentage is meaningful); indeterminate otherwise (drafts
* return together, so a count would just snap 0100).
*/
export function PetProgress({ done, total }: { done?: number; total?: number }) {
const determinate = typeof done === 'number' && typeof total === 'number' && total > 0
const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={determinate ? pct : undefined}
className="pet-progress"
role="progressbar"
>
{determinate ? (
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
) : (
<div className="pet-progress__indeterminate" />
)}
</div>
)
}
export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) {
return (
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex flex-col items-center">
<PixelEggSprite mode="bounce" size={88} />
{/* The egg sprite has transparent canvas below the art, so pull the
shadow up ~a fifth of its size to sit at the egg's base. */}
<span className="pet-egg-shadow" style={{ marginTop: '-0.55rem' }} />
</div>
{subtitle && (
<p className="shimmer shimmer-color-primary whitespace-nowrap text-center text-[length:var(--conversation-caption-font-size)] leading-snug text-(--ui-text-tertiary)">
{subtitle}
</p>
)}
{onCancel && (
<Button onClick={onCancel} size="xs" variant="text">
{cancelLabel ?? 'Cancel'}
</Button>
)}
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -32,10 +32,33 @@ const STATE_ALIASES: Record<PetState, string[]> = {
waiting: ['waiting']
}
const ROW_TO_STATE: Record<string, PetState> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
failed: 'failed',
review: 'review',
waiting: 'waiting'
}
interface PetSpriteProps {
info: PetInfo
/** On-screen scale multiplier applied on top of the pet's native scale. */
zoom?: number
/**
* Force a specific animation state instead of reading the live `$petState`.
* Used by the generate-flow preview to showcase every row without driving (or
* being driven by) the real agent activity that moves the floating mascot.
*/
stateOverride?: PetState
/** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */
rowOverride?: string
}
/**
@ -49,14 +72,26 @@ interface PetSpriteProps {
* with `memo`, this component effectively never re-renders after mount until
* the pet itself changes.
*/
function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<PetState>($petState.get())
const overrideRef = useRef<PetState | undefined>(stateOverride)
const rowOverrideRef = useRef<string | undefined>(rowOverride)
// Keep the override current without re-running the RAF setup effect.
useEffect(() => {
overrideRef.current = stateOverride
}, [stateOverride])
useEffect(() => {
rowOverrideRef.current = rowOverride
}, [rowOverride])
const frameW = info.frameW ?? DEFAULT_FRAME_W
const frameH = info.frameH ?? DEFAULT_FRAME_H
const frames = info.framesPerState ?? DEFAULT_FRAMES
const framesByState = info.framesByState
const framesByRow = info.framesByRow
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
@ -100,6 +135,8 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
let lastStep = performance.now()
let drawnFrame = -1
let drawnRow = -1
let activeRow = -1
let activeCount = -1
const rowIndexForState = (s: PetState): number => {
for (const key of STATE_ALIASES[s] ?? [s]) {
@ -116,6 +153,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
// than flashing blank padding.
const resolve = (s: PetState): { row: number; count: number } => {
const real = framesByState?.[s] ?? frames
if (real > 0) {
return { row: rowIndexForState(s), count: real }
}
@ -123,8 +161,28 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
}
const resolveRow = (rowName: string): { row: number; count: number } => {
const row = rows.indexOf(rowName)
const state = ROW_TO_STATE[rowName]
const count = Math.max(
1,
framesByRow?.[rowName] ?? framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames
)
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
}
const render = (now: number) => {
const { row, count } = resolve(stateRef.current)
const forcedRow = rowOverrideRef.current
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
if (row !== activeRow || count !== activeCount) {
activeRow = row
activeCount = count
frame = 0
lastStep = now
drawnFrame = -1
}
// Per-state step keeps every state's loop ~loopMs even when frame counts
// differ; counts vary per row so derive the cadence here, not once.
const stepMs = loopMs / count
@ -158,7 +216,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
cancelAnimationFrame(raf)
unsubState()
}
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
return (
<canvas

View file

@ -0,0 +1,204 @@
import { useEffect, useRef } from 'react'
/**
* Canvas hatch celebration layered over a freshly revealed pet: a one-shot
* sunburst of rotating god-rays, a fast radial star burst (confetti physics
* velocity + decay + gravity + spin), and a light trickle of rising twinkle
* motes. Additive (`lighter`) so the sparkles bloom. No glow-halo flash.
*
* Sized to its container (absolute inset-0, pointer-events: none) and disabled
* under `prefers-reduced-motion`.
*/
const GOLD = '#ffd76a'
const BURST = 15
const VELOCITY = 500
const DECAY = 0.9
const GRAVITY = 90
const RAY_COUNT = 24
const GOLD_MIX = 0.6
const MOTE_MS = 333 // ~3 / sec
interface Star {
x: number
y: number
vx: number
vy: number
size: number
rot: number
vrot: number
phase: number
twinkle: number
life: number
ttl: number
color: string
rise: boolean
}
function readAccent(el: HTMLElement): string {
return getComputedStyle(el).getPropertyValue('--ui-accent').trim() || '#9aa0ff'
}
function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color: string): void {
ctx.rotate(rot)
ctx.fillStyle = color
for (const [rx, ry] of [
[size, size * 0.26],
[size * 0.26, size]
]) {
ctx.beginPath()
ctx.moveTo(0, -ry)
ctx.lineTo(rx, 0)
ctx.lineTo(0, ry)
ctx.lineTo(-rx, 0)
ctx.closePath()
ctx.fill()
}
const core = Math.max(1, Math.round(size * 0.4))
ctx.fillStyle = '#fff'
ctx.fillRect(-core / 2, -core / 2, core, core)
}
export function PetStarShower() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
const parent = canvas?.parentElement
if (!canvas || !ctx || !parent) {
return
}
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
return
}
const accent = readAccent(canvas)
const dpr = Math.min(window.devicePixelRatio || 1, 3)
let w = 0
let h = 0
let cx = 0
let cy = 0
const resize = () => {
const r = parent.getBoundingClientRect()
w = r.width
h = r.height
cx = w / 2
cy = h * 0.54
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(parent)
const pick = () => (Math.random() < GOLD_MIX ? GOLD : Math.random() < 0.5 ? accent : '#ffffff')
const stars: Star[] = []
for (let i = 0; i < BURST; i++) {
const a = Math.random() * Math.PI * 2
const sp = VELOCITY * (0.4 + Math.random() * 0.7)
stars.push({
x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
size: 3.5 + Math.random() * 5.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 8,
phase: 0, twinkle: 0, life: 0, ttl: 0.8 + Math.random() * 0.7, color: pick(), rise: false
})
}
const rays = { life: 0, ttl: 0.9, rot: Math.random() * 6.28 }
let raf = 0
let last = performance.now()
let acc = 0
let raysAlive = true
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
const ms = now - last
last = now
const dt = Math.min(0.05, ms / 1000)
const decay = Math.pow(DECAY, dt * 60)
acc += ms
if (acc >= MOTE_MS && stars.length < 40) {
acc = 0
stars.push({
x: cx + (Math.random() - 0.5) * w * 0.85, y: cy + Math.random() * h * 0.25,
vx: (Math.random() - 0.5) * 14, vy: -(14 + Math.random() * 26),
size: 2.5 + Math.random() * 3.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 2,
phase: Math.random() * 6.28, twinkle: 5 + Math.random() * 4, life: 0, ttl: 1.2 + Math.random(),
color: pick(), rise: true
})
}
ctx.clearRect(0, 0, w, h)
ctx.globalCompositeOperation = 'lighter'
// Sunburst god-rays — one-shot bloom + slow spin.
if (raysAlive) {
rays.life += dt
rays.rot += dt * 0.6
const t = rays.life / rays.ttl
if (t >= 1) {
raysAlive = false
} else {
const len = Math.max(w, h) * 0.62 * (1 - (1 - t) ** 2)
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(rays.rot)
for (let i = 0; i < RAY_COUNT; i++) {
ctx.rotate((Math.PI * 2) / RAY_COUNT)
const a = (1 - t) * 0.3 * (i % 2 ? 0.65 : 1)
const wd = len * 0.05
const g = ctx.createLinearGradient(0, 0, 0, -len)
g.addColorStop(0, `rgba(255,255,255,${a})`)
g.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = g
ctx.beginPath()
ctx.moveTo(-wd, 0)
ctx.lineTo(wd, 0)
ctx.lineTo(0, -len)
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
}
for (let i = stars.length - 1; i >= 0; i--) {
const s = stars[i]
s.life += dt
if (s.rise) {
s.vy += 7 * dt
s.phase += s.twinkle * dt
} else {
s.vx *= decay
s.vy = s.vy * decay + GRAVITY * dt
}
s.x += s.vx * dt
s.y += s.vy * dt
s.rot += s.vrot * dt
if (s.life >= s.ttl || s.y < -12) {
stars.splice(i, 1)
continue
}
const fade = s.rise
? Math.min(1, s.life * 5, (s.ttl - s.life) * 3) * (0.45 + 0.55 * Math.abs(Math.sin(s.phase)))
: Math.min(1, (s.ttl - s.life) * 3)
ctx.save()
ctx.globalAlpha = fade
ctx.translate(Math.round(s.x), Math.round(s.y))
sparkle(ctx, s.size, s.rot, s.color)
ctx.restore()
}
ctx.globalCompositeOperation = 'source-over'
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [])
return <canvas className="pointer-events-none absolute inset-0 z-10 h-full w-full" ref={canvasRef} />
}

View file

@ -0,0 +1,234 @@
import { type CSSProperties, useEffect, useRef } from 'react'
import eggSheetUrl from './pet-egg-sheet.png'
/**
* Animated pixel egg the iamcrog "bouncing hatching egg" 12-frame sheet
* (32×32 cells, stacked vertically), drawn to a canvas and recolored to a warm
* white/creme shell.
*
* The sheet's shell is mid-gray, so a plain multiply only darkens it (still
* gray). Instead we remap each pixel's luminance through a creme ramp via a 256-
* entry LUT: near-black stays a warm dark outline, midtones become creme shadow,
* highlights go near-white. Done on a 32×32 offscreen then nearest-neighbor
* scaled up so it stays crisp.
*
* Frames 05 are the intact squash/stretch bounce; 611 are the crack/hatch.
* `mode="bounce"` loops 05 (never shows a crack); `mode="hatch"` plays 611
* once then calls onDone.
*/
const FRAME = 32
const TOTAL_FRAMES = 12
const BOUNCE_FRAMES = 6 // 0..5 — intact egg only; cracks start at frame 6
const HATCH_START = 6 // first crack frame
// Per-frame speed *while* a bounce is playing.
const BOUNCE_MS = 250
const HATCH_MS = 190
// Harvest-Moon idle: the egg rests on frame 0 for a long, randomized gap between
// bounces so it reads as "occasionally stirs", not "constantly animating".
const REST_MIN_MS = 2600
const REST_MAX_MS = 6200
// Creme ramp endpoints: warm dark outline → creme shadow → near-white highlight.
const OUTLINE: [number, number, number] = [78, 66, 58]
const SHADOW: [number, number, number] = [214, 198, 168]
const HIGHLIGHT: [number, number, number] = [253, 249, 238]
const OUTLINE_CUTOFF = 46
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
// Precompute the luminance→creme mapping once (shared across every egg). Below
// the cutoff it's the flat outline; above, a SHADOW→HIGHLIGHT ramp.
const CREME_LUT = (() => {
const lut = new Uint8ClampedArray(256 * 3)
for (let g = 0; g < 256; g++) {
const dark = g < OUTLINE_CUTOFF
const t = dark ? 0 : (g - OUTLINE_CUTOFF) / (255 - OUTLINE_CUTOFF)
const from = dark ? OUTLINE : SHADOW
const to = dark ? OUTLINE : HIGHLIGHT
lut.set([lerp(from[0], to[0], t), lerp(from[1], to[1], t), lerp(from[2], to[2], t)], g * 3)
}
return lut
})()
let _sheet: HTMLImageElement | null = null
let _sheetLoading: Promise<HTMLImageElement> | null = null
function loadSheet(): Promise<HTMLImageElement> {
if (_sheet?.complete) {
return Promise.resolve(_sheet)
}
if (!_sheetLoading) {
_sheetLoading = new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
_sheet = img
resolve(img)
}
img.onerror = reject
img.src = eggSheetUrl
})
}
return _sheetLoading
}
interface PixelEggSpriteProps {
mode: 'bounce' | 'hatch'
/** On-screen size (px, square). */
size: number
/**
* Slot position in a grid of eggs. Used to deterministically spread each egg's
* first bounce across the rest window so neighbours never stir together (random
* jitter alone can collide with only a handful of eggs).
*/
index?: number
className?: string
style?: CSSProperties
/** Fired once when a `hatch` run reaches the final frame. */
onDone?: () => void
}
export function PixelEggSprite({ mode, size, index = 0, className, style, onDone }: PixelEggSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const onDoneRef = useRef(onDone)
onDoneRef.current = onDone
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
const dpr = Math.min(window.devicePixelRatio || 1, 3)
const dim = Math.round(size * dpr)
canvas.width = dim
canvas.height = dim
const lastFrame = TOTAL_FRAMES - 1
// Mild per-egg speed jitter so bounces don't feel mechanical.
const frameMs = (mode === 'bounce' ? BOUNCE_MS : HATCH_MS) * (0.85 + Math.random() * 0.3)
const restMs = () => REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS)
// First bounce: a deterministic per-slot slice of the rest window (so two
// eggs never start together) plus a little random jitter on top.
const firstDelay = ((index % 4) + 1) * (REST_MIN_MS / 4) + Math.random() * REST_MIN_MS
// 32×32 offscreen we recolor per frame, then scale up nearest-neighbor.
const off = document.createElement('canvas')
off.width = FRAME
off.height = FRAME
const offCtx = off.getContext('2d', { willReadFrequently: true })
let sheet: HTMLImageElement | null = null
void loadSheet().then(img => {
sheet = img
})
const render = (frame: number) => {
if (!sheet || !offCtx) {
return
}
offCtx.clearRect(0, 0, FRAME, FRAME)
offCtx.imageSmoothingEnabled = false
offCtx.drawImage(sheet, 0, frame * FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME)
const img = offCtx.getImageData(0, 0, FRAME, FRAME)
const d = img.data
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] === 0) {
continue
}
const g = d[i] * 3
d[i] = CREME_LUT[g]
d[i + 1] = CREME_LUT[g + 1]
d[i + 2] = CREME_LUT[g + 2]
}
offCtx.putImageData(img, 0, 0)
ctx.clearRect(0, 0, dim, dim)
ctx.imageSmoothingEnabled = false
ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim)
}
let raf = 0
let step = 0
let finished = false
// bounce: `nextAt` is when the next thing happens — the next bounce frame, or
// the start of a new bounce after a rest. hatch: `lastHatch` time-gates frames.
let resting = mode === 'bounce'
let nextAt = 0
let lastHatch = 0
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
if (!sheet) {
return
}
if (mode === 'hatch') {
if (!lastHatch) {
lastHatch = now
render(HATCH_START)
return
}
if (now - lastHatch < frameMs) {
return
}
lastHatch = now
const frame = Math.min(HATCH_START + step, lastFrame)
render(frame)
if (frame >= lastFrame) {
if (!finished) {
finished = true
onDoneRef.current?.()
}
return // hold the cracked-open last frame
}
step += 1
return
}
// bounce: rest on frame 0, play 0..5, then rest again.
if (!nextAt) {
render(0)
nextAt = now + firstDelay // staggered first bounce, per slot
return
}
if (now < nextAt) {
return
}
if (resting) {
resting = false
step = 0
render(0)
nextAt = now + frameMs
return
}
step += 1
if (step >= BOUNCE_FRAMES) {
resting = true
render(0)
nextAt = now + restMs()
return
}
render(step)
nextAt = now + frameMs
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
}
}, [mode, size, index])
return (
<canvas
className={className}
ref={canvasRef}
style={{ width: size, height: size, imageRendering: 'pixelated', ...style }}
/>
)
}

View file

@ -35,16 +35,98 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
)
}
type DialogBannerTone = 'error' | 'warn' | 'info'
// Tinted, edge-to-edge bottom banner per tone. Error/warn keep their semantic
// destructive/primary tokens; info derives from the dialog's own bubble
// background so it reads as part of the themed dialog — lifted 30% toward white
// in light mode, deepened 20% toward black in dark mode.
const DIALOG_BANNER_TONES: Record<DialogBannerTone, string> = {
error: 'bg-destructive/12 text-destructive',
warn: 'bg-primary/12 text-primary',
info: 'bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_30%)] text-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_60%)] dark:bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_20%)] dark:text-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_60%)]'
}
function DialogContent({
className,
children,
showCloseButton = true,
fitContent = false,
banner,
bannerTone = 'error',
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
// Size the dialog to its content (capped at the viewport) instead of the
// default fixed `max-w-lg`. For content that has no intrinsic width (grids,
// full-width inputs) pair it with a `min-w-*` in `className`.
fitContent?: boolean
// A dialog-level notice rendered as a banner flush to the bottom edge (tinted,
// inherited bottom radius) so it reads as part of the dialog, not a floating
// alert. Falsy → no banner. Tone picks the colour.
banner?: React.ReactNode
bannerTone?: DialogBannerTone
}) {
const { t } = useI18n()
const widthClass = fitContent ? 'w-auto max-w-[92vw]' : 'w-full max-w-lg'
const closeButton = showCloseButton ? (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
) : null
// With a banner, the border can't live on the scroll/clip box (it would draw a
// line around the banner too). The white body keeps its own bottom radius and
// sits over the tinted footer; the outer shell only clips the banner to the
// dialog's rounded bottom edge.
if (banner) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className,
// Callers often pass `gap-*` for the no-banner grid layout — suppress
// it here so the banner can tuck under the body's rounded bottom edge.
'gap-0'
)}
data-slot="dialog-content"
{...props}
>
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
</div>
<div
className={cn(
// Overlap by one corner radius so the white bottom lobes read clearly
// over the tint instead of meeting it on a straight seam.
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
DIALOG_BANNER_TONES[bannerTone]
)}
data-slot="dialog-banner"
role={bannerTone === 'error' ? 'alert' : 'status'}
>
{banner}
</div>
{closeButton}
</DialogPrimitive.Content>
</DialogPortal>
)
}
return (
<DialogPortal>
<DialogOverlay />
@ -53,26 +135,15 @@ function DialogContent({
// Cap height at 85vh and let long content scroll inside the dialog
// instead of overflowing off-screen (long cron titles, tool detail
// dumps, etc.). Individual dialogs can still override via className.
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className
)}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
)}
{closeButton}
</DialogPrimitive.Content>
</DialogPortal>
)

View file

@ -0,0 +1,62 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface GenerateButtonProps extends Omit<React.ComponentProps<typeof Button>, 'children' | 'onClick'> {
/** True while a generation is in flight. */
generating: boolean
/** Start a generation. */
onGenerate: () => void
/** Cancel an in-flight generation. When omitted, the button just spins while
* generating (for one-shots that can't be cancelled). */
onCancel?: () => void
/** Tooltip + aria label at rest (and while generating if no `generatingLabel`). */
label: string
/** Tooltip while generating (e.g. "Stop" with cancel, "Generating…" without). */
generatingLabel?: string
iconSize?: number | string
}
/** The sparkle "generate with AI" affordance icon + tooltip, shared by the
* commit-message box and the new-project idea field so they stay one pattern.
* Sparkle click generates; with `onCancel`, a Stop square appears mid-run;
* without it, the sparkle spins until the one-shot resolves. */
export function GenerateButton({
generating,
onGenerate,
onCancel,
label,
generatingLabel,
disabled,
iconSize = 12,
className,
...rest
}: GenerateButtonProps) {
const tip = generating ? (generatingLabel ?? label) : label
const cancellable = generating && !!onCancel
return (
<Tip label={tip}>
<Button
aria-label={tip}
className={cn('text-muted-foreground/80 hover:text-foreground', className)}
disabled={generating ? !onCancel : disabled}
onClick={cancellable ? onCancel : onGenerate}
size="icon-xs"
type="button"
variant="ghost"
{...rest}
>
{cancellable ? (
<Square className="fill-current" size={11} />
) : (
<Codicon name="sparkle" size={iconSize} spinning={generating} />
)}
</Button>
</Tip>
)
}

View file

@ -391,11 +391,23 @@ export const en: Translations = {
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
noMatch: query => `No pets match "${query}".`,
installedTag: 'installed',
generatedTag: 'Generated',
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
uninstall: name => `Uninstall ${name}`,
delete: name => `Delete ${name}`,
deleteTitle: name => `Delete ${name}?`,
deleteBody: "This permanently deletes the pet — it can't be reinstalled.",
deleteConfirm: 'Delete',
rename: name => `Rename ${name}`,
renameTitle: 'Rename pet',
renamePlaceholder: 'Name your pet',
renameSave: 'Save',
exportPet: name => `Export ${name}`,
adoptFailed: slug => `Could not adopt ${slug}`,
uninstallFailed: slug => `Could not uninstall ${slug}`,
renameFailed: slug => `Could not rename ${slug}`,
exportFailed: slug => `Could not export ${slug}`,
noneAvailable: 'No pets available to turn on right now.',
turnOnFailed: 'Could not turn the pet on.',
turnOffFailed: 'Could not turn the pet off.'
@ -762,10 +774,36 @@ export const en: Translations = {
turnOff: 'Turn off',
turnOn: 'Turn on',
installed: 'Installed',
generatedTag: 'Generated',
adoptFailed: 'Could not adopt that pet.',
toggleFailed: 'Could not toggle the pet.',
noneAvailable: 'No pets available — pick one below to install.'
},
generatePet: {
title: 'Generate a pet',
placeholder: 'Describe a pet to generate…',
promptHint: 'Type a description, then press Enter to draft four looks.',
readyHint: 'Press Enter to draft four looks from your description.',
generate: 'Generate',
generating: 'Generating…',
retry: 'Retry',
hatch: 'Hatch',
spawning: 'Spawning…',
hatching: 'Hatching your pet…',
hatchingSub: 'Bringing it to life…',
hatched: 'It hatched!',
hatchRow: (_state, done, total) => `Sketching frame ${done} of ${total}`,
hatchComposing: 'Piecing it together…',
hatchSaving: 'Almost there…',
namePlaceholder: 'Name your pet',
staleBackend: 'Update Hermes to generate pets.',
backgroundHint: 'You can close this — Hermes will notify you when its done.',
genericError: 'Generation failed — try again or pick a suggestion.',
referenceImageTooLarge: 'Reference image is too large. Use one under 16 MB.',
referenceImageInvalid: 'Could not read that reference image. Try a PNG, JPG, WebP, or GIF.',
adopt: 'Adopt',
startOver: 'Start over'
},
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',

View file

@ -305,11 +305,23 @@ export const ja = defineLocale({
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
noMatch: query => `${query}」に一致するペットがありません。`,
installedTag: 'インストール済み',
generatedTag: '生成',
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
count: n => `${n} 件のペット。`,
uninstall: name => `${name} をアンインストール`,
delete: name => `${name} を削除`,
deleteTitle: name => `${name} を削除しますか?`,
deleteBody: 'ペットを完全に削除します。再インストールはできません。',
deleteConfirm: '削除',
rename: name => `${name} の名前を変更`,
renameTitle: 'ペットの名前を変更',
renamePlaceholder: 'ペットに名前を付ける',
renameSave: '保存',
exportPet: name => `${name} をエクスポート`,
adoptFailed: slug => `${slug} を採用できませんでした`,
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
renameFailed: slug => `${slug} の名前を変更できませんでした`,
exportFailed: slug => `${slug} をエクスポートできませんでした`,
noneAvailable: 'オンにできるペットがありません。',
turnOnFailed: 'ペットをオンにできませんでした。',
turnOffFailed: 'ペットをオフにできませんでした。'
@ -881,10 +893,36 @@ export const ja = defineLocale({
turnOff: 'オフ',
turnOn: 'オン',
installed: 'インストール済み',
generatedTag: '生成',
adoptFailed: 'ペットを採用できませんでした。',
toggleFailed: 'ペットを切り替えできませんでした。',
noneAvailable: '利用可能なペットがありません。'
},
generatePet: {
title: 'ペットを生成',
placeholder: '生成するペットを説明…',
promptHint: '説明を入力して Enter を押すと、4 つの見た目を生成します。',
readyHint: 'Enter を押すと、説明から 4 つの見た目を生成します。',
generate: '生成',
generating: '生成中…',
retry: '再試行',
hatch: '孵化',
spawning: 'スポーン中…',
hatching: 'ペットを孵化しています…',
hatchingSub: '命を吹き込んでいます…',
hatched: '孵化しました!',
hatchRow: (_state, done, total) => `フレームを描画中… ${done}/${total}`,
hatchComposing: 'まとめています…',
hatchSaving: 'もうすぐです…',
namePlaceholder: 'ペットに名前を付ける',
staleBackend: 'ペットを生成するには Hermes を更新してください。',
backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。',
genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。',
referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。',
referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。',
adopt: '迎え入れる',
startOver: 'やり直す'
},
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',

View file

@ -285,11 +285,23 @@ export interface Translations {
unreachable: string
noMatch: (query: string) => string
installedTag: string
generatedTag: string
countCapped: (cap: number, total: number) => string
count: (n: number) => string
uninstall: (name: string) => string
delete: (name: string) => string
deleteTitle: (name: string) => string
deleteBody: string
deleteConfirm: string
rename: (name: string) => string
renameTitle: string
renamePlaceholder: string
renameSave: string
exportPet: (name: string) => string
adoptFailed: (slug: string) => string
uninstallFailed: (slug: string) => string
renameFailed: (slug: string) => string
exportFailed: (slug: string) => string
noneAvailable: string
turnOnFailed: string
turnOffFailed: string
@ -636,10 +648,36 @@ export interface Translations {
turnOff: string
turnOn: string
installed: string
generatedTag: string
adoptFailed: string
toggleFailed: string
noneAvailable: string
}
generatePet: {
title: string
placeholder: string
promptHint: string
readyHint: string
generate: string
generating: string
retry: string
hatch: string
spawning: string
hatching: string
hatchingSub: string
hatched: string
hatchRow: (state: string, done: number, total: number) => string
hatchComposing: string
hatchSaving: string
namePlaceholder: string
staleBackend: string
backgroundHint: string
genericError: string
referenceImageTooLarge: string
referenceImageInvalid: string
adopt: string
startOver: string
}
installTheme: {
title: string
placeholder: string

View file

@ -292,11 +292,23 @@ export const zhHant = defineLocale({
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
noMatch: query => `沒有符合「${query}」的寵物。`,
installedTag: '已安裝',
generatedTag: '生成',
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
count: n => `${n} 個寵物。`,
uninstall: name => `解除安裝 ${name}`,
delete: name => `刪除 ${name}`,
deleteTitle: name => `刪除 ${name}`,
deleteBody: '此操作會永久刪除寵物,且無法重新安裝。',
deleteConfirm: '刪除',
rename: name => `重新命名 ${name}`,
renameTitle: '重新命名寵物',
renamePlaceholder: '為寵物取個名字',
renameSave: '儲存',
exportPet: name => `匯出 ${name}`,
adoptFailed: slug => `無法領養 ${slug}`,
uninstallFailed: slug => `無法解除安裝 ${slug}`,
renameFailed: slug => `無法重新命名 ${slug}`,
exportFailed: slug => `無法匯出 ${slug}`,
noneAvailable: '目前沒有可開啟的寵物。',
turnOnFailed: '無法開啟寵物。',
turnOffFailed: '無法關閉寵物。'
@ -851,10 +863,36 @@ export const zhHant = defineLocale({
turnOff: '關閉',
turnOn: '開啟',
installed: '已安裝',
generatedTag: '生成',
adoptFailed: '無法領養該寵物。',
toggleFailed: '無法切換寵物顯示。',
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
},
generatePet: {
title: '生成寵物',
placeholder: '描述要生成的寵物……',
promptHint: '輸入描述,然後按 Enter 生成四種造型。',
readyHint: '按 Enter 依描述生成四種造型。',
generate: '生成',
generating: '生成中……',
retry: '重試',
hatch: '孵化',
spawning: '召喚中……',
hatching: '正在孵化你的寵物……',
hatchingSub: '正在注入生命……',
hatched: '孵化成功!',
hatchRow: (_state, done, total) => `正在繪製畫面…… ${done}/${total}`,
hatchComposing: '正在拼合……',
hatchSaving: '快好了……',
namePlaceholder: '為寵物命名',
staleBackend: '請更新 Hermes 以生成寵物。',
backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。',
genericError: '生成失敗——請重試或選一個建議。',
referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。',
referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。',
adopt: '領養',
startOver: '重新開始'
},
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',

View file

@ -381,11 +381,23 @@ export const zh: Translations = {
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
noMatch: query => `没有匹配「${query}」的宠物。`,
installedTag: '已安装',
generatedTag: '生成',
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
count: n => `${n} 个宠物。`,
uninstall: name => `卸载 ${name}`,
delete: name => `删除 ${name}`,
deleteTitle: name => `删除 ${name}`,
deleteBody: '此操作会永久删除宠物,且无法重新安装。',
deleteConfirm: '删除',
rename: name => `重命名 ${name}`,
renameTitle: '重命名宠物',
renamePlaceholder: '给宠物起个名字',
renameSave: '保存',
exportPet: name => `导出 ${name}`,
adoptFailed: slug => `无法领养 ${slug}`,
uninstallFailed: slug => `无法卸载 ${slug}`,
renameFailed: slug => `无法重命名 ${slug}`,
exportFailed: slug => `无法导出 ${slug}`,
noneAvailable: '当前没有可开启的宠物。',
turnOnFailed: '无法开启宠物。',
turnOffFailed: '无法关闭宠物。'
@ -949,10 +961,36 @@ export const zh: Translations = {
turnOff: '关闭',
turnOn: '开启',
installed: '已安装',
generatedTag: '生成',
adoptFailed: '无法领养该宠物。',
toggleFailed: '无法切换宠物显示。',
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
},
generatePet: {
title: '生成宠物',
placeholder: '描述要生成的宠物……',
promptHint: '输入描述,然后按 Enter 生成四种造型。',
readyHint: '按 Enter 根据描述生成四种造型。',
generate: '生成',
generating: '生成中……',
retry: '重试',
hatch: '孵化',
spawning: '召唤中……',
hatching: '正在孵化你的宠物……',
hatchingSub: '正在注入生命……',
hatched: '孵化成功!',
hatchRow: (_state, done, total) => `正在绘制画面…… ${done}/${total}`,
hatchComposing: '正在拼合……',
hatchSaving: '马上就好……',
namePlaceholder: '给宠物起个名字',
staleBackend: '请更新 Hermes 以生成宠物。',
backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。',
genericError: '生成失败——请重试或选择一个建议。',
referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。',
referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。',
adopt: '领养',
startOver: '重新开始'
},
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',

View file

@ -32,6 +32,7 @@ export type DesktopActionId =
| 'branch'
| 'browser'
| 'handoff'
| 'hatch'
| 'help'
| 'new'
| 'pet'
@ -130,6 +131,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
{ name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true },
{ name: '/hatch', description: 'Generate a new pet (opens the pet generator)', aliases: ['/generate-pet'], surface: action('hatch') },
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },

View file

@ -29,6 +29,7 @@ import {
IconCopy as CopyIcon,
IconCpu as Cpu,
IconDownload as Download,
IconEgg as Egg,
IconExternalLink as ExternalLink,
IconEye as Eye,
IconEyeOff as EyeOff,
@ -133,6 +134,7 @@ export {
CopyIcon,
Cpu,
Download,
Egg,
ExternalLink,
Eye,
EyeOff,

View file

@ -27,6 +27,8 @@ export interface GalleryPet {
spritesheetUrl?: string
/** petdex's hand-picked set — used only to rank "popular" pets first. */
curated?: boolean
/** Hatched locally by the user (createdBy=generator) — badged + ranked first. */
generated?: boolean
}
export interface PetGallery {
@ -39,7 +41,12 @@ export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest` passed in so the
* store reuses the hook's reconnect/reauth handling instead of duplicating it. */
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export type GatewayRequest = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
/** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active
* profile (the gateway no-ops it for the launch profile). One chokepoint so no
@ -115,16 +122,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
$petGalleryStatus.set('loading')
}
let localOk = false
try {
const [next, info] = await Promise.all([
petRpc<PetGallery>(request, 'pet.gallery'),
// Phase 1: local pets only — instant, never blocks on the remote petdex
// manifest. The user's own/generated pets render right away.
const [local, info] = await Promise.all([
petRpc<PetGallery>(request, 'pet.gallery', { localOnly: true }),
petRpc<PetInfo>(request, 'pet.info')
])
if (next) {
$petGallery.set(next)
if (local) {
$petGallery.set(local)
$petGalleryStatus.set('ready')
$petGalleryError.set(null)
localOk = true
}
if (info) {
@ -142,6 +154,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
} finally {
galleryLoad = null
}
// Phase 2: merge in the full petdex catalog in the background. A slow/failed
// manifest fetch never hides the local pets shown in phase 1.
if (localOk) {
try {
const full = await petRpc<PetGallery>(request, 'pet.gallery')
if (full) {
$petGallery.set(full)
$petGalleryStatus.set('ready')
}
} catch {
// Keep the local-only gallery; the petdex catalog just stays unmerged.
}
}
})()
return galleryLoad
@ -161,6 +188,24 @@ async function syncInfo(request: GatewayRequest): Promise<void> {
}
}
/**
* Reflect a just-adopted *local* pet without any network: optimistically mark it
* active/installed in the cached gallery and repaint the live mascot via the
* local `pet.info`. Adopting a generated pet is a disk+config op it must never
* wait on `pet.gallery`'s remote petdex manifest fetch.
*/
export async function applyAdoptedPet(request: GatewayRequest, slug: string, displayName: string): Promise<void> {
patchGallery(gallery => ({
...gallery,
enabled: true,
active: slug,
pets: gallery.pets.some(p => p.slug === slug)
? gallery.pets.map(p => (p.slug === slug ? { ...p, installed: true, displayName } : p))
: [...gallery.pets, { slug, displayName, installed: true, spritesheetUrl: '' }]
}))
await syncInfo(request)
}
/**
* Filter (drop the internal `clawd*` pets + apply a search query) and rank the
* gallery for a picker. Ranking has no popularity data, so it leans on the
@ -175,8 +220,15 @@ export function rankedGalleryPets(gallery: PetGallery | null, query = ''): Galle
const needle = query.trim().toLowerCase()
// User-generated pets first, then the active pet, then installed, then curated.
// Guard every term with a boolean — local-only pets omit curated/generated, and
// `Number(undefined)` is NaN, which poisons the sort (it would sink those pets
// below the render cap and hide them entirely).
const rank = (p: GalleryPet) =>
Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated)
(p.generated ? 8 : 0) +
(gallery.enabled && p.slug === gallery.active ? 4 : 0) +
(p.installed ? 2 : 0) +
(p.curated ? 1 : 0)
return gallery.pets
.filter(
@ -309,14 +361,111 @@ export function setPetScale(request: GatewayRequest, scale: number): void {
}, 200)
}
/** Export a pet as a `.zip` (pet.json + spritesheet) and save it via the browser. */
export async function exportPet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
$petBusy.set(slug)
$petGalleryError.set(null)
try {
const res = await petRpc<{ ok: boolean; filename: string; zipBase64: string }>(request, 'pet.export', { slug })
if (!res?.ok || !res.zipBase64) {
throw new Error(fallback)
}
const bytes = Uint8Array.from(atob(res.zipBase64), c => c.charCodeAt(0))
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = res.filename || `${slug}.zip`
anchor.click()
URL.revokeObjectURL(url)
return true
} catch (e) {
$petGalleryError.set(e instanceof Error ? e.message : fallback)
return false
} finally {
$petBusy.set(null)
}
}
/**
* Rename a pet optimistic. The new name shows instantly (so the dialog can
* close immediately); the RPC runs in the background and the backend also
* realigns the slug/dir, so we reconcile the slug + thumb cache when it returns,
* and roll the name back if it fails.
*/
export function renamePet(request: GatewayRequest, slug: string, name: string, fallback: string): Promise<boolean> {
const trimmed = name.trim()
if (!trimmed) {
return Promise.resolve(false)
}
const prev = $petGallery.get()?.pets.find(p => p.slug === slug)?.displayName ?? ''
// Optimistic: paint the new name now (slug reconciles when the RPC returns).
patchGallery(g => ({
...g,
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: trimmed } : p))
}))
$petGalleryError.set(null)
return (async () => {
try {
const res = await petRpc<{ ok: boolean; slug: string; displayName: string }>(request, 'pet.rename', {
slug,
name: trimmed
})
if (!res?.ok) {
throw new Error(fallback)
}
const newSlug = res.slug || slug
if (newSlug !== slug) {
thumbCache.delete(slug)
patchGallery(g => ({
...g,
active: g.active === slug ? newSlug : g.active,
pets: g.pets
.filter(p => p.slug !== newSlug || p.slug === slug)
.map(p => (p.slug === slug ? { ...p, slug: newSlug, displayName: res.displayName || trimmed } : p))
}))
}
return true
} catch (e) {
// Roll the optimistic name back so the list reflects on-disk truth.
patchGallery(g => ({
...g,
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: prev } : p))
}))
$petGalleryError.set(e instanceof Error ? e.message : fallback)
return false
}
})()
}
/** Uninstall a pet; turns the mascot off if it was the active one. */
export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
return mutate(slug, fallback, request, async () => {
await petRpc(request, 'pet.remove', { slug })
// Evict the by-slug thumb cache so a reused slug doesn't render this pet's
// stale thumbnail (the backend drops its disk thumb in parallel).
thumbCache.delete(slug)
patchGallery(g => ({
...g,
enabled: g.active === slug ? false : g.enabled,
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p))
active: g.active === slug ? '' : g.active,
// Petdex pets can be reinstalled from the manifest, so we just mark them
// uninstalled. Generated / local-only pets have no remote source — once
// deleted they're gone, so drop them from the list entirely.
pets: g.pets.flatMap(p => {
if (p.slug !== slug) {
return [p]
}
return p.generated || !p.spritesheetUrl ? [] : [{ ...p, installed: false }]
})
}))
})
}

View file

@ -0,0 +1,652 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { type PetInfo } from '@/store/pet'
import { applyAdoptedPet, type GatewayRequest } from '@/store/pet-gallery'
import { $activeSessionId } from '@/store/session'
/**
* Feature store for the "generate a pet" flow (Cmd-K Pets Generate).
*
* Three backend steps, mirrored as state here:
* - `pet.generate` produces N cheap base-look *drafts* keyed by a `token`.
* - `pet.hatch` turns the chosen draft into a full animated pet installed but
* NOT active and returns its renderer payload so we can preview all frames.
* - the user then *adopts* (`pet.select`) or *discards* (`pet.remove`) it.
*
* The store owns the draft set, the selected variant, the hatched preview, and
* the busy/error status so the page is a thin view. Retry == regenerate (new
* token). Kept separate from `pet-gallery` because its lifecycle (ephemeral
* drafts + an unadopted preview) is unrelated to the long-lived gallery cache.
*/
// Generation is many grounded image calls — far longer than the default 30s RPC
// timeout. Drafts fan out 4 base looks; hatch fans out ~8 animation rows. Even
// parallelized, a cold provider call is slow, so we give these calls real
// headroom (the bug was "request timed out: pet.generate" on the 30s default).
const GENERATE_TIMEOUT_MS = 240_000
const HATCH_TIMEOUT_MS = 420_000
// Filler words to drop when deriving a default name from a free-text prompt.
const NAME_STOPWORDS = new Set([
'a',
'an',
'and',
'at',
'by',
'cute',
'for',
'from',
'in',
'of',
'on',
'style',
'the',
'to',
'with'
])
/**
* Derive a short, friendly default name from a generation prompt. The prompt
* (e.g. "2d dragon in the style of ragnarok online") is grounding text, not a
* name using it verbatim makes a terrible label + slug. We keep the first few
* meaningful words, title-cased and capped, so a blank adopt still reads well.
* The user can always override on the reveal screen or rename later.
*/
export function cleanPetName(prompt: string): string {
const words = prompt
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.split(/\s+/)
.filter(Boolean)
const meaningful = words.filter(w => !NAME_STOPWORDS.has(w.toLowerCase()))
const picked = (meaningful.length ? meaningful : words).slice(0, 3)
const name = picked
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
.slice(0, 28)
.trim()
return name || 'Pet'
}
export interface PetDraft {
index: number
/** Downscaled PNG data URI preview from the gateway. */
dataUri: string
}
export type PetGenStatus =
| 'idle'
| 'generating'
| 'ready'
| 'hatching'
| 'preview'
| 'adopting'
| 'error'
| 'stale'
/** Live hatch step for the egg screen — which row is being drawn, then compose/save. */
export interface PetHatchStage {
phase: 'row' | 'compose' | 'save'
state?: string
done?: number
total?: number
}
export const $petGenStatus = atom<PetGenStatus>('idle')
export const $petGenStage = atom<PetHatchStage | null>(null)
export const $petGenError = atom<string | null>(null)
// Whether a reference-capable image backend is configured. `null` = not yet
// probed (treat as available so the prompt shows optimistically); the overlay
// re-probes on open and on return from settings.
export const $petGenAvailable = atom<boolean | null>(null)
/** A reference-capable image backend the user can pick for generation. */
export interface PetGenProvider {
name: string
label: string
/** One-line speed/quality tradeoff note. */
note: string
/** Whether this is the backend's default pick (no override needed). */
default: boolean
}
const PROVIDER_KEY = 'hermes.desktop.petgen.provider'
/** Reference-capable providers available to pick (from `pet.generate.status`). */
export const $petGenProviders = atom<PetGenProvider[]>([])
/** The picked provider name; `''` means "use the backend default". Persisted. */
export const $petGenProvider = atom(storedString(PROVIDER_KEY) ?? '')
/** Set (and persist) the pet-gen provider override. `''` clears it. */
export function setPetGenProvider(name: string): void {
$petGenProvider.set(name)
persistString(PROVIDER_KEY, name || null)
}
/** Probe whether generation is possible (a reference-capable backend exists). */
export async function checkPetGenAvailable(request: GatewayRequest): Promise<void> {
try {
const res = await request<{ available: boolean; providers?: PetGenProvider[] }>('pet.generate.status')
$petGenAvailable.set(Boolean(res?.available))
const providers = res?.providers ?? []
$petGenProviders.set(providers)
// Drop a stale pick if that backend is no longer configured.
const picked = $petGenProvider.get()
if (picked && !providers.some(p => p.name === picked)) {
setPetGenProvider('')
}
} catch {
// Unknown (old backend / transient) — don't gate the UI on a failed probe.
$petGenAvailable.set(true)
}
}
/** Whether the dedicated "Generate a pet" Pokédex overlay is open. */
export const $petGenerateOpen = atom(false)
export function openPetGenerate(): void {
// Resume an in-flight or finished-but-unadopted run (so a Stop-free close, or
// a "done" notification click, lands back on the right step); only start on a
// clean slate when nothing is going on.
if ($petGenStatus.get() === 'idle') {
resetPetGen()
}
$petGenerateOpen.set(true)
}
export function closePetGenerate(): void {
$petGenerateOpen.set(false)
}
export const $petGenToken = atom<string | null>(null)
/** Prompt that produced the current draft token; hatch uses this for consistency. */
export const $petGenPrompt = atom<string>('')
export const $petGenDrafts = atom<PetDraft[]>([])
export const $petGenSelected = atom<number | null>(null)
/** The hatched-but-unadopted pet: its renderer payload, played in the preview. */
export const $petGenPreview = atom<PetInfo | null>(null)
// Live composer inputs live in atoms (not component state) so closing the
// overlay mid-flow — or letting it run in the background — and reopening (or
// clicking the "done" notification) restores exactly what you had.
export const $petGenInput = atom('')
export const $petGenRefImage = atom<string | null>(null)
export const $petGenRefName = atom('')
function isMissingMethod(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /method not found|-32601|unknown method|no such method/i.test(message)
}
/** Clear all generation state (before a fresh run). */
export function resetPetGen(): void {
$petGenStatus.set('idle')
$petGenStage.set(null)
$petGenError.set(null)
$petGenToken.set(null)
$petGenPrompt.set('')
$petGenDrafts.set([])
$petGenSelected.set(null)
$petGenPreview.set(null)
$petGenInput.set('')
$petGenRefImage.set(null)
$petGenRefName.set('')
}
/**
* Close-time cleanup: if a pet is already hatched but not adopted, discard it so
* abandoned previews do not accumulate on disk. In-flight generate/hatch runs
* are intentionally left alone (background-resumable).
*/
export function cleanupPetGenOnClose(request: GatewayRequest): void {
const status = $petGenStatus.get()
const preview = $petGenPreview.get()
if ((status === 'preview' || status === 'adopting') && preview?.slug) {
void request('pet.remove', { slug: preview.slug }).catch(() => {})
resetPetGen()
}
}
// A finished background run (overlay closed) nudges the user back: an in-app
// toast with a View action always, plus an OS notification when enabled and the
// app is in the background. Clicking either reopens the overlay to its state.
function notifyPetGenDone(title: string, message: string, kind: 'error' | 'success'): void {
if ($petGenerateOpen.get()) {
return
}
notify({ kind, title, message, action: { label: 'View', onClick: openPetGenerate } })
dispatchNativeNotification({ kind: 'backgroundDone', title, body: message, sessionId: $activeSessionId.get() })
}
interface GenerateOptions {
prompt: string
style?: string
count?: number
/** Optional data-URL reference image — every draft is grounded on it. */
referenceImage?: string
}
// A Stop (or a fresh round) must invalidate the in-flight call. This primitive
// pairs a monotonic run id with the current run's cancel fn; `begin` opens a
// run, `isCurrent` gates stale callbacks/events, `arm` registers the aborter,
// `stop` supersedes + fires it. Drives both the draft and hatch flows.
interface Run {
begin: () => number
isCurrent: (id: number) => boolean
arm: (cancel: () => void) => void
stop: () => void
disarmIf: (id: number) => void
}
function cancelableRun(): Run {
let id = 0
let cancel: (() => void) | null = null
return {
begin: () => (id += 1),
isCurrent: n => n === id,
arm: fn => {
cancel = fn
},
stop: () => {
id += 1
cancel?.()
cancel = null
},
disarmIf: n => {
if (n === id) {
cancel = null
}
}
}
}
const gen = cancelableRun()
/**
* Stop the in-flight draft generation (real abort). If any drafts have already
* streamed in, keep them and drop into the ready/picker state (no reason to wait
* for all 4) otherwise reset to idle.
*/
export function cancelGenerate(): void {
gen.stop()
$petGenError.set(null)
const drafts = $petGenDrafts.get()
if (drafts.length > 0) {
if ($petGenSelected.get() === null) {
$petGenSelected.set(drafts[0]?.index ?? 0)
}
$petGenStatus.set('ready')
return
}
$petGenStatus.set('idle')
$petGenDrafts.set([])
$petGenSelected.set(null)
$petGenToken.set(null)
}
/**
* Abandon the current drafts and return to the prompt (step 1). Stops any
* in-flight generation; keeps the prompt text so the user can tweak + retry.
*/
export function discardDrafts(): void {
gen.stop()
$petGenDrafts.set([])
$petGenSelected.set(null)
$petGenToken.set(null)
$petGenError.set(null)
$petGenStatus.set('idle')
}
const hatch = cancelableRun()
// A Stop invalidates the in-flight hatch and drops back to the draft picker (the
// server still finishes, so we delete the pet it created).
/** Stop the in-flight hatch and return to the draft picker. */
export function cancelHatch(): void {
hatch.stop()
$petGenStage.set(null)
$petGenError.set(null)
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
}
/** Generate (or retry) a fresh set of base-look drafts for `prompt`. */
export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise<boolean> {
const prompt = options.prompt.trim()
const referenceImage = options.referenceImage
// Need *something* to ground on: a description or a reference image.
if (!prompt && !referenceImage) {
return false
}
const runId = gen.begin()
const controller = new AbortController()
gen.arm(() => {
controller.abort()
const token = $petGenToken.get()
if (token) {
void request('pet.cancel', { token }).catch(() => {})
}
})
// Starting a fresh generation round supersedes any unadopted preview pet.
const preview = $petGenPreview.get()
if (preview?.slug) {
await request('pet.remove', { slug: preview.slug }).catch(() => {})
}
$petGenStatus.set('generating')
$petGenError.set(null)
$petGenPreview.set(null)
$petGenDrafts.set([])
$petGenSelected.set(null)
// Stream drafts in as the backend finishes each one (pet.generate.progress),
// so the grid fills live instead of sitting on placeholders until all N land.
const off =
$gateway.get()?.on<PetDraft & { token: string; count: number }>('pet.generate.progress', event => {
const draft = event.payload
// Token-only init event (no draft yet): learn the token immediately so an
// early Stop can still tell the backend to cancel this run.
if (draft?.token && !draft.dataUri) {
if (gen.isCurrent(runId) && $petGenStatus.get() === 'generating') {
$petGenToken.set(draft.token)
}
return
}
if (!draft?.dataUri || typeof draft.index !== 'number') {
return
}
// Ignore events from a superseded/stopped run, and only stream while live.
if (!gen.isCurrent(runId) || $petGenStatus.get() !== 'generating') {
return
}
// Capture the token from the stream so a Stop can still hatch the partial set.
if (draft.token) {
$petGenToken.set(draft.token)
}
const current = $petGenDrafts.get()
if (current.some(d => d.index === draft.index)) {
return
}
$petGenDrafts.set(
[...current, { index: draft.index, dataUri: draft.dataUri }].sort((a, b) => a.index - b.index)
)
}) ?? (() => {})
try {
const result = await request<{ ok: boolean; token: string; drafts: PetDraft[] }>(
'pet.generate',
{
prompt,
style: options.style ?? 'auto',
count: options.count ?? 4,
...(referenceImage ? { referenceImage } : {}),
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
},
GENERATE_TIMEOUT_MS,
controller.signal
)
// Stopped (or superseded by a newer round) while the RPC was in flight.
if (!gen.isCurrent(runId)) {
return false
}
if (!result?.ok || !result.drafts?.length) {
throw new Error('generation produced no drafts')
}
$petGenToken.set(result.token)
// Keep a concept for the hatch row prompts even on an image-only generate.
$petGenPrompt.set(prompt || 'a custom pet')
$petGenDrafts.set(result.drafts)
$petGenSelected.set(result.drafts[0]?.index ?? 0)
$petGenStatus.set('ready')
notifyPetGenDone('Pet drafts ready', 'Your pet looks finished — pick one to hatch.', 'success')
return true
} catch (e) {
if (!gen.isCurrent(runId)) {
return false
}
if (isMissingMethod(e)) {
$petGenStatus.set('stale')
} else {
$petGenStatus.set('error')
$petGenError.set(e instanceof Error ? e.message : 'Could not generate pet drafts.')
notifyPetGenDone('Pet generation failed', 'Reopen to try again.', 'error')
}
return false
} finally {
off()
gen.disarmIf(runId)
}
}
interface HatchOptions {
name: string
description?: string
prompt?: string
style?: string
}
/**
* Hatch the selected draft into a full pet (installed but NOT yet active) and
* load its renderer payload into the preview. Adoption is a separate, explicit
* step (`adoptHatched`) so the user sees every frame play before committing.
* Returns true when the preview is ready.
*/
export async function hatchSelected(request: GatewayRequest, options: HatchOptions): Promise<boolean> {
const token = $petGenToken.get()
const index = $petGenSelected.get()
const name = options.name.trim()
const concept = ($petGenPrompt.get() || options.prompt || name).trim()
if (token === null || index === null || !name) {
return false
}
// Hatch cancellation rides its own token (not the draft token): hatching
// mid-generation leaves pet.generate releasing that token, which would race
// the arm. The draft token still locates the staged image server-side.
const cancelToken = crypto.randomUUID()
const hatchRunId = hatch.begin()
const controller = new AbortController()
hatch.arm(() => {
controller.abort()
void request('pet.cancel', { token: cancelToken }).catch(() => {})
})
$petGenStatus.set('hatching')
$petGenStage.set(null)
$petGenError.set(null)
// Stream the hatch steps (which row is drawing, then compose/save) to the egg
// screen so a multi-minute hatch shows live progress instead of a black box.
const offProgress =
$gateway
.get()
?.on<{ event: string; state?: string; done?: string; total?: string }>('pet.hatch.progress', event => {
const p = event.payload
if (!p || !hatch.isCurrent(hatchRunId) || $petGenStatus.get() !== 'hatching') {
return
}
if (p.event === 'row' && p.state) {
$petGenStage.set({
phase: 'row',
state: p.state,
done: Number(p.done) || undefined,
total: Number(p.total) || undefined
})
} else if (p.event === 'compose') {
$petGenStage.set({ phase: 'compose' })
} else if (p.event === 'save') {
$petGenStage.set({ phase: 'save' })
}
}) ?? (() => {})
try {
const result = await request<{ ok: boolean; slug: string; displayName: string; pet?: PetInfo }>(
'pet.hatch',
{
token,
cancelToken,
index,
name,
description: options.description ?? '',
prompt: concept,
style: options.style ?? 'auto',
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
},
HATCH_TIMEOUT_MS,
controller.signal
)
// Stopped mid-hatch: the server created the pet anyway, so delete it.
if (!hatch.isCurrent(hatchRunId)) {
if (result?.slug) {
void request('pet.remove', { slug: result.slug }).catch(() => {})
}
return false
}
if (!result?.ok || !result.pet?.spritesheetBase64) {
throw new Error('hatch produced no preview')
}
$petGenPreview.set({ ...result.pet, enabled: true })
$petGenStatus.set('preview')
notifyPetGenDone('Your pet hatched', 'Reopen to name and adopt it.', 'success')
return true
} catch (e) {
if (!hatch.isCurrent(hatchRunId)) {
return false
}
$petGenStatus.set('error')
$petGenError.set(e instanceof Error ? e.message : 'Could not hatch the pet.')
notifyPetGenDone('Hatching failed', 'Reopen to try again.', 'error')
return false
} finally {
offProgress()
if (hatch.isCurrent(hatchRunId)) {
$petGenStage.set(null)
hatch.disarmIf(hatchRunId)
}
}
}
export interface AdoptOutcome {
ok: boolean
slug?: string
displayName?: string
}
/**
* Adopt the previewed pet: optionally rename it to the user's chosen name (set
* on the reveal screen), activate it (`pet.select`), refresh the gallery + live
* mascot, and clear generation state. No-op unless a preview exists.
*/
export async function adoptHatched(request: GatewayRequest, name?: string): Promise<AdoptOutcome> {
const preview = $petGenPreview.get()
if (!preview?.slug) {
return { ok: false }
}
$petGenStatus.set('adopting')
$petGenError.set(null)
try {
// Name is collected after hatch, so apply it before activating. The rename
// also realigns the slug to the chosen name (so lists show what the user
// typed, not the prompt), so adopt the *returned* slug. Best-effort: a
// rename failure shouldn't block adopting under the provisional slug.
const finalName = name?.trim()
let adoptSlug = preview.slug
if (finalName && finalName !== preview.displayName) {
const renamed = await request<{ ok: boolean; slug: string }>('pet.rename', {
slug: preview.slug,
name: finalName
}).catch(() => null)
if (renamed?.slug) {
adoptSlug = renamed.slug
}
}
const result = await request<{ ok: boolean; slug: string; displayName: string }>('pet.select', {
slug: adoptSlug
})
if (!result?.ok) {
throw new Error('adopt failed')
}
// pet.select already set the active mascot (disk + config). Reflect it
// locally — no remote petdex manifest fetch — and close immediately.
resetPetGen()
void applyAdoptedPet(request, result.slug, result.displayName)
return { ok: true, slug: result.slug, displayName: result.displayName }
} catch (e) {
$petGenStatus.set('preview')
$petGenError.set(e instanceof Error ? e.message : 'Could not adopt the pet.')
return { ok: false }
}
}
/**
* Throw away the previewed pet (`pet.remove`) and return to the draft picker so
* the user can choose another base or regenerate. Best-effort on the delete.
*/
export async function discardHatched(request: GatewayRequest): Promise<void> {
const preview = $petGenPreview.get()
if (preview?.slug) {
await request('pet.remove', { slug: preview.slug }).catch(() => {})
}
$petGenPreview.set(null)
$petGenError.set(null)
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
}

View file

@ -20,6 +20,9 @@ export interface PetInfo {
displayName?: string
mime?: string
spritesheetBase64?: string
// Stable sheet revision (`mtime_ns:size`) from the gateway; lets the desktop
// skip full sprite payload refreshes when the active pet hasn't changed.
spritesheetRevision?: string
frameW?: number
frameH?: number
framesPerState?: number
@ -27,6 +30,9 @@ export interface PetInfo {
// canvas step only frames that exist instead of a fixed framesPerState, which
// would animate into the transparent padding of ragged sheets (blank flash).
framesByState?: Record<string, number>
// Concrete Codex row counts (e.g. running-right may have 8 frames even though
// the Hermes "run" activity state uses the in-place running row).
framesByRow?: Record<string, number>
loopMs?: number
scale?: number
stateRows?: string[]

View file

@ -1426,3 +1426,254 @@ canvas {
animation: none;
}
}
/* -------------------------------------------------------------------------- */
/* Pet egg hatch (Cmd-K → Pets → Generate) */
/* The incubation wobble + reveal flash/pop give the draft→pet step a */
/* Pokémon-style "egg is hatching" beat instead of a bare spinner. */
/* -------------------------------------------------------------------------- */
.pet-egg {
position: relative;
width: 5.5rem;
height: 7rem;
border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
background:
radial-gradient(120% 90% at 32% 26%, color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%, #f4ecd8 46%, #e4d3ad 100%);
box-shadow:
inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent),
inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent),
0 0.4rem 0.9rem color-mix(in srgb, #000 22%, transparent);
transform-origin: 50% 88%;
animation: pet-egg-wobble 2.4s ease-in-out infinite;
}
/* Compact egg (empty-state hero). Children are %-based so they track the size;
only the rem box-shadow needs scaling down to stay crisp. */
.pet-egg--sm {
width: 3.25rem;
height: 4.1rem;
box-shadow:
inset -0.28rem -0.38rem 0.7rem color-mix(in srgb, #000 16%, transparent),
inset 0.22rem 0.26rem 0.45rem color-mix(in srgb, #fff 70%, transparent),
0 0.25rem 0.55rem color-mix(in srgb, #000 22%, transparent);
}
.pet-egg__shine {
position: absolute;
top: 14%;
left: 22%;
width: 28%;
height: 22%;
border-radius: 50%;
background: color-mix(in srgb, #fff 85%, transparent);
filter: blur(2px);
opacity: 0.85;
}
.pet-egg__spot {
position: absolute;
border-radius: 50%;
background: color-mix(in srgb, var(--ui-accent) 70%, #b89b63);
opacity: 0.55;
}
.pet-egg__glow {
position: absolute;
inset: -35%;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--ui-accent) 55%, transparent) 0%, transparent 62%);
animation: pet-egg-glow 2.4s ease-in-out infinite;
pointer-events: none;
}
.pet-egg-shadow {
width: 4.5rem;
height: 0.8rem;
border-radius: 50%;
/* Lighter on light backgrounds (~20% less ink); dark mode keeps it grounded. */
background: radial-gradient(circle, color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%, transparent 72%);
animation: pet-egg-shadow 2.4s ease-in-out infinite;
}
.dark .pet-egg-shadow {
--pet-egg-shadow-ink: 32%;
}
/* Contact shadow sized for the compact incubator egg (roughly its footprint). */
.pet-egg-shadow--sm {
width: 3rem;
height: 0.6rem;
}
/* Contact shadow under the revealed pet mirrors the floating mascot's in-app
shadow: an ellipse at the feet, ~55% of the sprite width, sitting behind it. */
.pet-contact-shadow {
position: absolute;
bottom: -0.15rem;
left: 50%;
width: 55%;
aspect-ratio: 100 / 28;
transform: translateX(-50%);
background: radial-gradient(ellipse at center, color-mix(in srgb, #000 42%, transparent) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* Hatch wiggle for the pixel egg (rocks around its base). */
.pet-wobble {
transform-origin: 50% 85%;
animation: pet-egg-wobble 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.pet-wobble {
animation: none;
}
}
@keyframes pet-egg-wobble {
0%,
62%,
100% {
transform: rotate(0deg);
}
8% {
transform: rotate(-7deg);
}
16% {
transform: rotate(6deg);
}
24% {
transform: rotate(-5deg);
}
32% {
transform: rotate(4deg);
}
40% {
transform: rotate(0deg);
}
/* the "almost out" burst */
70% {
transform: rotate(-12deg);
}
76% {
transform: rotate(12deg);
}
82% {
transform: rotate(-9deg);
}
88% {
transform: rotate(7deg);
}
94% {
transform: rotate(-3deg);
}
}
@keyframes pet-egg-glow {
0%,
100% {
opacity: 0.35;
transform: scale(0.92);
}
70% {
opacity: 0.4;
}
84% {
opacity: 0.85;
transform: scale(1.08);
}
}
@keyframes pet-egg-shadow {
0%,
62%,
100% {
transform: scaleX(1);
opacity: 0.6;
}
76% {
transform: scaleX(0.8);
opacity: 0.45;
}
}
.pet-reveal {
animation: pet-reveal-pop 620ms cubic-bezier(0.22, 1.4, 0.4, 1) both;
}
@keyframes pet-reveal-pop {
0% {
opacity: 0;
transform: scale(0.35) translateY(0.4rem);
}
60% {
opacity: 1;
transform: scale(1.12) translateY(0);
}
100% {
transform: scale(1) translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.pet-egg,
.pet-egg__glow,
.pet-egg-shadow,
.pet-reveal {
animation: none;
}
.pet-reveal {
opacity: 1;
transform: none;
}
}
/* Pet generation progress bar — determinate (hatch rows: done/total) or */
/* indeterminate (drafts, which return together so a % would just snap). */
.pet-progress {
position: relative;
height: 0.25rem;
width: 100%;
overflow: hidden;
border-radius: 9999px;
background: color-mix(in srgb, var(--ui-accent) 15%, transparent);
}
.pet-progress__fill {
position: absolute;
inset: 0 auto 0 0;
height: 100%;
border-radius: 9999px;
background: var(--ui-accent);
transition: width 320ms ease;
}
.pet-progress__indeterminate {
position: absolute;
top: 0;
bottom: 0;
width: 40%;
border-radius: 9999px;
background: var(--ui-accent);
animation: pet-progress-slide 1.15s ease-in-out infinite;
}
@keyframes pet-progress-slide {
0% {
left: -42%;
}
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.pet-progress__indeterminate {
animation: none;
left: 0;
width: 100%;
opacity: 0.4;
}
}

View file

@ -217,29 +217,67 @@ export class JsonRpcGatewayClient {
return () => this.stateHandlers.delete(handler)
}
request<T>(method: string, params: Record<string, unknown> = {}, timeoutMs = this.options.requestTimeoutMs): Promise<T> {
request<T>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = this.options.requestTimeoutMs,
signal?: AbortSignal
): Promise<T> {
const socket = this.socket
if (!socket || socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error(this.options.notConnectedErrorMessage))
}
if (signal?.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'))
}
const id = this.options.createRequestId(++this.nextId)
return new Promise<T>((resolve, reject) => {
let onAbort: (() => void) | undefined
const detach = () => {
if (onAbort && signal) {
signal.removeEventListener('abort', onAbort)
}
}
const pending: PendingCall = {
reject,
resolve: value => resolve(value as T)
resolve: value => {
detach()
resolve(value as T)
},
reject: error => {
detach()
reject(error)
}
}
if (timeoutMs > 0) {
pending.timer = setTimeout(() => {
if (this.pending.delete(id)) {
detach()
reject(new Error(`request timed out: ${method}`))
}
}, timeoutMs)
}
// Abort drops the pending call immediately (no dangling resolver/timer);
// server-side cancellation is a separate cooperative RPC where it matters.
if (signal) {
onAbort = () => {
const call = this.pending.get(id)
if (call?.timer) {
clearTimeout(call.timer)
}
this.pending.delete(id)
detach()
reject(new DOMException('Aborted', 'AbortError'))
}
signal.addEventListener('abort', onAbort, { once: true })
}
this.pending.set(id, pending)
try {
@ -253,6 +291,7 @@ export class JsonRpcGatewayClient {
)
} catch (error) {
this.clearPending(id)
detach()
reject(error instanceof Error ? error : new Error(String(error)))
}
})

3
cli.py
View file

@ -8194,6 +8194,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_personality_command(cmd_original)
elif canonical == "pet":
self._handle_pet_command(cmd_original)
elif canonical == "hatch":
self._handle_hatch_command(cmd_original)
elif canonical == "retry":
retry_msg = self.retry_last()
if retry_msg and hasattr(self, '_pending_input'):

View file

@ -1051,6 +1051,74 @@ class CLICommandsMixin:
_set_active(arg)
print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.")
def _handle_hatch_command(self, cmd: str):
"""Generate ("hatch") a brand-new petdex pet from a description.
``/hatch <description>`` runs the full pet pipeline in-process: a base
look, then one grounded animation row per state, sliced + normalized into
a spritesheet, then adopted as the active mascot. Progress streams inline
(it's ~a minute of image-model calls). In the desktop app this command
opens the richer generate overlay instead; here we run it directly.
"""
from agent.pet import store
from agent.pet.generate import orchestrate
from agent.pet.generate.imagegen import GenerationError
from hermes_cli.pets import _set_active
parts = cmd.split(maxsplit=1)
concept = parts[1].strip() if len(parts) > 1 else ""
if not concept:
try:
concept = input("(o_o) Describe your pet: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if not concept:
print("(o_o) Usage: /hatch <description> (e.g. /hatch a tiny cyber fox)")
return
# A short, friendly display name from the first few words of the concept.
display_name = " ".join(w.capitalize() for w in concept.split()[:3])[:28].strip() or "Pet"
slug = store.slugify(display_name) or store.slugify(concept) or "pet"
print(f"(o_o) Designing '{concept}'… (a minute of image-model calls)")
try:
drafts = orchestrate.generate_base_drafts(concept, n=1)
except GenerationError as exc:
print(f"(x_x) Couldn't generate a base look: {exc}")
return
if not drafts:
print("(x_x) No base draft came back — try again.")
return
def _progress(event: str, detail: str) -> None:
if event == "row":
# detail is "<state>:<done>:<total>"; show the state name.
state = detail.split(":", 1)[0]
print(f" ┊ drawing {state}")
elif event == "compose":
print(" ┊ composing spritesheet…")
elif event == "save":
print(" ┊ saving…")
try:
result = orchestrate.hatch_pet(
base_image=drafts[0],
slug=slug,
display_name=display_name,
concept=concept,
on_progress=_progress,
)
except GenerationError as exc:
print(f"(x_x) Hatch failed: {exc}")
return
_set_active(result.slug)
print(f"(^_^)b {result.display_name} hatched and adopted — it'll pop in shortly!")
def _handle_cron_command(self, cmd: str):
"""Handle the /cron command to manage scheduled tasks."""
from cli import get_job

View file

@ -181,6 +181,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
"Tools & Skills"),
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
CommandDef("hatch", "Generate a new petdex pet from a description",
"Tools & Skills", cli_only=True, aliases=("generate-pet",), args_hint="[description]"),
CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)",
"Tools & Skills", args_hint="<what to learn from>"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",

View file

@ -416,6 +416,26 @@ def _clear_active_if(slug: str) -> bool:
return True
def _rename_active_if(old_slug: str, new_slug: str) -> bool:
"""Repoint the active pet from ``old_slug`` to ``new_slug`` iff it's active.
Used when a rename realigns a pet's slug/dir: if the renamed pet was the
active one, the config must follow or surfaces point at a now-missing dir.
Preserves the ``enabled`` flag. Returns whether anything changed.
"""
if not new_slug or old_slug == new_slug:
return False
from hermes_cli.config import load_config, save_config
cfg = load_config()
pet = cfg.setdefault("display", {}).setdefault("pet", {})
if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != old_slug:
return False
pet["slug"] = new_slug
save_config(cfg)
return True
def _interactive_pick(pets) -> str:
"""Minimal numbered picker (avoids curses dep for a tiny list)."""
_print("Installed pets:")

View file

@ -0,0 +1,414 @@
"""OpenRouter-compatible image generation backend (OpenRouter + Nous Portal).
Both OpenRouter and the Nous Portal inference endpoint speak the same
OpenAI-style ``/chat/completions`` image-generation protocol: send
``modalities: ["image", "text"]`` with an image-output model (e.g.
``google/gemini-2.5-flash-image``), pass reference images as ``image_url``
content parts for grounding, and read the generated images back from
``choices[0].message.images[].image_url.url`` (a ``data:image/...;base64`` URI).
Nous Portal proxies OpenRouter, so one implementation services both we only
swap the resolved ``(base_url, api_key)``. Credentials are resolved through the
agent's existing :func:`~hermes_cli.runtime_provider.resolve_runtime_provider`,
which already understands OpenRouter's key pool and the Nous OAuth device-code
token, so this plugin never reinvents auth.
Reference grounding is the reason pet sprite generation cares about this
backend: each animation row must stay the same character as the chosen base
frame, which only works on models that accept image input. Gemini Flash Image
("nano-banana") does, so both providers advertise image-to-image support.
"""
from __future__ import annotations
import base64
import logging
import mimetypes
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
resolve_aspect_ratio,
save_b64_image,
save_url_image,
success_response,
)
logger = logging.getLogger(__name__)
# Default image-output model. Gemini 2.5 Flash Image ("nano-banana") is GA on
# OpenRouter, accepts reference images for grounding, and honors
# ``image_config.aspect_ratio``.
DEFAULT_MODEL = "google/gemini-2.5-flash-image"
# Semantic aspect ratio (the image_gen contract) → OpenRouter's image_config
# aspect_ratio strings.
_ASPECT_RATIOS = {
"square": "1:1",
"landscape": "16:9",
"portrait": "9:16",
}
# Gemini Flash Image accepts up to 3 input images per prompt; clamp references
# so we never overflow the model's limit.
_MAX_REFERENCE_IMAGES = 3
_REQUEST_TIMEOUT = 180.0
def _load_image_gen_config() -> Dict[str, Any]:
"""Read the ``image_gen`` section from config.yaml (``{}`` on failure)."""
try:
from hermes_cli.config import load_config
cfg = load_config()
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
return section if isinstance(section, dict) else {}
except Exception as exc: # noqa: BLE001 - config is best-effort
logger.debug("could not load image_gen config: %s", exc)
return {}
def _to_image_url_part(ref: str) -> Optional[str]:
"""Turn a reference (local path or http URL) into an ``image_url`` value.
Remote URLs pass through unchanged; local files are inlined as base64 data
URIs so the request is self-contained (the provider endpoint can't reach a
path on our disk). Returns ``None`` when the reference can't be read.
"""
ref = str(ref or "").strip()
if not ref:
return None
if ref.startswith(("http://", "https://", "data:")):
return ref
path = Path(ref)
try:
raw = path.read_bytes()
except OSError as exc:
logger.debug("could not read reference image %s: %s", ref, exc)
return None
mime = mimetypes.guess_type(path.name)[0] or "image/png"
encoded = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{encoded}"
def _extract_images(payload: Dict[str, Any]) -> List[str]:
"""Pull generated image URLs from a chat-completions response.
OpenRouter returns generated images under
``choices[0].message.images[].image_url.url`` (typically a base64 data URI).
"""
out: List[str] = []
choices = payload.get("choices") if isinstance(payload, dict) else None
if not isinstance(choices, list):
return out
for choice in choices:
message = choice.get("message") if isinstance(choice, dict) else None
images = message.get("images") if isinstance(message, dict) else None
if not isinstance(images, list):
continue
for image in images:
if not isinstance(image, dict):
continue
image_url = image.get("image_url")
url = image_url.get("url") if isinstance(image_url, dict) else None
if isinstance(url, str) and url.strip():
out.append(url.strip())
return out
class OpenRouterCompatImageProvider(ImageGenProvider):
"""Image generation over an OpenRouter-compatible chat-completions endpoint.
Instantiated once per backend (OpenRouter, Nous Portal). The two differ only
in which runtime provider supplies ``(base_url, api_key)`` and in the config
namespace used for the model override.
"""
def __init__(
self,
*,
provider_name: str,
display_name: str,
runtime_name: str,
config_key: str,
model_env_var: str,
setup_schema: Dict[str, Any],
) -> None:
self._name = provider_name
self._display = display_name
self._runtime_name = runtime_name
self._config_key = config_key
self._model_env_var = model_env_var
self._setup_schema = setup_schema
@property
def name(self) -> str:
return self._name
@property
def display_name(self) -> str:
return self._display
def _resolve_runtime(self) -> Dict[str, Any]:
"""Resolve ``(base_url, api_key)`` via the shared runtime resolver."""
from hermes_cli.runtime_provider import resolve_runtime_provider
return resolve_runtime_provider(requested=self._runtime_name)
def is_available(self) -> bool:
try:
runtime = self._resolve_runtime()
except Exception as exc: # noqa: BLE001 - treat resolution failure as unavailable
logger.debug("%s runtime resolution failed: %s", self._name, exc)
return False
return bool(str(runtime.get("api_key") or "").strip())
def capabilities(self) -> Dict[str, Any]:
# Both text-to-image and image-to-image (reference grounding) — the
# latter is what makes this backend usable for pet sprite rows.
return {
"modalities": ["text", "image"],
"max_reference_images": _MAX_REFERENCE_IMAGES,
}
def list_models(self) -> List[Dict[str, Any]]:
return [
{
"id": DEFAULT_MODEL,
"display": "Gemini 2.5 Flash Image (nano-banana)",
"strengths": "Reference-grounded edits; aspect-ratio control",
}
]
def default_model(self) -> Optional[str]:
return self._resolve_model()
def get_setup_schema(self) -> Dict[str, Any]:
return dict(self._setup_schema)
def _resolve_model(self) -> str:
"""Pick the image model: env override → config → :data:`DEFAULT_MODEL`."""
env_override = os.environ.get(self._model_env_var, "").strip()
if env_override:
return env_override
cfg = _load_image_gen_config()
scoped = cfg.get(self._config_key) if isinstance(cfg.get(self._config_key), dict) else {}
if isinstance(scoped, dict):
value = scoped.get("model")
if isinstance(value, str) and value.strip():
return value.strip()
return DEFAULT_MODEL
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
import requests
try:
runtime = self._resolve_runtime()
except Exception as exc: # noqa: BLE001
return error_response(
error=f"Could not resolve {self._display} credentials: {exc}",
error_type="missing_api_key",
provider=self._name,
aspect_ratio=aspect_ratio,
)
api_key = str(runtime.get("api_key") or "").strip()
base_url = str(runtime.get("base_url") or "").strip().rstrip("/")
if not api_key or not base_url:
return error_response(
error=(
f"No {self._display} credentials found. "
f"Configure {self._display} in `hermes tools` → Image Generation."
),
error_type="missing_api_key",
provider=self._name,
aspect_ratio=aspect_ratio,
)
model_id = self._resolve_model()
aspect = resolve_aspect_ratio(aspect_ratio)
or_aspect = _ASPECT_RATIOS.get(aspect, "1:1")
# Collect every reference: the pet generator passes local paths via the
# ``reference_images`` kwarg; the generic tool surface uses ``image_url``
# / ``reference_image_urls``. Accept all three.
references: List[str] = []
for ref in kwargs.get("reference_images") or []:
references.append(str(ref))
if image_url:
references.append(str(image_url))
for ref in reference_image_urls or []:
references.append(str(ref))
content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}]
for ref in references[:_MAX_REFERENCE_IMAGES]:
part = _to_image_url_part(ref)
if part:
content.append({"type": "image_url", "image_url": {"url": part}})
payload: Dict[str, Any] = {
"model": model_id,
"modalities": ["image", "text"],
"messages": [{"role": "user", "content": content}],
"image_config": {"aspect_ratio": or_aspect},
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# OpenRouter attribution headers (harmless against Nous Portal).
"HTTP-Referer": "https://github.com/NousResearch/hermes-agent",
"X-Title": "Hermes Agent",
}
try:
response = requests.post(
f"{base_url}/chat/completions",
headers=headers,
json=payload,
timeout=_REQUEST_TIMEOUT,
)
response.raise_for_status()
except requests.HTTPError as exc:
resp = exc.response
status = resp.status_code if resp is not None else 0
try:
err_msg = resp.json().get("error", {}).get("message", resp.text[:300])
except Exception: # noqa: BLE001
err_msg = resp.text[:300] if resp is not None else str(exc)
logger.error("%s image gen failed (%d): %s", self._name, status, err_msg)
return error_response(
error=f"{self._display} image generation failed ({status}): {err_msg}",
error_type="api_error",
provider=self._name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect,
)
except requests.Timeout:
return error_response(
error=f"{self._display} image generation timed out "
f"({int(_REQUEST_TIMEOUT)}s)",
error_type="timeout",
provider=self._name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect,
)
except requests.ConnectionError as exc:
return error_response(
error=f"{self._display} connection error: {exc}",
error_type="connection_error",
provider=self._name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect,
)
try:
result = response.json()
except Exception as exc: # noqa: BLE001
return error_response(
error=f"{self._display} returned invalid JSON: {exc}",
error_type="invalid_response",
provider=self._name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect,
)
images = _extract_images(result)
if not images:
# A response with text but no image usually means the model didn't
# honor image output (wrong model or modalities); surface that.
return error_response(
error=(
f"{self._display} returned no image. Ensure the model "
f"'{model_id}' supports image output."
),
error_type="empty_response",
provider=self._name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect,
)
first = images[0]
try:
if first.startswith("data:"):
b64 = first.split(",", 1)[1] if "," in first else ""
saved_path = save_b64_image(b64, prefix=f"{self._name}_gen")
else:
saved_path = save_url_image(first, prefix=f"{self._name}_gen")
except Exception as exc: # noqa: BLE001
return error_response(
error=f"Could not save generated image: {exc}",
error_type="io_error",
provider=self._name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect,
)
return success_response(
image=str(saved_path),
model=model_id,
prompt=prompt,
aspect_ratio=aspect,
provider=self._name,
)
def _build_providers() -> List[OpenRouterCompatImageProvider]:
return [
OpenRouterCompatImageProvider(
provider_name="openrouter",
display_name="OpenRouter",
runtime_name="openrouter",
config_key="openrouter",
model_env_var="OPENROUTER_IMAGE_MODEL",
setup_schema={
"name": "OpenRouter (image)",
"badge": "paid",
"tag": "Gemini Flash Image & more via OpenRouter; uses OPENROUTER_API_KEY",
"env_vars": [
{
"key": "OPENROUTER_API_KEY",
"prompt": "OpenRouter API key",
"url": "https://openrouter.ai/keys",
}
],
},
),
OpenRouterCompatImageProvider(
provider_name="nous",
display_name="Nous Portal",
runtime_name="nous",
config_key="nous",
model_env_var="NOUS_IMAGE_MODEL",
setup_schema={
"name": "Nous Portal (image)",
"badge": "subscription",
"tag": "Reference-grounded image generation via Nous Portal (OpenRouter-backed)",
"env_vars": [],
"requires_nous_auth": True,
},
),
]
def register(ctx: Any) -> None:
"""Register the OpenRouter + Nous Portal image gen providers."""
for provider in _build_providers():
ctx.register_image_gen_provider(provider)

View file

@ -0,0 +1,7 @@
name: openrouter
version: 1.0.0
description: "OpenRouter + Nous Portal image generation (chat-completions image output; reference-grounded). Text-to-image and image-to-image."
author: Hermes Agent
kind: backend
requires_env:
- OPENROUTER_API_KEY

View file

@ -0,0 +1,493 @@
"""Tests for pet generation: deterministic atlas ops, store register, orchestration.
No network/API calls image generation is mocked with synthetic strips so the
whole pipeline (segmentation compose validate register adopt) is
exercised hermetically.
"""
from __future__ import annotations
import pytest
from agent.pet.generate import atlas
PIL = pytest.importorskip("PIL")
from PIL import Image, ImageDraw # noqa: E402
def _strip(n_blobs: int, *, transparent: bool = True, bg=(0, 255, 0, 255), size=(208, 208)) -> Image.Image:
"""A horizontal strip with *n_blobs* clearly-separated colored ellipses."""
w = size[0] * n_blobs
h = size[1]
base = (0, 0, 0, 0) if transparent else bg
img = Image.new("RGBA", (w, h), base)
draw = ImageDraw.Draw(img)
for i in range(n_blobs):
cx = i * size[0] + size[0] // 2
cy = h // 2
r = size[0] // 3
color = (40 + i * 30 % 200, 80, 200 - i * 20 % 180, 255)
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
return img
# ───────────────────────── frame extraction ─────────────────────────
def test_extract_strip_frames_transparent_returns_centered_cells():
frames = atlas.extract_strip_frames(_strip(6), 6)
assert len(frames) == 6
for frame in frames:
assert frame.size == (atlas.CELL_WIDTH, atlas.CELL_HEIGHT)
# Background corners must be transparent.
assert frame.getpixel((0, 0))[3] == 0
# Something is drawn.
assert frame.getchannel("A").getextrema()[1] > 0
def test_extract_strip_frames_keys_out_solid_background():
frames = atlas.extract_strip_frames(_strip(4, transparent=False), 4)
assert len(frames) == 4
# The green backdrop must be gone (corner transparent).
assert frames[0].getpixel((0, 0))[3] == 0
def test_remove_background_clears_trapped_chroma_pocket():
# Green body enclosing a magenta pocket (the "pink between the arm" case):
# the pocket isn't border-reachable, so it must be cleared by interior seeding.
img = Image.new("RGBA", (200, 200), (255, 0, 255, 255)) # magenta backdrop
draw = ImageDraw.Draw(img)
draw.ellipse((40, 40, 160, 160), fill=(40, 200, 60, 255)) # body
draw.ellipse((85, 85, 115, 115), fill=(255, 0, 255, 255)) # trapped pocket
keyed = atlas.remove_background(img)
assert keyed.getpixel((100, 100))[3] == 0 # pocket cleared
assert keyed.getpixel((100, 50))[3] > 0 # body still opaque
assert keyed.getpixel((2, 2))[3] == 0 # border cleared
def test_extract_strip_frames_repairs_provider_alpha_holes():
img = _strip(1)
draw = ImageDraw.Draw(img)
cx = img.width // 2
cy = img.height // 2
draw.ellipse((cx - 16, cy - 16, cx + 16, cy + 16), fill=(0, 0, 0, 0))
frames = atlas.extract_strip_frames(img, 1, method="components")
assert frames[0].getpixel((atlas.CELL_WIDTH // 2, atlas.CELL_HEIGHT // 2))[3] > 0
def test_extract_strip_frames_severs_thin_bridges_between_frames():
# AI strips often connect poses with a 1px shadow/glow bridge. Strict
# component extraction must still find each frame instead of treating the row
# as one merged subject.
img = _strip(4)
draw = ImageDraw.Draw(img)
draw.line((20, img.height // 2, img.width - 20, img.height // 2), fill=(255, 255, 255, 255), width=1)
frames = atlas.extract_strip_frames(img, 4, method="components")
assert len(frames) == 4
assert all(frame.getchannel("A").getextrema()[1] > 0 for frame in frames)
def test_extract_strip_frames_drops_small_side_lobes_from_adjacent_frames():
# Frogger regression: a real pose plus a small separated side lobe from a
# neighbouring pose. The side lobe should not survive into the fitted cell.
img = Image.new("RGBA", (atlas.CELL_WIDTH, atlas.CELL_HEIGHT), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.ellipse((52, 34, 150, 188), fill=(70, 190, 70, 255))
draw.rectangle((4, 70, 24, 160), fill=(70, 190, 70, 255))
draw.rectangle((168, 82, 186, 150), fill=(70, 190, 70, 255))
frame = atlas.extract_strip_frames(img, 1, method="components")[0]
alpha = frame.getchannel("A")
left_edge_mass = sum(1 for x in range(0, 36) for y in range(frame.height) if alpha.getpixel((x, y)) > 16)
right_edge_mass = sum(1 for x in range(frame.width - 36, frame.width) for y in range(frame.height) if alpha.getpixel((x, y)) > 16)
assert left_edge_mass == 0
assert right_edge_mass == 0
def test_extract_strip_frames_uses_real_gutters_when_spacing_is_uneven():
# gpt-image often returns a square chroma strip whose poses are separated but
# not laid out on exact equal-width slots. Equal slot slicing would include
# the next pose's wing/cape in frame 0; gutter-derived crops keep it out.
img = Image.new("RGBA", (600, 208), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.rectangle((40, 58, 140, 178), fill=(80, 120, 220, 255))
draw.rectangle((182, 58, 282, 178), fill=(220, 120, 80, 255))
draw.rectangle((430, 58, 530, 178), fill=(80, 220, 120, 255))
frames = atlas.extract_strip_frames(img, 3, method="auto", fit=False)
assert len(frames) == 3
assert frames[0].getbbox()[2] <= 120
assert frames[1].getbbox()[0] <= 16
def test_extract_strip_frames_slot_fallback_when_unsegmentable():
# A single connected smear can't be split into 5 components → slot fallback.
img = Image.new("RGBA", (200 * 5, 208), (0, 0, 0, 0))
ImageDraw.Draw(img).rectangle((0, 80, 200 * 5 - 1, 120), fill=(200, 50, 50, 255))
frames = atlas.extract_strip_frames(img, 5, method="auto")
assert len(frames) == 5
def test_extract_components_method_raises_when_too_few():
img = Image.new("RGBA", (400, 208), (0, 0, 0, 0))
ImageDraw.Draw(img).ellipse((10, 10, 100, 100), fill=(255, 0, 0, 255))
with pytest.raises(ValueError):
atlas.extract_strip_frames(img, 6, method="components")
# ───────────────────────── atlas compose / validate ─────────────────────────
def _frames_for_all_states() -> dict[str, list]:
out: dict[str, list] = {}
for state, _row, count in atlas.ROW_SPECS:
out[state] = atlas.extract_strip_frames(_strip(count), count)
return out
def test_compose_atlas_geometry_and_validation():
sheet = atlas.compose_atlas(_frames_for_all_states())
assert sheet.size == (atlas.ATLAS_WIDTH, atlas.ATLAS_HEIGHT)
result = atlas.validate_atlas(sheet)
assert result["ok"], result["errors"]
assert set(result["filled_states"]) == {s for s, _, _ in atlas.ROW_SPECS}
def test_compose_atlas_leaves_unused_tail_transparent():
# waving has 4 frames; columns 4 and 5 of its row must be transparent.
sheet = atlas.compose_atlas(_frames_for_all_states())
wave_row = next(r for s, r, _ in atlas.ROW_SPECS if s == "waving")
top = wave_row * atlas.CELL_HEIGHT
for col in (4, 5):
left = col * atlas.CELL_WIDTH
cell = sheet.crop((left, top, left + atlas.CELL_WIDTH, top + atlas.CELL_HEIGHT))
assert cell.getchannel("A").getextrema()[1] == 0
def test_validate_atlas_rejects_wrong_size():
bad = Image.new("RGBA", (100, 100), (0, 0, 0, 0))
result = atlas.validate_atlas(bad)
assert not result["ok"]
assert any("expected" in e for e in result["errors"])
def test_validate_atlas_rejects_rgb_residue():
sheet = atlas.compose_atlas(_frames_for_all_states())
# Poke a fully-transparent pixel with non-zero RGB.
sheet.putpixel((0, 0), (120, 0, 0, 0))
result = atlas.validate_atlas(sheet)
assert not result["ok"]
assert any("residue" in e for e in result["errors"])
def test_validate_atlas_warns_on_empty_state():
frames = _frames_for_all_states()
frames["jumping"] = []
sheet = atlas.compose_atlas(frames)
result = atlas.validate_atlas(sheet)
assert result["ok"] # one empty row is a warning, not an error
assert any("jumping" in w for w in result["warnings"])
def test_single_frame_fits_cell():
frame = atlas.single_frame(_strip(1))
assert frame.size == (atlas.CELL_WIDTH, atlas.CELL_HEIGHT)
assert frame.getchannel("A").getextrema()[1] > 0
def test_normalize_cells_uses_consistent_pose_scale_for_motion_rows():
# A jump row needs a taller union crop than idle, but the pet itself should
# not shrink just because the motion envelope is taller.
idle = Image.new("RGBA", (160, 180), (0, 0, 0, 0))
jump_low = Image.new("RGBA", (160, 180), (0, 0, 0, 0))
jump_high = Image.new("RGBA", (160, 180), (0, 0, 0, 0))
ImageDraw.Draw(idle).rectangle((50, 80, 110, 160), fill=(80, 120, 220, 255))
ImageDraw.Draw(jump_low).rectangle((50, 80, 110, 160), fill=(220, 120, 80, 255))
ImageDraw.Draw(jump_high).rectangle((50, 60, 110, 140), fill=(220, 120, 80, 255))
normalized = atlas.normalize_cells({"idle": [idle], "jumping": [jump_low, jump_high]})
idle_box = normalized["idle"][0].getbbox()
jump_box = normalized["jumping"][0].getbbox()
assert idle_box is not None
assert jump_box is not None
idle_h = idle_box[3] - idle_box[1]
jump_h = jump_box[3] - jump_box[1]
assert abs(idle_h - jump_h) <= 8
# ───────────────────────── store register / adopt ─────────────────────────
def test_slugify_and_unique_slug():
from agent.pet import store
assert store.slugify("My Cool Pet!") == "my-cool-pet"
assert store.slugify(" ") == "pet"
first = store.unique_slug("Robo")
(store.pets_dir() / first).mkdir(parents=True)
assert store.unique_slug("Robo") == "robo-2"
def test_register_local_pet_appears_and_is_adoptable():
from agent.pet import store
sheet = atlas.compose_atlas(_frames_for_all_states())
pet = store.register_local_pet(sheet, slug="Sparky", display_name="Sparky", description="zappy")
assert pet.slug == "sparky"
assert pet.exists
assert any(p.slug == "sparky" for p in store.installed_pets())
# install_pet returns the on-disk pet without ever hitting the manifest.
adopted = store.install_pet("sparky")
assert adopted.slug == "sparky"
assert adopted.display_name == "Sparky"
def test_register_local_pet_is_generated_and_exports_zip():
import io
import zipfile
from agent.pet import store
sheet = atlas.compose_atlas(_frames_for_all_states())
store.register_local_pet(sheet, slug="zippy", display_name="Zippy")
assert store.load_pet("zippy").generated is True # createdBy=generator
filename, data = store.export_pet("zippy")
assert filename == "zippy.zip"
names = zipfile.ZipFile(io.BytesIO(data)).namelist()
assert "zippy/pet.json" in names
assert any(n.startswith("zippy/spritesheet") for n in names)
def test_export_pet_rejects_unknown_and_traversal():
from agent.pet import store
with pytest.raises(store.PetStoreError):
store.export_pet("does-not-exist")
with pytest.raises(store.PetStoreError):
store.export_pet("../secrets")
def test_register_local_pet_accepts_bytes():
from agent.pet import store
sheet = atlas.compose_atlas(_frames_for_all_states())
data = atlas.atlas_to_webp_bytes(sheet)
pet = store.register_local_pet(data, slug="bytey")
assert pet.exists
# ───────────────────────── orchestration (mocked imagegen) ─────────────────────────
def test_generate_base_drafts_returns_n(monkeypatch, tmp_path):
from agent.pet.generate import imagegen, orchestrate
calls = {"n": 0}
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
paths = []
for i in range(n):
calls["n"] += 1
p = tmp_path / f"{prefix}_{calls['n']}.png"
_strip(1).save(p)
paths.append(p)
return paths
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
monkeypatch.setattr(imagegen, "generate", fake_generate)
drafts = orchestrate.generate_base_drafts("a fox", n=4)
assert len(drafts) == 4
def test_generate_base_drafts_hardens_opaque_background(monkeypatch, tmp_path):
"""A provider that ignores background=transparent still yields a cutout."""
from agent.pet.generate import imagegen, orchestrate
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
# Solid-green backdrop with a blob — i.e. the provider painted a backdrop.
p = tmp_path / f"{prefix}_opaque.png"
_strip(1, transparent=False, bg=(0, 255, 0, 255)).save(p)
return [p]
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
monkeypatch.setattr(imagegen, "generate", fake_generate)
drafts = orchestrate.generate_base_drafts("a fox", n=1)
assert len(drafts) == 1
with Image.open(drafts[0]) as out:
rgba = out.convert("RGBA")
# The keyed backdrop is now transparent (corner pixel fully see-through).
assert rgba.getpixel((0, 0))[3] == 0
# The pet blob in the center is still opaque.
assert rgba.getpixel((rgba.width // 2, rgba.height // 2))[3] > 0
def test_hatch_pet_end_to_end(monkeypatch, tmp_path):
from agent.pet import store
from agent.pet.generate import atlas as atlas_mod
from agent.pet.generate import imagegen, orchestrate
base = tmp_path / "base.png"
_strip(1).save(base)
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
# Return a synthetic row strip; frame count is inferable from the spec.
state = prefix.replace("pet_row_", "")
count = atlas_mod.FRAME_COUNTS.get(state, 6)
p = tmp_path / f"{prefix}.png"
_strip(count).save(p)
return [p]
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
monkeypatch.setattr(imagegen, "generate", fake_generate)
events: list[tuple[str, str]] = []
result = orchestrate.hatch_pet(
base_image=base,
slug="mocky",
display_name="Mocky",
description="a test pet",
concept="a fox",
on_progress=lambda ev, detail: events.append((ev, detail)),
)
assert result.slug == "mocky"
assert result.validation["ok"]
assert set(result.states) == {s for s, _, _ in atlas_mod.ROW_SPECS}
assert ("compose", "") in events
# The pet is on disk and adoptable.
assert store.load_pet("mocky").exists
def test_hatch_pet_idle_fallback_when_row_fails(monkeypatch, tmp_path):
from agent.pet.generate import atlas as atlas_mod
from agent.pet.generate import imagegen, orchestrate
from agent.pet.generate.imagegen import GenerationError
base = tmp_path / "base.png"
_strip(1).save(base)
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
if prefix == "pet_row_idle":
raise GenerationError("boom")
state = prefix.replace("pet_row_", "")
count = atlas_mod.FRAME_COUNTS.get(state, 6)
p = tmp_path / f"{prefix}.png"
_strip(count).save(p)
return [p]
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
monkeypatch.setattr(imagegen, "generate", fake_generate)
result = orchestrate.hatch_pet(base_image=base, slug="fallbacky", concept="a fox")
assert "idle" in result.states # filled by the base-image fallback
def test_hatch_pet_rejects_missing_required_animation_rows(monkeypatch, tmp_path):
from agent.pet.generate import atlas as atlas_mod
from agent.pet.generate import imagegen, orchestrate
from agent.pet.generate.imagegen import GenerationError
base = tmp_path / "base.png"
_strip(1).save(base)
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
if prefix == "pet_row_running-right":
raise GenerationError("bad row")
state = prefix.replace("pet_row_", "")
count = atlas_mod.FRAME_COUNTS.get(state, 6)
p = tmp_path / f"{prefix}.png"
_strip(count).save(p)
return [p]
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
monkeypatch.setattr(imagegen, "generate", fake_generate)
with pytest.raises(GenerationError, match="running-right"):
orchestrate.hatch_pet(base_image=base, slug="broken", concept="a fox")
def test_resolve_provider_errors_without_backend(monkeypatch):
from agent.pet.generate import imagegen
monkeypatch.setattr(imagegen, "_discover", lambda: None)
monkeypatch.setattr("agent.image_gen_registry.get_active_provider", lambda: None)
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: None)
with pytest.raises(imagegen.GenerationError):
imagegen.resolve_provider(require_references=True)
class _FakeImgProvider:
def __init__(self, name, available=True):
self.name = name
self._available = available
def is_available(self):
return self._available
def test_resolve_provider_honors_available_preference(monkeypatch):
"""An explicit, configured, ref-capable preference wins over the active one."""
from agent.pet.generate import imagegen
registry = {"openai": _FakeImgProvider("openai"), "openrouter": _FakeImgProvider("openrouter")}
monkeypatch.setattr(imagegen, "_discover", lambda: None)
monkeypatch.setattr("agent.image_gen_registry.get_active_provider", lambda: registry["openai"])
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: registry.get(name))
assert imagegen.resolve_provider(prefer="openrouter").name == "openrouter"
# An unavailable / unknown preference is ignored — fall back to the active one.
registry["openrouter"]._available = False
assert imagegen.resolve_provider(prefer="openrouter").name == "openai"
assert imagegen.resolve_provider(prefer="not-a-provider").name == "openai"
def test_list_sprite_providers_marks_default(monkeypatch):
"""Lists only available ref-capable backends, flagging the default pick."""
from agent.pet.generate import imagegen
registry = {"openai": _FakeImgProvider("openai"), "nous": _FakeImgProvider("nous")}
monkeypatch.setattr(imagegen, "_discover", lambda: None)
monkeypatch.setattr("agent.image_gen_registry.get_active_provider", lambda: registry["openai"])
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: registry.get(name))
listed = imagegen.list_sprite_providers()
names = {p["name"] for p in listed}
assert names == {"openai", "nous"}
# Every entry carries display metadata, and exactly one is the default.
assert all(p["label"] and "note" in p for p in listed)
assert [p["name"] for p in listed if p["default"]] == ["openai"]
def test_generate_retries_without_transparent_background(monkeypatch, tmp_path):
"""A model that rejects background=transparent still produces images."""
from agent.pet.generate import imagegen
saved = tmp_path / "img.png"
_strip(1).save(saved)
calls: list[dict] = []
class FakeProvider:
def generate(self, prompt, **kwargs):
calls.append(kwargs)
if kwargs.get("background") == "transparent":
return {"success": False, "error": "Transparent background is not supported for this model."}
return {"success": True, "image": str(saved)}
sprite = imagegen.SpriteProvider(name="openai", provider=FakeProvider(), supports_references=False)
out = imagegen.generate("a fox", n=2, provider=sprite)
assert len(out) == 2
# First variant probes transparent (rejected) then retries opaque; the second
# variant skips the transparent probe entirely.
backgrounds = [c.get("background") for c in calls]
assert backgrounds == ["transparent", None, None]

View file

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Tests for the OpenRouter-compatible image gen provider (OpenRouter + Nous)."""
from __future__ import annotations
import base64
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
_RUNTIME = "hermes_cli.runtime_provider.resolve_runtime_provider"
_PNG_DATA_URI = "data:image/png;base64,dGVzdC1pbWFnZS1kYXRh" # "test-image-data"
def _runtime_ok(**over):
base = {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test",
"source": "env",
}
base.update(over)
return base
def _mock_chat_response(images):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {
"choices": [
{
"message": {
"role": "assistant",
"content": "",
"images": [
{"type": "image_url", "image_url": {"url": u}} for u in images
],
}
}
]
}
return resp
def _openrouter():
from plugins.image_gen.openrouter import OpenRouterCompatImageProvider
return OpenRouterCompatImageProvider(
provider_name="openrouter",
display_name="OpenRouter",
runtime_name="openrouter",
config_key="openrouter",
model_env_var="OPENROUTER_IMAGE_MODEL",
setup_schema={"name": "OpenRouter (image)", "badge": "paid", "env_vars": []},
)
# ---------------------------------------------------------------------------
# Provider class
# ---------------------------------------------------------------------------
class TestProviderClass:
def test_names(self):
from plugins.image_gen.openrouter import _build_providers
names = {p.name for p in _build_providers()}
assert names == {"openrouter", "nous"}
def test_display_names(self):
from plugins.image_gen.openrouter import _build_providers
by_name = {p.name: p for p in _build_providers()}
assert by_name["openrouter"].display_name == "OpenRouter"
assert by_name["nous"].display_name == "Nous Portal"
def test_capabilities_support_image_input(self):
caps = _openrouter().capabilities()
assert "image" in caps["modalities"]
assert caps["max_reference_images"] >= 1
def test_is_available_with_key(self):
with patch(_RUNTIME, return_value=_runtime_ok()):
assert _openrouter().is_available() is True
def test_is_available_without_key(self):
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
assert _openrouter().is_available() is False
def test_is_available_on_resolution_error(self):
with patch(_RUNTIME, side_effect=RuntimeError("boom")):
assert _openrouter().is_available() is False
def test_default_model(self):
from plugins.image_gen.openrouter import DEFAULT_MODEL
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
assert _openrouter().default_model() == DEFAULT_MODEL
assert DEFAULT_MODEL == "google/gemini-2.5-flash-image"
def test_model_env_override(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "black-forest-labs/flux.2-pro")
assert _openrouter()._resolve_model() == "black-forest-labs/flux.2-pro"
def test_model_config_override(self):
cfg = {"openrouter": {"model": "google/gemini-3.1-flash-image-preview"}}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model() == "google/gemini-3.1-flash-image-preview"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class TestHelpers:
def test_to_image_url_part_passthrough_url(self):
from plugins.image_gen.openrouter import _to_image_url_part
assert _to_image_url_part("https://x/y.png") == "https://x/y.png"
assert _to_image_url_part("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
def test_to_image_url_part_inlines_local_file(self, tmp_path):
from plugins.image_gen.openrouter import _to_image_url_part
f = tmp_path / "base.png"
f.write_bytes(b"\x89PNG\r\n")
part = _to_image_url_part(str(f))
assert part.startswith("data:image/png;base64,")
decoded = base64.b64decode(part.split(",", 1)[1])
assert decoded == b"\x89PNG\r\n"
def test_to_image_url_part_missing_file(self):
from plugins.image_gen.openrouter import _to_image_url_part
assert _to_image_url_part("/no/such/file.png") is None
def test_extract_images(self):
from plugins.image_gen.openrouter import _extract_images
payload = {
"choices": [
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,AA"}}]}}
]
}
assert _extract_images(payload) == ["data:image/png;base64,AA"]
def test_extract_images_empty(self):
from plugins.image_gen.openrouter import _extract_images
assert _extract_images({"choices": [{"message": {"content": "no image"}}]}) == []
# ---------------------------------------------------------------------------
# generate()
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_credentials(self):
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "missing_api_key"
def test_success_data_uri(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])), \
patch(
"plugins.image_gen.openrouter.save_b64_image",
return_value=Path("/tmp/openrouter_gen.png"),
) as mock_save:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["image"] == "/tmp/openrouter_gen.png"
assert result["provider"] == "openrouter"
mock_save.assert_called_once()
def test_success_http_url(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response(["https://cdn/x.png"])), \
patch(
"plugins.image_gen.openrouter.save_url_image",
return_value=Path("/tmp/openrouter_gen_url.png"),
) as mock_save_url:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["image"] == "/tmp/openrouter_gen_url.png"
mock_save_url.assert_called_once()
def test_empty_response(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([])):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_payload_shape_and_references(self, tmp_path):
"""Wire payload must carry image modalities, aspect_ratio, and the
reference image inlined as a data URI (this is what makes pet rows
stay on-model)."""
ref = tmp_path / "base.png"
ref.write_bytes(b"\x89PNG\r\n")
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(
prompt="a pet", aspect_ratio="square", reference_images=[str(ref)]
)
payload = mock_post.call_args.kwargs["json"]
assert payload["modalities"] == ["image", "text"]
assert payload["image_config"]["aspect_ratio"] == "1:1"
content = payload["messages"][0]["content"]
assert content[0] == {"type": "text", "text": "a pet"}
image_parts = [c for c in content if c["type"] == "image_url"]
assert len(image_parts) == 1
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
def test_auth_header(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(prompt="a pet")
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer sk-or-test"
def test_posts_to_resolved_base_url(self):
"""Nous routes to its own base URL — proves the same code serves both."""
nous_runtime = _runtime_ok(
provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok"
)
with patch(_RUNTIME, return_value=nous_runtime), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
from plugins.image_gen.openrouter import _build_providers
nous = {p.name: p for p in _build_providers()}["nous"]
result = nous.generate(prompt="a pet")
assert result["success"] is True
assert result["provider"] == "nous"
url = mock_post.call_args[0][0]
assert url == "https://inference.nousresearch.com/v1/chat/completions"
def test_api_error(self):
import requests as req_lib
resp = MagicMock()
resp.status_code = 401
resp.text = "Unauthorized"
resp.json.return_value = {"error": {"message": "Invalid API key"}}
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=resp):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "api_error"
def test_timeout(self):
import requests as req_lib
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", side_effect=req_lib.Timeout()):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "timeout"
# ---------------------------------------------------------------------------
# Registration + pet integration
# ---------------------------------------------------------------------------
class TestRegistration:
def test_register_both(self):
from plugins.image_gen.openrouter import register
ctx = MagicMock()
register(ctx)
registered = [c.args[0].name for c in ctx.register_image_gen_provider.call_args_list]
assert set(registered) == {"openrouter", "nous"}
def test_both_are_reference_capable_for_pets(self):
from agent.pet.generate.imagegen import _REF_CAPABLE
assert "openrouter" in _REF_CAPABLE
assert "nous" in _REF_CAPABLE

View file

@ -1932,12 +1932,14 @@ class TestDelegateHeartbeat(unittest.TestCase):
child.run_conversation.side_effect = slow_run
# Patch both the interval AND the idle ceiling so the test proves
# the in-tool branch takes effect: with a 0.05s interval and the
# default _HEARTBEAT_STALE_CYCLES_IDLE=5, the old behavior would
# trip after 0.25s and stop firing. We should see heartbeats
# continuing through the full 0.4s run.
with patch("tools.delegate_tool._HEARTBEAT_INTERVAL", 0.05):
# Use tiny thresholds so the assertion is scheduler-robust in CI:
# if idle rules were used for in-tool work, heartbeat would stop after
# ~2 cycles. The in-tool branch should keep touching well past that.
with (
patch("tools.delegate_tool._HEARTBEAT_INTERVAL", 0.05),
patch("tools.delegate_tool._HEARTBEAT_STALE_CYCLES_IDLE", 2),
patch("tools.delegate_tool._HEARTBEAT_STALE_CYCLES_IN_TOOL", 40),
):
_run_single_child(
task_index=0,
goal="Test long-running tool",
@ -1945,11 +1947,10 @@ class TestDelegateHeartbeat(unittest.TestCase):
parent_agent=parent,
)
# With the old idle threshold (5 cycles = 0.25s), touch_calls
# would cap at ~5. With the in-tool threshold (20 cycles = 1.0s),
# we should see substantially more heartbeats over 0.4s.
# If idle-threshold logic applied, we'd cap around 2 touches; prove we
# continued beyond that while inside a long-running tool.
self.assertGreater(
len(touch_calls), 6,
len(touch_calls), 2,
f"Heartbeat stopped too early while child was inside a tool; "
f"got {len(touch_calls)} touches over 0.4s at 0.05s interval",
)

View file

@ -0,0 +1,245 @@
"""Gateway RPC tests for pet generation (pet.generate / pet.hatch).
Image generation is mocked, so these assert the RPC contract + staging behavior
(draft tokens, data-URI previews, expiry, activation) without any API calls.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PIL")
from PIL import Image # noqa: E402
from tui_gateway import server # noqa: E402
def _png(path):
Image.new("RGBA", (64, 64), (200, 80, 80, 255)).save(path)
def test_pet_generate_requires_prompt():
resp = server._methods["pet.generate"]("r1", {"prompt": " "})
assert "error" in resp
def test_pet_generate_rejects_invalid_reference_image():
resp = server._methods["pet.generate"](
"r_invalid_ref",
{"referenceImage": "data:image/svg+xml;base64,PHN2Zy8+"},
)
assert "error" in resp
assert "unsupported reference image type" in resp["error"]["message"]
def test_pet_generate_rejects_oversized_reference_image(monkeypatch):
import base64
monkeypatch.setattr(server, "_PET_REFERENCE_MAX_BYTES", 8)
payload = base64.b64encode(b"0123456789").decode("ascii")
resp = server._methods["pet.generate"](
"r_big_ref",
{"referenceImage": f"data:image/png;base64,{payload}"},
)
assert "error" in resp
assert "too large" in resp["error"]["message"].lower()
def test_pet_generate_returns_token_and_previews(monkeypatch, tmp_path):
import agent.pet.generate as gen
def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None):
paths = []
for i in range(n):
p = tmp_path / f"d{i}.png"
_png(p)
paths.append(p)
if on_draft is not None:
on_draft(i, p)
return paths
monkeypatch.setattr(gen, "generate_base_drafts", fake_drafts)
resp = server._methods["pet.generate"]("r2", {"prompt": "a robot fox", "count": 4})
result = resp["result"]
assert result["ok"]
assert len(result["drafts"]) == 4
assert all(d["dataUri"].startswith("data:image/png;base64,") for d in result["drafts"])
# Drafts are staged on disk under the returned token.
staged = server._pet_gen_root() / result["token"] / "draft-0.png"
assert staged.is_file()
def test_pet_cancel_unknown_token_is_noop():
resp = server._methods["pet.cancel"]("c0", {"token": "missing"})
assert resp["result"]["ok"] is True
def test_pet_generate_cancel_stops_run(monkeypatch, tmp_path):
import agent.pet.generate as gen
seen: dict = {}
def cap_emit(event, sid, payload=None):
# Capture the token from the up-front init event so we can cancel it.
if event == "pet.generate.progress" and payload and payload.get("token") and not payload.get("dataUri"):
seen["token"] = payload["token"]
monkeypatch.setattr(server, "_emit", cap_emit)
def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None):
# Simulate a Stop landing mid-run: the cooperative flag must read True.
server._pet_cancel_request(seen["token"])
assert is_cancelled() is True
return [] # bailed before producing anything
monkeypatch.setattr(gen, "generate_base_drafts", fake_drafts)
resp = server._methods["pet.generate"]("rc", {"prompt": "x", "count": 4})
assert "error" in resp
assert "cancel" in resp["error"]["message"].lower()
# The flag is released after the run so reusing the token isn't pre-cancelled.
assert server._pet_is_cancelled(seen["token"]) is False
def test_pet_hatch_validates_params():
assert "error" in server._methods["pet.hatch"]("r1", {"name": "x"}) # missing token
assert "error" in server._methods["pet.hatch"]("r2", {"token": "abc"}) # missing name
def test_pet_hatch_expired_draft():
resp = server._methods["pet.hatch"]("r3", {"token": "nope", "index": 0, "name": "Ghost"})
assert "error" in resp
assert "expired" in resp["error"]["message"]
def _fake_drafts_factory(tmp_path):
def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None):
paths = []
for i in range(n):
p = tmp_path / f"d{i}.png"
_png(p)
paths.append(p)
if on_draft is not None:
on_draft(i, p)
return paths
return fake_drafts
def _fake_hatch_factory(captured):
"""A hatch that registers a real local pet (so the preview payload populates)."""
import agent.pet.generate as gen
from agent.pet import store
def fake_hatch(*, base_image, slug, display_name="", description="", concept="", style="auto", on_progress=None, provider=None, is_cancelled=None):
captured["base_image"] = str(base_image)
captured["slug"] = slug
pet = store.register_local_pet(
Image.new("RGBA", (192, 208), (10, 20, 30, 255)),
slug=slug,
display_name=display_name,
description=description,
)
return gen.HatchResult(
slug=pet.slug,
display_name=display_name or pet.display_name,
spritesheet=pet.spritesheet,
states=["idle", "wave"],
validation={"ok": True, "warnings": ["state 'jump' has no frames"]},
)
return fake_hatch
def test_pet_generate_then_hatch_previews_without_activating(monkeypatch, tmp_path):
import agent.pet.generate as gen
from agent.pet import store
captured = {}
monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path))
monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured))
token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"]
resp = server._methods["pet.hatch"](
"r2",
{"token": token, "index": 1, "name": "My Fox", "description": "vulpine"},
)
result = resp["result"]
assert result["ok"]
assert result["slug"] == "my-fox"
assert result["displayName"] == "My Fox"
assert result["warnings"] == ["state 'jump' has no frames"]
# Hatched from the chosen draft index.
assert captured["base_image"].endswith("draft-1.png")
# The pet is installed on disk and the preview payload carries the sheet,
# but hatch must NOT activate it — adoption is a separate step.
assert store.load_pet("my-fox") is not None
assert result["pet"]["slug"] == "my-fox"
assert result["pet"]["spritesheetBase64"]
assert server._methods["pet.info"]("r3", {}).get("result", {}).get("enabled") in (False, None)
def test_pet_hatch_then_adopt_activates(monkeypatch, tmp_path):
import agent.pet.generate as gen
captured = {}
monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path))
monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured))
activated = {}
monkeypatch.setattr("hermes_cli.pets._set_active", lambda slug: activated.setdefault("slug", slug))
token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"]
hatched = server._methods["pet.hatch"]("r2", {"token": token, "index": 0, "name": "My Fox"})["result"]
# Adoption is the existing pet.select path, against the now-installed slug.
adopt = server._methods["pet.select"]("r3", {"slug": hatched["slug"]})["result"]
assert adopt["ok"]
assert activated["slug"] == "my-fox"
def test_pet_sprite_payload_includes_concrete_row_counts():
from agent.pet import constants, store
cols, rows = 8, 9
sheet = Image.new("RGBA", (constants.FRAME_W * cols, constants.FRAME_H * rows), (0, 0, 0, 0))
# Current Codex rows can have more/fewer frames than Hermes' generic
# FRAMES_PER_STATE. The desktop preview needs the concrete row count.
real = {0: 6, 1: 8, 3: 4, 4: 5, 7: 6}
for row, count in real.items():
for col in range(count):
block = Image.new("RGBA", (constants.FRAME_W, constants.FRAME_H), (80, 120, 220, 255))
sheet.paste(block, (col * constants.FRAME_W, row * constants.FRAME_H))
pet = store.register_local_pet(sheet, slug="row-counts", display_name="Row Counts")
payload = server._pet_sprite_payload(pet, scale=0.7)
assert payload["framesByRow"]["running-right"] == 8
assert payload["framesByRow"]["waving"] == 4
assert payload["framesByRow"]["jumping"] == 5
assert payload["framesByState"]["run"] == 6
def test_pet_info_meta_avoids_full_payload(monkeypatch):
import hermes_cli.config as cli_config
from agent.pet import constants, store
sheet = Image.new("RGBA", (constants.FRAME_W * 8, constants.FRAME_H * 9), (80, 120, 220, 255))
pet = store.register_local_pet(sheet, slug="meta-pet", display_name="Meta Pet")
monkeypatch.setattr(
cli_config,
"load_config",
lambda: {"display": {"pet": {"enabled": True, "slug": pet.slug, "scale": 0.7}}},
)
resp = server._methods["pet.info.meta"]("r_meta", {})
result = resp["result"]
assert result["enabled"] is True
assert result["slug"] == pet.slug
assert result["displayName"] == "Meta Pet"
assert result["scale"] == 0.7
assert ":" in result["spritesheetRevision"]

View file

@ -184,6 +184,10 @@ _LONG_HANDLERS = frozenset(
# animation poll stutters. On the pool they run concurrently.
"pet.cells",
"pet.gallery",
# Generation is the heaviest pet path by far — multiple image-model
# round-trips per call — so it must never block the reader thread.
"pet.generate",
"pet.hatch",
"pet.select",
"pet.thumb",
"plugins.manage",
@ -5575,6 +5579,153 @@ def _pet_frame_counts(spritesheet) -> dict:
return {}
_pet_payload_cache_lock = threading.Lock()
_pet_payload_cache: dict[tuple, dict] = {}
def _pet_sheet_revision(spritesheet) -> str:
"""Stable revision id for one spritesheet file."""
try:
stat = spritesheet.stat()
return f"{stat.st_mtime_ns}:{stat.st_size}"
except Exception: # noqa: BLE001 - cosmetic, never break the surface
return "0:0"
def _pet_payload_cache_key(pet, *, scale: float) -> tuple | None:
"""Cache key for the expensive sprite payload build."""
try:
stat = pet.spritesheet.stat()
except Exception: # noqa: BLE001
return None
return (
str(pet.spritesheet),
stat.st_mtime_ns,
stat.st_size,
pet.slug,
pet.display_name,
round(scale, 4),
)
def _clone_pet_payload(payload: dict) -> dict:
"""Shallow-clone cached payloads so callers can't mutate shared state."""
out = dict(payload)
if isinstance(payload.get("framesByState"), dict):
out["framesByState"] = dict(payload["framesByState"])
if isinstance(payload.get("framesByRow"), dict):
out["framesByRow"] = dict(payload["framesByRow"])
if isinstance(payload.get("stateRows"), list):
out["stateRows"] = list(payload["stateRows"])
return out
def _pet_row_frame_counts(spritesheet) -> dict:
"""Real frame count per concrete spritesheet row name."""
try:
from PIL import Image
from agent.pet import constants, render
with Image.open(spritesheet) as opened:
image = opened.convert("RGBA")
cols = max(1, image.width // constants.FRAME_W)
row_count = max(1, image.height // constants.FRAME_H)
rows = constants.state_rows_for_grid(row_count)
out: dict[str, int] = {}
for row_idx, name in enumerate(rows[:row_count]):
top = row_idx * constants.FRAME_H
count = 0
for col in range(cols):
left = col * constants.FRAME_W
frame = image.crop((left, top, left + constants.FRAME_W, top + constants.FRAME_H))
if render._frame_is_blank(frame):
break
count += 1
out[name] = count
return out
except Exception: # noqa: BLE001 - cosmetic, never break the surface
return {}
def _pet_config_scale() -> float:
"""Configured ``display.pet.scale`` (or the engine default), never raises."""
from agent.pet import constants
try:
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
return float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
except Exception: # noqa: BLE001
return constants.DEFAULT_SCALE
def _pet_sprite_payload(pet, *, scale: float) -> dict:
"""Build the renderer payload (spritesheet bytes + geometry) for *pet*.
Shared by ``pet.info`` (the active mascot) and ``pet.hatch`` (the unadopted
preview) so both feed the desktop canvas / TUI from one shape.
"""
import base64
from agent.pet import constants
cache_key = _pet_payload_cache_key(pet, scale=scale)
if cache_key is not None:
with _pet_payload_cache_lock:
cached = _pet_payload_cache.get(cache_key)
if cached is not None:
return _clone_pet_payload(cached)
raw = pet.spritesheet.read_bytes()
suffix = pet.spritesheet.suffix.lower()
mime = "image/png" if suffix == ".png" else "image/webp"
payload = {
"slug": pet.slug,
"displayName": pet.display_name,
"mime": mime,
"spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"),
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet),
"frameW": constants.FRAME_W,
"frameH": constants.FRAME_H,
"framesPerState": constants.FRAMES_PER_STATE,
"framesByState": _pet_frame_counts(pet.spritesheet),
"framesByRow": _pet_row_frame_counts(pet.spritesheet),
"loopMs": constants.LOOP_MS,
"scale": scale,
"stateRows": _pet_state_rows(pet.spritesheet),
}
if cache_key is not None:
with _pet_payload_cache_lock:
_pet_payload_cache[cache_key] = payload
while len(_pet_payload_cache) > 8:
_pet_payload_cache.pop(next(iter(_pet_payload_cache)))
return _clone_pet_payload(payload)
def _pet_active_selection():
"""Resolve configured active pet + scale from config."""
from agent.pet import constants, store
try:
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
except Exception:
pet_cfg = {}
enabled = bool(pet_cfg.get("enabled"))
configured_slug = str(pet_cfg.get("slug", "") or "")
pet = store.resolve_active_pet(configured_slug) if enabled else None
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
return enabled, pet, scale
def _pet_state_rows(spritesheet) -> list[str]:
"""Row taxonomy for the concrete active pet sheet.
@ -5610,49 +5761,38 @@ def _(rid, params: dict) -> dict:
before the agent finishes building. Fail-open: returns ``enabled=False``
on any error rather than erroring the surface.
"""
import base64
try:
from agent.pet import constants, store
try:
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
except Exception:
pet_cfg = {}
enabled = bool(pet_cfg.get("enabled"))
configured_slug = str(pet_cfg.get("slug", "") or "")
pet = store.resolve_active_pet(configured_slug) if enabled else None
enabled, pet, scale = _pet_active_selection()
if not enabled or pet is None or not pet.exists:
return _ok(rid, {"enabled": False})
raw = pet.spritesheet.read_bytes()
suffix = pet.spritesheet.suffix.lower()
mime = "image/png" if suffix == ".png" else "image/webp"
return _ok(rid, {"enabled": True, **_pet_sprite_payload(pet, scale=scale)})
except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface
logger.debug("pet.info failed: %s", exc)
return _ok(rid, {"enabled": False})
@method("pet.info.meta")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Cheap active-pet metadata used to avoid full payload refreshes."""
try:
enabled, pet, scale = _pet_active_selection()
if not enabled or pet is None or not pet.exists:
return _ok(rid, {"enabled": False})
return _ok(
rid,
{
"enabled": True,
"slug": pet.slug,
"displayName": pet.display_name,
"mime": mime,
"spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"),
"frameW": constants.FRAME_W,
"frameH": constants.FRAME_H,
"framesPerState": constants.FRAMES_PER_STATE,
"framesByState": _pet_frame_counts(pet.spritesheet),
"loopMs": constants.LOOP_MS,
"scale": float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE),
"stateRows": _pet_state_rows(pet.spritesheet),
"scale": scale,
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet),
},
)
except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface
logger.debug("pet.info failed: %s", exc)
logger.debug("pet.info.meta failed: %s", exc)
return _ok(rid, {"enabled": False})
@ -5770,7 +5910,12 @@ def _(rid, params: dict) -> dict:
current config (active slug + enabled). Agent-independent. Fail-open:
returns whatever is installed locally if the gallery can't be reached, so
the picker still works offline.
Param ``localOnly`` (bool): skip the remote petdex manifest fetch and return
only locally-installed pets. The desktop loads this first so the user's own
pets render instantly instead of waiting on the (possibly slow) manifest.
"""
local_only = bool(params.get("localOnly"))
try:
from agent.pet import store
@ -5788,9 +5933,14 @@ def _(rid, params: dict) -> dict:
gallery: list[dict] = []
seen: set[str] = set()
try:
from agent.pet.manifest import fetch_manifest
from agent.pet.manifest import fetch_manifest, prefetch
for entry in fetch_manifest():
# Local-only: skip the network entirely, but kick off a background
# warm so the follow-up full request usually hits a cached manifest.
if local_only:
prefetch()
for entry in [] if local_only else fetch_manifest():
seen.add(entry.slug)
gallery.append(
{
@ -5802,6 +5952,7 @@ def _(rid, params: dict) -> dict:
# hand-picked/official set, identified by the asset path)
# is the closest signal, so the picker can surface it first.
"curated": "/curated/" in entry.spritesheet_url,
"generated": entry.slug in installed and installed[entry.slug].generated,
}
)
except Exception as exc: # noqa: BLE001 - offline: fall back to installed
@ -5811,7 +5962,13 @@ def _(rid, params: dict) -> dict:
for slug, pet in installed.items():
if slug not in seen:
gallery.append(
{"slug": slug, "displayName": pet.display_name, "installed": True, "spritesheetUrl": ""}
{
"slug": slug,
"displayName": pet.display_name,
"installed": True,
"spritesheetUrl": "",
"generated": pet.generated,
}
)
return _ok(
@ -5884,6 +6041,71 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5031, f"pet.remove failed: {exc}")
@method("pet.export")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Export an installed pet as a re-importable ``.zip`` (pet.json + sprite).
Params: ``slug`` (required). Returns ``{ok, filename, zipBase64}`` the
client decodes the base64 and saves it. Heavy-ish (reads + zips files) but
small; runs inline.
"""
slug = str(params.get("slug") or "").strip()
if not slug:
return _err(rid, 4004, "missing slug")
try:
import base64
from agent.pet import store
filename, data = store.export_pet(slug)
return _ok(
rid,
{"ok": True, "filename": filename, "zipBase64": base64.standard_b64encode(data).decode("ascii")},
)
except Exception as exc: # noqa: BLE001
logger.debug("pet.export failed: %s", exc)
return _err(rid, 5031, f"pet.export failed: {exc}")
@method("pet.rename")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Rename an installed pet's display name + realign its slug/dir.
Params: ``slug`` + ``name`` (both required). Lets the generate flow hatch
with a provisional name and apply the user's chosen name at adopt time.
Returns ``{ok, slug, displayName}`` with the (possibly new) slug.
"""
slug = str(params.get("slug") or "").strip()
name = str(params.get("name") or "").strip()
if not slug:
return _err(rid, 4004, "missing slug")
if not name:
return _err(rid, 4004, "missing name")
try:
from agent.pet import store
new_slug = store.rename_pet(slug, name)
if not new_slug:
return _err(rid, 5031, "pet.rename failed")
# The dir may have moved; if the renamed pet was active, follow the slug
# in config so surfaces don't point at the old (now-missing) directory.
if new_slug != slug:
try:
from hermes_cli.pets import _rename_active_if
_rename_active_if(slug, new_slug)
except Exception as exc: # noqa: BLE001 - rename already succeeded
logger.debug("pet.rename config update failed: %s", exc)
return _ok(rid, {"ok": True, "slug": new_slug, "displayName": name})
except Exception as exc: # noqa: BLE001
logger.debug("pet.rename failed: %s", exc)
return _err(rid, 5031, f"pet.rename failed: {exc}")
@method("pet.thumb")
@_profile_scoped
def _(rid, params: dict) -> dict:
@ -5954,6 +6176,377 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5031, f"pet.scale failed: {exc}")
def _pet_gen_root():
"""Profile-scoped staging dir for in-progress generation drafts."""
from hermes_constants import get_hermes_home
root = get_hermes_home() / "cache" / "pet-gen"
root.mkdir(parents=True, exist_ok=True)
return root
def _pet_gen_sweep(root, *, max_age_s: float = 3600.0) -> None:
"""Drop stale draft staging dirs so cache never grows unbounded."""
import shutil
import time
try:
now = time.time()
for child in root.iterdir():
if child.is_dir() and now - child.stat().st_mtime > max_age_s:
shutil.rmtree(child, ignore_errors=True)
except Exception as exc: # noqa: BLE001 - cleanup is best-effort
logger.debug("pet-gen sweep failed: %s", exc)
def _pet_png_data_uri(path, *, max_px: int = 160) -> str:
"""Downscaled PNG data URI for a draft image (small preview payload)."""
import base64
import io
from PIL import Image
with Image.open(path) as opened:
img = opened.convert("RGBA")
img.thumbnail((max_px, max_px), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.standard_b64encode(buf.getvalue()).decode("ascii")
# Cooperative cancellation for the heavy pet generation paths. The client's Stop
# aborts its RPC immediately, but the worker-pool generation keeps running unless
# told to stop — pet.cancel flips a token's flag, which generate_base_drafts /
# hatch_pet poll between provider calls to skip work they haven't started.
_pet_cancel_lock = threading.Lock()
_pet_cancelled: set[str] = set()
_PET_REFERENCE_MIME_EXT = {
"png": "png",
"jpeg": "jpg",
"jpg": "jpg",
"webp": "webp",
"gif": "gif",
}
try:
_PET_REFERENCE_MAX_BYTES = max(
1,
int(os.environ.get("HERMES_PET_REFERENCE_MAX_BYTES") or str(16 * 1024 * 1024)),
)
except (TypeError, ValueError):
_PET_REFERENCE_MAX_BYTES = 16 * 1024 * 1024
def _pet_reference_images_from_data_url(ref_raw: str, stage) -> list:
"""Decode + validate a reference-image data URL into the stage dir."""
import base64
import binascii
import re as _re
match = _re.match(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", ref_raw, _re.DOTALL)
if not match:
raise ValueError("invalid reference image format")
mime = match.group(1).lower()
ext = _PET_REFERENCE_MIME_EXT.get(mime)
if ext is None:
raise ValueError("unsupported reference image type")
payload = "".join(match.group(2).split())
approx = (len(payload) * 3) // 4
if approx > _PET_REFERENCE_MAX_BYTES:
raise ValueError("reference image too large")
try:
raw = base64.b64decode(payload, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("invalid reference image data") from exc
if len(raw) > _PET_REFERENCE_MAX_BYTES:
raise ValueError("reference image too large")
ref_path = stage / f"reference.{ext}"
ref_path.write_bytes(raw)
return [ref_path]
def _pet_cancel_arm(token: str) -> None:
"""Clear a stale cancel flag at the start of a generate/hatch run."""
with _pet_cancel_lock:
_pet_cancelled.discard(token)
def _pet_cancel_request(token: str) -> None:
with _pet_cancel_lock:
_pet_cancelled.add(token)
def _pet_is_cancelled(token: str) -> bool:
with _pet_cancel_lock:
return token in _pet_cancelled
def _pet_cancel_release(token: str) -> None:
with _pet_cancel_lock:
_pet_cancelled.discard(token)
@method("pet.cancel")
def _(rid, params: dict) -> dict:
"""Signal an in-flight ``pet.generate``/``pet.hatch`` (by token) to stop.
Best-effort + idempotent: cancelling an unknown/finished token is a no-op.
Stays off the worker pool so it lands while a heavy generation is occupying
it. Returns ``{ok: True}``.
"""
token = str(params.get("token") or "").strip()
if token:
_pet_cancel_request(token)
return _ok(rid, {"ok": True})
@method("pet.generate.status")
def _(rid, params: dict) -> dict:
"""Whether pet generation is possible right now.
True only when a reference-capable image backend (Nous Portal / OpenRouter /
OpenAI gpt-image) is configured the desktop checks this on open so it can
offer setup instead of a dead prompt. Cheap (config + plugin discovery).
"""
try:
from agent.pet.generate.imagegen import (
GenerationError,
list_sprite_providers,
resolve_provider,
)
try:
resolve_provider(require_references=True)
available = True
except GenerationError:
available = False
try:
providers = list_sprite_providers()
except Exception as exc: # noqa: BLE001 - picker is best-effort
logger.debug("pet provider list failed: %s", exc)
providers = []
return _ok(rid, {"available": available, "providers": providers})
except Exception as exc: # noqa: BLE001 - never break the surface
logger.debug("pet.generate.status failed: %s", exc)
return _ok(rid, {"available": False, "providers": []})
@method("pet.generate")
def _(rid, params: dict) -> dict:
"""Generate candidate base looks for a new pet (the draft/variant step).
Params: ``prompt`` (required unless ``referenceImage`` is given), ``count``
(default 4), ``style`` (default ``auto``), ``referenceImage`` (optional data
URL a user photo/reference every draft is grounded on, e.g. to make *their*
pet). Returns ``{ok, token, drafts:[{index, dataUri}]}`` the token keys the
staged base images for a later ``pet.hatch``. Heavy (network): worker pool.
"""
prompt = str(params.get("prompt") or "").strip()
ref_raw = str(params.get("referenceImage") or "").strip()
if not prompt and not ref_raw:
return _err(rid, 4004, "missing prompt")
try:
count = max(1, min(4, int(params.get("count") or 4)))
except (TypeError, ValueError):
count = 4
style = str(params.get("style") or "auto").strip() or "auto"
try:
import shutil
import uuid
from agent.pet.generate import generate_base_drafts
from agent.pet.generate.imagegen import GenerationError, resolve_provider
root = _pet_gen_root()
_pet_gen_sweep(root)
# Token up front so each draft can be staged + streamed the moment it
# lands, instead of the user staring at a blank grid until all N finish.
token = uuid.uuid4().hex[:12]
_pet_cancel_arm(token)
stage = root / token
stage.mkdir(parents=True, exist_ok=True)
reference_images = None
if ref_raw:
try:
reference_images = _pet_reference_images_from_data_url(ref_raw, stage)
except ValueError as exc:
_pet_cancel_release(token)
return _err(rid, 4004, str(exc))
# Optional desktop picker override: resolve the chosen provider up front so
# a bad/uncredentialed pick fails fast instead of mid-fan-out.
provider_name = str(params.get("provider") or "").strip()
sprite = None
if provider_name:
try:
sprite = resolve_provider(require_references=bool(reference_images), prefer=provider_name)
except GenerationError as exc:
_pet_cancel_release(token)
return _err(rid, 5031, str(exc))
concept = prompt or "a pet based on the reference image"
out: list[dict] = []
# Hand the token to the client up front (token-only init event) so a Stop
# fired before the first draft lands can still target this run.
try:
_emit("pet.generate.progress", "", {"token": token, "count": count})
except Exception as exc: # noqa: BLE001 - streaming is best-effort
logger.debug("pet.generate init emit failed: %s", exc)
def _on_draft(index: int, src) -> None:
dest = stage / f"draft-{index}.png"
try:
shutil.copyfile(src, dest)
data_uri = _pet_png_data_uri(dest)
except Exception as exc: # noqa: BLE001 - skip a bad draft, keep the rest
logger.debug("pet.generate draft %d failed: %s", index, exc)
return
out.append({"index": index, "dataUri": data_uri})
# Stream this draft to the client so the grid fills in live. Best-
# effort: a transport hiccup must not abort the generation itself.
try:
_emit(
"pet.generate.progress",
"",
{"token": token, "index": index, "dataUri": data_uri, "count": count},
)
except Exception as exc: # noqa: BLE001
logger.debug("pet.generate progress emit failed: %s", exc)
try:
generate_base_drafts(
concept,
n=count,
style=style,
reference_images=reference_images,
provider=sprite,
on_draft=_on_draft,
is_cancelled=lambda: _pet_is_cancelled(token),
)
except GenerationError as exc:
_pet_cancel_release(token)
return _err(rid, 5031, str(exc))
cancelled = _pet_is_cancelled(token)
_pet_cancel_release(token)
if cancelled:
return _err(rid, 5031, "generation cancelled")
if not out:
return _err(rid, 5031, "generation produced no usable drafts")
out.sort(key=lambda d: d["index"])
return _ok(rid, {"ok": True, "token": token, "drafts": out})
except Exception as exc: # noqa: BLE001
logger.debug("pet.generate failed: %s", exc)
return _err(rid, 5031, f"pet.generate failed: {exc}")
@method("pet.hatch")
def _(rid, params: dict) -> dict:
"""Turn a chosen base draft into a full pet — installed but NOT yet active.
Generation is expensive and the result varies, so hatch produces a *preview*
the surface plays (all frames) before the user commits: the pet is written to
the store (so it can be rendered + later activated) but the active pet is left
untouched. Adopt with ``pet.select`` or throw it away with ``pet.remove``.
Params: ``token`` + ``index`` (from ``pet.generate``), ``name`` (required),
``description`` (optional), ``prompt`` (optional concept for row prompts),
``style`` (optional). Returns ``{ok, slug, displayName, warnings, pet}`` where
``pet`` is the renderer payload. Heavy (network + raster): worker pool.
"""
token = str(params.get("token") or "").strip()
# Hatch cancellation rides its own key, not the generation token: hatching a
# draft mid-generation means pet.generate is still releasing `token`, which
# would otherwise wipe the arm we set here. Falls back to `token` for clients
# that don't send one.
cancel_token = str(params.get("cancelToken") or "").strip() or token
index = params.get("index", 0)
name = str(params.get("name") or "").strip()
if not token:
return _err(rid, 4004, "missing token")
if not name:
return _err(rid, 4004, "missing name")
try:
index = int(index)
except (TypeError, ValueError):
index = 0
try:
from agent.pet import store
from agent.pet.generate import hatch_pet
from agent.pet.generate.imagegen import GenerationError, resolve_provider
base = _pet_gen_root() / token / f"draft-{index}.png"
if not base.is_file():
return _err(rid, 4004, "draft expired — generate again")
# Optional desktop picker override (rows always need reference grounding).
provider_name = str(params.get("provider") or "").strip()
sprite = None
if provider_name:
try:
sprite = resolve_provider(require_references=True, prefer=provider_name)
except GenerationError as exc:
return _err(rid, 5031, str(exc))
_pet_cancel_arm(cancel_token)
slug = store.unique_slug(name)
def _on_progress(event: str, detail: str) -> None:
# Row progress is encoded as "<state>:<done>:<total>" so the egg
# screen can show "Drawing <state>… (n/total)"; other phases
# (compose, save) pass through as-is. Best-effort streaming.
payload: dict = {"event": event, "detail": detail}
if event == "row" and detail.count(":") == 2:
state, done, total = detail.split(":")
payload = {"event": "row", "state": state, "done": done, "total": total}
try:
_emit("pet.hatch.progress", "", payload)
except Exception as exc: # noqa: BLE001
logger.debug("pet.hatch progress emit failed: %s", exc)
try:
result = hatch_pet(
base_image=base,
slug=slug,
display_name=name,
description=str(params.get("description") or ""),
concept=str(params.get("prompt") or name),
style=str(params.get("style") or "auto").strip() or "auto",
provider=sprite,
on_progress=_on_progress,
is_cancelled=lambda: _pet_is_cancelled(cancel_token),
)
except GenerationError as exc:
return _err(rid, 5031, str(exc))
finally:
_pet_cancel_release(cancel_token)
pet = store.load_pet(result.slug)
payload = _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {}
return _ok(
rid,
{
"ok": True,
"slug": result.slug,
"displayName": result.display_name,
"warnings": result.validation.get("warnings", []),
"pet": payload,
},
)
except Exception as exc: # noqa: BLE001
logger.debug("pet.hatch failed: %s", exc)
return _err(rid, 5031, f"pet.hatch failed: {exc}")
@method("credits.view")
def _(rid, params: dict) -> dict:
"""Structured Nous credit view for the TUI /credits command.