From 32f837add1f5a07245de232d16892438fcebf4bf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 13:48:29 -0500 Subject: [PATCH 1/9] =?UTF-8?q?feat(pets):=20prompt=20=E2=86=92=20atlas=20?= =?UTF-8?q?sprite-generation=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn a text prompt into a petdex-spec spritesheet (8×9 grid of 192×208 cells), grounded so every animation row stays the same creature: - orchestrate: base drafts (distinct variation nudges) → per-row grounded generation → atlas compose; one image call per row, rows fan out in parallel. - atlas: frame-perfect registration in normalize_cells — 1-D cross-correlation of each frame's column-mass profile locks the body (robust to limbs/cape), one shared per-state scale, bottom-anchored; plus alpha-hole repair, gutter severing, and interior-seeded chroma-pocket clearing. - prompts: pixel-art-by-default style hints + registration constraints. - store: local pet write (register_local_pet), slugify/unique_slug, export_pet, slug-realigning rename_pet, createdBy provenance. --- agent/pet/generate/__init__.py | 29 ++ agent/pet/generate/atlas.py | 724 ++++++++++++++++++++++++++++++ agent/pet/generate/imagegen.py | 176 ++++++++ agent/pet/generate/orchestrate.py | 292 ++++++++++++ agent/pet/generate/prompts.py | 140 ++++++ agent/pet/manifest.py | 39 +- agent/pet/store.py | 160 +++++++ tests/agent/test_pet_generate.py | 413 +++++++++++++++++ 8 files changed, 1972 insertions(+), 1 deletion(-) create mode 100644 agent/pet/generate/__init__.py create mode 100644 agent/pet/generate/atlas.py create mode 100644 agent/pet/generate/imagegen.py create mode 100644 agent/pet/generate/orchestrate.py create mode 100644 agent/pet/generate/prompts.py create mode 100644 tests/agent/test_pet_generate.py diff --git a/agent/pet/generate/__init__.py b/agent/pet/generate/__init__.py new file mode 100644 index 00000000000..b75a03cd985 --- /dev/null +++ b/agent/pet/generate/__init__.py @@ -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", +] diff --git a/agent/pet/generate/atlas.py b/agent/pet/generate/atlas.py new file mode 100644 index 00000000000..8559ddb530d --- /dev/null +++ b/agent/pet/generate/atlas.py @@ -0,0 +1,724 @@ +"""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 + + 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 + + visited = 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() + px[x, y] = (0, 0, 0, 0) + 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)) + return rgba + + +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: + sprite = sprite.resize( + (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))), + Image.Resampling.LANCZOS, + ) + 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) + + segments: list[tuple[int, int, int]] = [] # (left, right, mass) + start = mass = 0 + started = False + for x, v in enumerate(profile + [0]): + if v > 2: + if not started: + start, mass, started = x, 0, True + mass += v + elif started: + segments.append((start, x, mass)) + started = False + + if len(segments) < 2: + return rgba + keep_mass = max(m for _, _, m in segments) * _SIDE_LOBE_RATIO + keep = [(l, r) for l, r, m in segments if m >= keep_mass] + if len(keep) == len(segments): + 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 _connected_components(image) -> list[dict]: + """Flood-fill the alpha mask into connected blobs (4-connectivity).""" + alpha = image.getchannel("A") + w, h = image.size + data = alpha.tobytes() + visited = bytearray(w * h) + out: list[dict] = [] + + for start, a in enumerate(data): + if a <= _ALPHA_FLOOR or visited[start]: + continue + stack = [start] + visited[start] = 1 + pixels: list[int] = [] + min_x = w + min_y = h + max_x = 0 + max_y = 0 + while stack: + cur = stack.pop() + pixels.append(cur) + x = cur % w + y = cur // w + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x) + max_y = max(max_y, y) + for nb, ok in ( + (cur - 1, x > 0), + (cur + 1, x + 1 < w), + (cur - w, y > 0), + (cur + w, y + 1 < h), + ): + if ok and not visited[nb] and data[nb] > _ALPHA_FLOOR: + visited[nb] = 1 + stack.append(nb) + out.append( + { + "pixels": pixels, + "area": len(pixels), + "bbox": (min_x, min_y, max_x + 1, max_y + 1), + "center_x": (min_x + max_x + 1) / 2, + } + ) + return out + + +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(2, min(8, round(slot * 0.02))) + 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 _segmentable(strip, frame_count: int) -> bool: + """True if the (gutter-severed) strip yields ≥ *frame_count* distinct blobs. + + Used only as a quality gate: a row that can't show this many separable poses + is a bad generation (caller retries / falls back), never silently sliced into + merged frames. + """ + components = _connected_components(strip) + if not components: + return False + largest = max(c["area"] for c in components) + seed_threshold = max(120, largest * 0.20) + return sum(1 for c in components if c["area"] >= seed_threshold) >= frame_count + + +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 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. + + Background is keyed out, the expected frame gutters are severed, then the + strip is sliced into equal columns. Connected components only *validate* that + the row holds *frame_count* separable poses (``components`` raises, ``auto`` + falls back to slicing the un-severed strip). + + *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) + severed = _sever_expected_gutters(strip, frame_count) + segmentable = _segmentable(severed, frame_count) + if method == "components" and not segmentable: + raise ValueError(f"could not segment {frame_count} sprites from strip") + + frames = _slot_crops(severed if segmentable else strip, frame_count) + 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** the registered frames through one shared window and apply + **one shared scale** + bottom-anchor, so size and baseline are uniform and + intra-state vertical motion (a jump's lift) is preserved. + """ + from PIL import Image + + blank = lambda: Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) + + out: dict[str, list] = {} + 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 + scale over the registered set; bottom-anchored, centered. + 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) + uw, uh = right - left, bottom - top + scale = min((CELL_WIDTH - pad) / uw, (CELL_HEIGHT - pad) / uh) + 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): + crop = crop.resize((sw, sh), Image.Resampling.LANCZOS) + 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, + } diff --git a/agent/pet/generate/imagegen.py b/agent/pet/generate/imagegen.py new file mode 100644 index 00000000000..1e01592e4bf --- /dev/null +++ b/agent/pet/generate/imagegen.py @@ -0,0 +1,176 @@ +"""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") + + +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) -> SpriteProvider: + """Pick the image provider to use for sprite work. + + Preference: the configured 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 + + # 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 OpenRouter, Nous " + "Portal, or OpenAI (gpt-image-2) with an API key." + ) + + +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", +) -> list[Path]: + """Generate *n* square sprite images and return their local paths. + + *reference_images* grounds the output on a base image (required for rows). + 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": "square", **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 diff --git a/agent/pet/generate/orchestrate.py b/agent/pet/generate/orchestrate.py new file mode 100644 index 00000000000..238c490a22a --- /dev/null +++ b/agent/pet/generate/orchestrate.py @@ -0,0 +1,292 @@ +"""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", ""). +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 +_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", + 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). + """ + sprite = provider or imagegen.resolve_provider(require_references=False) + 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]: + if cancelled(): + return index, 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, 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 + if not out: + logger.warning("pet generate: draft %d produced no image", index) + return index, None + logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0) + return index, _harden_transparency(out[0]) + + workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS)) + results: dict[int, Path] = {} + 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 = fut.result() + if path is None: + 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(): + raise GenerationError("image generation produced no usable drafts") + return drafts + + +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() + 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}", + ) + # One image call per row (the expensive part). ``auto`` validates by + # connected components with an equal-slot fallback; raw (fit=False) so + # normalize_cells registers the whole pet at once. We deliberately do + # NOT re-generate a ragged row — the registration pass salvages it far + # cheaper than another image-model round-trip. + frames = atlas.extract_strip_frames(strips[0], count, method="auto", fit=False) + logger.info("pet hatch %r: row %r ready in %.1fs", slug, state, time.monotonic() - t0) + return state, frames + except Exception as exc: # noqa: BLE001 - one bad row is tolerated (idle guaranteed) + logger.warning("pet hatch %r: row %r failed after %.1fs: %s", slug, state, time.monotonic() - t0, 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, + ) diff --git a/agent/pet/generate/prompts.py b/agent/pet/generate/prompts.py new file mode 100644 index 00000000000..c6afbc28313 --- /dev/null +++ b/agent/pet/generate/prompts.py @@ -0,0 +1,140 @@ +"""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(), "") + + +# 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", + "rounder, chunkier chibi proportions and a bigger head", + "a different face and expression, with unique accent/accessory details", + "a leaner, taller build and an alternate colour scheme", + "bolder, more saturated colours and a playful expression", +) + + +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 cute friendly mascot creature").strip() + nudge = f" Make this design distinct: {variation}." if variation else "" + return ( + f"A cute, characterful mascot pet: {concept}. " + "Compact, whole-body silhouette that reads clearly at small size, " + "appealing face, 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() + return ( + f"Using the attached reference image as the exact same character " + f"(same species, face, colors, markings, proportions, and props), " + f"draw a single horizontal strip of {frame_count} animation frames showing {action}. " + f"The {frame_count} poses must be evenly spaced left to right, each fully separated " + "by clear empty chroma-key gutters; silhouettes must NEVER touch, overlap, " + "share a shadow, share a ground line, share motion trails, or merge into " + "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)}" + ) diff --git a/agent/pet/manifest.py b/agent/pet/manifest.py index 94d539691dc..98a0e4a3f7e 100644 --- a/agent/pet/manifest.py +++ b/agent/pet/manifest.py @@ -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.""" diff --git a/agent/pet/store.py b/agent/pet/store.py index 8f9f8376865..42627c1ac81 100644 --- a/agent/pet/store.py +++ b/agent/pet/store.py @@ -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 ``) 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/.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 diff --git a/tests/agent/test_pet_generate.py b/tests/agent/test_pet_generate.py new file mode 100644 index 00000000000..1a3ad424bb8 --- /dev/null +++ b/tests/agent/test_pet_generate.py @@ -0,0 +1,413 @@ +"""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_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 + + +# ───────────────────────── 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"): + 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"): + # 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"): + # 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"): + 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"): + 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) + + +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] From 3faf768cdef055535298c669b6f46045c5572d58 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 13:48:38 -0500 Subject: [PATCH 2/9] feat(pets): OpenRouter + Nous Portal image backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference-grounded image provider over the OpenRouter-compatible chat-completions image protocol (Gemini Flash Image et al.). Nous Portal proxies OpenRouter, so one provider serves both — giving pet generation a reference-capable backend beyond OpenAI gpt-image. --- plugins/image_gen/openrouter/__init__.py | 414 ++++++++++++++++++ plugins/image_gen/openrouter/plugin.yaml | 7 + .../test_openrouter_compat_provider.py | 296 +++++++++++++ 3 files changed, 717 insertions(+) create mode 100644 plugins/image_gen/openrouter/__init__.py create mode 100644 plugins/image_gen/openrouter/plugin.yaml create mode 100644 tests/plugins/image_gen/test_openrouter_compat_provider.py diff --git a/plugins/image_gen/openrouter/__init__.py b/plugins/image_gen/openrouter/__init__.py new file mode 100644 index 00000000000..53c0a8b73da --- /dev/null +++ b/plugins/image_gen/openrouter/__init__.py @@ -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) diff --git a/plugins/image_gen/openrouter/plugin.yaml b/plugins/image_gen/openrouter/plugin.yaml new file mode 100644 index 00000000000..3e5c3ec9011 --- /dev/null +++ b/plugins/image_gen/openrouter/plugin.yaml @@ -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 diff --git a/tests/plugins/image_gen/test_openrouter_compat_provider.py b/tests/plugins/image_gen/test_openrouter_compat_provider.py new file mode 100644 index 00000000000..77724c82528 --- /dev/null +++ b/tests/plugins/image_gen/test_openrouter_compat_provider.py @@ -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 From aab49f6927cc7ff7aab40da8881727a18a8db4ac Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 13:48:38 -0500 Subject: [PATCH 3/9] feat(pets): generation RPCs, non-blocking gallery + gateway plumbing - pet.generate / pet.hatch (parallel rows, off the reader thread) + cooperative pet.cancel; pet.export / pet.rename. - pet.gallery localOnly fast path + background manifest prefetch so the picker never blocks on petdex; rename follows the active-pet config. - gateway request gains optional timeout + AbortSignal for real Stop. --- .../app/gateway/hooks/use-gateway-request.ts | 6 +- apps/shared/src/json-rpc-gateway.ts | 45 +- hermes_cli/pets.py | 20 + tests/tui_gateway/test_pet_generate_rpc.py | 180 ++++++++ tui_gateway/server.py | 412 ++++++++++++++++-- 5 files changed, 632 insertions(+), 31 deletions(-) create mode 100644 tests/tui_gateway/test_pet_generate_rpc.py diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 1addd3c1e7d..29b6cbd80c8 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -94,7 +94,7 @@ export function useGatewayRequest() { }, []) const requestGateway = useCallback( - async (method: string, params: Record = {}) => { + async (method: string, params: Record = {}, timeoutMs?: number, signal?: AbortSignal) => { const gateway = gatewayRef.current if (!gateway) { @@ -102,7 +102,7 @@ export function useGatewayRequest() { } try { - return await gateway.request(method, params) + return await gateway.request(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(method, params) + return recovered.request(method, params, timeoutMs, signal) } }, [ensureGatewayOpen] diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index af48290d71a..a138edbb1c2 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -217,29 +217,67 @@ export class JsonRpcGatewayClient { return () => this.stateHandlers.delete(handler) } - request(method: string, params: Record = {}, timeoutMs = this.options.requestTimeoutMs): Promise { + request( + method: string, + params: Record = {}, + timeoutMs = this.options.requestTimeoutMs, + signal?: AbortSignal + ): Promise { 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((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))) } }) diff --git a/hermes_cli/pets.py b/hermes_cli/pets.py index 1cb74c63411..7fcba082d02 100644 --- a/hermes_cli/pets.py +++ b/hermes_cli/pets.py @@ -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:") diff --git a/tests/tui_gateway/test_pet_generate_rpc.py b/tests/tui_gateway/test_pet_generate_rpc.py new file mode 100644 index 00000000000..99d65b3d85a --- /dev/null +++ b/tests/tui_gateway/test_pet_generate_rpc.py @@ -0,0 +1,180 @@ +"""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_returns_token_and_previews(monkeypatch, tmp_path): + import agent.pet.generate as gen + + def fake_drafts(prompt, *, n=4, style="auto", 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", 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", 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" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9832be00ed8..f29ef972017 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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,49 @@ def _pet_frame_counts(spritesheet) -> dict: 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 + + raw = pet.spritesheet.read_bytes() + suffix = pet.spritesheet.suffix.lower() + mime = "image/png" if suffix == ".png" else "image/webp" + return { + "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": scale, + "stateRows": _pet_state_rows(pet.spritesheet), + } + + def _pet_state_rows(spritesheet) -> list[str]: """Row taxonomy for the concrete active pet sheet. @@ -5610,8 +5657,6 @@ 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 @@ -5631,26 +5676,8 @@ def _(rid, params: dict) -> dict: 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, - "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 = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) + 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}) @@ -5770,7 +5797,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 +5820,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 +5839,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 +5849,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 +5928,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 +6063,259 @@ 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() + + +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") +def _(rid, params: dict) -> dict: + """Generate candidate base looks for a new pet (the draft/variant step). + + Params: ``prompt`` (required), ``count`` (default 4), ``style`` (default + ``auto``). Returns ``{ok, token, drafts:[{index, dataUri}]}`` — the token + keys the staged base images for a later ``pet.hatch``. Retry == call again + (fresh token). Heavy (network): runs on the worker pool. + """ + prompt = str(params.get("prompt") or "").strip() + if not prompt: + 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 + + 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) + 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( + prompt, + n=count, + style=style, + 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() + 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 + + base = _pet_gen_root() / token / f"draft-{index}.png" + if not base.is_file(): + return _err(rid, 4004, "draft expired — generate again") + + _pet_cancel_arm(token) + slug = store.unique_slug(name) + + def _on_progress(event: str, detail: str) -> None: + # Row progress is encoded as "::" so the egg + # screen can show "Drawing … (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", + on_progress=_on_progress, + is_cancelled=lambda: _pet_is_cancelled(token), + ) + except GenerationError as exc: + return _err(rid, 5031, str(exc)) + finally: + _pet_cancel_release(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. From 743985bf1ec4c911cd5af7bec705a419d8cdd61b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 13:48:45 -0500 Subject: [PATCH 4/9] =?UTF-8?q?feat(pets):=20Pok=C3=A9dex=20generate=20UI?= =?UTF-8?q?=20=E2=80=94=20overlay,=20animated=20egg,=20hatch=20FX,=20manag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated generate modal (Cmd-K → Pets → Generate): prompt → 2×2 draft grid → egg hatch → preview → adopt, width fits each phase. - Reuses shared primitives (Button/Input/Dialog/Alert/GenerateButton); cards use selectableCardClass; only canvas + range stay raw. - Animated creme pixel egg + PetStarShower hatch celebration (canvas). - Live streamed drafts with a real Stop (AbortSignal); clean default name. - Manage generated pets: badge + top ranking, rename (optimistic), safe delete (confirm + drop), export — in both the Cmd-K and Settings lists. - pet-gallery routes every RPC through profile-scoped petRpc; i18n ×5. --- .../desktop/src/app/command-palette/index.tsx | 13 +- .../app/command-palette/pet-palette-page.tsx | 33 +- apps/desktop/src/app/desktop-controller.tsx | 2 + .../app/pet-generate/pet-generate-overlay.tsx | 469 ++++++++++++++++ .../desktop/src/app/settings/pet-settings.tsx | 156 +++++- .../src/components/pet/pet-egg-hatch.tsx | 66 +++ .../src/components/pet/pet-egg-sheet.png | Bin 0 -> 1797 bytes .../desktop/src/components/pet/pet-sprite.tsx | 47 +- .../src/components/pet/pet-star-shower.tsx | 204 +++++++ .../src/components/pet/pixel-egg-sprite.tsx | 234 ++++++++ .../src/components/ui/generate-button.tsx | 62 +++ apps/desktop/src/i18n/en.ts | 34 ++ apps/desktop/src/i18n/ja.ts | 34 ++ apps/desktop/src/i18n/types.ts | 34 ++ apps/desktop/src/i18n/zh-hant.ts | 34 ++ apps/desktop/src/i18n/zh.ts | 34 ++ apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/store/pet-gallery.ts | 163 +++++- apps/desktop/src/store/pet-generate.ts | 527 ++++++++++++++++++ apps/desktop/src/styles.css | 232 ++++++++ 20 files changed, 2353 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx create mode 100644 apps/desktop/src/components/pet/pet-egg-hatch.tsx create mode 100644 apps/desktop/src/components/pet/pet-egg-sheet.png create mode 100644 apps/desktop/src/components/pet/pet-star-shower.tsx create mode 100644 apps/desktop/src/components/pet/pixel-egg-sprite.tsx create mode 100644 apps/desktop/src/components/ui/generate-button.tsx create mode 100644 apps/desktop/src/store/pet-generate.ts diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 84c75b1c150..0f94ed13945 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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() { {/* Server-driven pages render their own list; the rest show groups. */} {page === 'pets' ? ( - + { closeCommandPalette(); openPetGenerate() }} search={search} /> ) : page === 'install-theme' ? ( ) : ( diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx index 891637c67cb..9e75b666ef6 100644 --- a/apps/desktop/src/app/command-palette/pet-palette-page.tsx +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -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 (
+ {onGenerate && ( + + )} + {error &&

{error}

} {shown.length === 0 ? ( @@ -104,7 +124,14 @@ export function PetPalettePage({ search }: PetPalettePageProps) { url={pet.spritesheetUrl} /> - {pet.displayName} + + {pet.displayName} + {pet.generated && ( + + {copy.generatedTag} + + )} + {pet.slug} {pet.installed ? ` · ${copy.installed}` : ''} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ac965299bdd..8a039f41710 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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() { + {settingsOpen && ( diff --git a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx new file mode 100644 index 00000000000..2ba12a22bc0 --- /dev/null +++ b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx @@ -0,0 +1,469 @@ +/** + * "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. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetEggHatch } from '@/components/pet/pet-egg-hatch' +import { PetStarShower } from '@/components/pet/pet-star-shower' +import { PetSprite } from '@/components/pet/pet-sprite' +import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, 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, Loader2, PawPrint, RefreshCw } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' +import { cn } from '@/lib/utils' +import { type PetInfo } from '@/store/pet' +import { + $petGenDrafts, + $petGenerateOpen, + $petGenError, + $petGenPreview, + $petGenSelected, + $petGenStage, + $petGenStatus, + adoptHatched, + cancelGenerate, + cancelHatch, + cleanPetName, + cleanupPetGen, + closePetGenerate, + discardHatched, + generateDrafts, + hatchSelected +} from '@/store/pet-generate' + +const VARIANT_COUNT = 4 +const PREVIEW_SCALE = 0.7 +const PREVIEW_ROWS = [ + 'idle', + 'waving', + 'running-right', + 'running-left', + 'running', + 'review', + 'jumping', + 'failed', + 'waiting' +] +const PREVIEW_STATE_MS = 1400 + +const ROW_TO_FRAME_KEY: Record = { + 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' +} + +function frameCountForRow(pet: PetInfo, row: string): number { + const byState = pet.framesByState + const mapped = ROW_TO_FRAME_KEY[row] + return byState?.[row] ?? (mapped ? byState?.[mapped] : undefined) ?? pet.framesPerState ?? 0 +} + +export function PetGenerateOverlay() { + const open = useStore($petGenerateOpen) + const status = useStore($petGenStatus) + const { requestGateway } = useGatewayRequest() + + const handleOpenChange = (next: boolean) => { + if (!next) { + // Deletes a hatched-but-unadopted preview pet so it doesn't linger, then + // resets all generation state. + cleanupPetGen(requestGateway) + 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. + const single = status === 'hatching' || status === 'preview' || status === 'adopting' + + return ( + + + {open && } + + + ) +} + +function PetGenerateContent() { + const { t } = useI18n() + const copy = t.commandCenter.generatePet + const { requestGateway } = useGatewayRequest() + + const status = useStore($petGenStatus) + const error = useStore($petGenError) + const drafts = useStore($petGenDrafts) + const selected = useStore($petGenSelected) + const preview = useStore($petGenPreview) + const stage = useStore($petGenStage) + + const [prompt, setPrompt] = useState('') + + 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-7.5) from the prompt; the working states stay compact. + const isEmptyState = + !hasDrafts && + !generating && + status !== 'hatching' && + status !== 'preview' && + status !== 'adopting' && + status !== 'stale' + + const close = () => { + cleanupPetGen(requestGateway) + closePetGenerate() + } + + const generate = () => { + if (prompt.trim() && !busy) { + void generateDrafts(requestGateway, { prompt: prompt.trim() }) + } + } + + // One-click an example prompt straight into a draft round. + const runExample = (example: string) => { + setPrompt(example) + void generateDrafts(requestGateway, { prompt: example }) + } + + // Hatch with a clean default name derived from the prompt (the prompt itself + // is grounding text, not a label); the user names it on the reveal screen. + const hatch = () => { + if (prompt.trim()) { + void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() }) + } + } + + const adopt = (finalName: string) => { + void adoptHatched(requestGateway, finalName).then(out => { + if (out.ok) { + triggerHaptic('crisp') + close() + } + }) + } + + // 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 + // Prompt input only belongs on the describe/draft screens. + const showPrompt = status !== 'hatching' && status !== 'preview' && status !== 'adopting' + + return ( + <> + + {headerTitle} + + +
+ {/* Concept prompt with the inline sparkle generate/stop affordance (the + same primitive as the commit-message + project-idea fields). */} + {showPrompt && ( +
+ setPrompt(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + generate() + } + }} + placeholder={copy.placeholder} + value={prompt} + /> + +
+ )} + + {error && status !== 'preview' && status !== 'adopting' && ( + + {error} + + )} + + {status === 'stale' ? ( + + {copy.staleBackend} + + ) : status === 'hatching' ? ( + + ) : (status === 'preview' || status === 'adopting') && preview ? ( + void discardHatched(requestGateway)} + pet={preview} + /> + ) : !hasDrafts && !generating ? ( + + ) : ( + $petGenSelected.set(index)} + selected={selected} + /> + )} +
+ + ) +} + +// Creative seed prompts — specifics make better pets (petdex's own advice). +// Doubling as guidance and a one-click way to see the flow. +const EXAMPLE_PROMPTS = ['a bubble-tea otter', 'a tiny sock elf', 'a pixel dragon', 'a grumpy office cat', 'a neon axolotl'] + +function EmptyHint({ onExample }: { onExample: (prompt: string) => void }) { + return ( +
+

Need a spark?

+
+ {EXAMPLE_PROMPTS.map(example => ( + + ))} +
+
+ ) +} + +function HatchingView({ stage }: { stage: { phase: string; state?: string; done?: number; total?: number } | null }) { + 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 +} + +interface DraftGridProps { + busy: boolean + drafts: { index: number; dataUri: string }[] + generating: boolean + hasDrafts: boolean + onHatch: () => void + onSelect: (index: number) => void + selected: number | null +} + +function DraftGrid({ busy, drafts, generating, hasDrafts, 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 ( +
+ {generating && ( +
+ {copy.generating} + + {drafts.length}/{VARIANT_COUNT} + +
+ )} + +
+ {slots.map((draft, i) => { + const isSelected = !generating && draft != null && selected === draft.index + + return ( + + ) + })} +
+ + {hasDrafts && ( + + )} +
+ ) +} + +interface HatchPreviewProps { + pet: PetInfo + adopting: boolean + error: string | null + onAdopt: (name: string) => void + onDiscard: () => void +} + +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 ( +
+ {/* Fills the (now narrow) dialog so the pet frame is the screen width. */} +
+ {revealed ? ( + <> +
+ +
+ + + ) : ( + // The egg cracks open, then we swap in the live pet. + { + setRevealed(true) + triggerHaptic('crisp') + }} + size={150} + /> + )} +
+ + setName(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + onAdopt(name) + } + }} + placeholder={copy.namePlaceholder} + value={name} + /> + + {error && ( + + {error} + + )} + +
+ + +
+
+ ) +} + diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx index e9b0e925ce1..2990a7cdc37 100644 --- a/apps/desktop/src/app/settings/pet-settings.tsx +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -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(null) + const [renameTarget, setRenameTarget] = useState(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} /> - - {pet.displayName} + + + {pet.displayName} + + {pet.generated && ( + + {copy.generatedTag} + + )} {pet.slug} @@ -152,16 +186,36 @@ export function PetSettings() { {isBusy && } - {pet.installed && !isBusy && ( - + {!isBusy && (pet.installed || pet.generated) && ( +
+ {pet.generated && ( + } + label={copy.rename(pet.displayName)} + onClick={() => { + setRenameValue(pet.displayName) + setRenameTarget(pet) + }} + /> + )} + {pet.generated && ( + } + 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. + } + label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)} + onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))} + /> + )} +
)}
) @@ -226,6 +280,80 @@ export function PetSettings() { /> )} + + 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) : ''} + /> + + !open && setRenameTarget(null)} open={renameTarget !== null}> + + + {copy.renameTitle} + + setRenameValue(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + saveRename() + } + }} + placeholder={copy.renamePlaceholder} + value={renameValue} + /> + + + + + + ) } + +/** 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 ( + + ) +} diff --git a/apps/desktop/src/components/pet/pet-egg-hatch.tsx b/apps/desktop/src/components/pet/pet-egg-hatch.tsx new file mode 100644 index 00000000000..a677d84b13c --- /dev/null +++ b/apps/desktop/src/components/pet/pet-egg-hatch.tsx @@ -0,0 +1,66 @@ +/** + * 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 0→100). + */ +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 ( +
+ {determinate ? ( +
+ ) : ( +
+ )} +
+ ) +} + +export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) { + return ( +
+
+ + +
+ + {subtitle && ( +

+ {subtitle} +

+ )} + + {onCancel && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-egg-sheet.png b/apps/desktop/src/components/pet/pet-egg-sheet.png new file mode 100644 index 0000000000000000000000000000000000000000..128958eeec38667555a2af9e533cb823f1c45e35 GIT binary patch literal 1797 zcmV+g2m1JlP)Px*zez+vRCr$PT~U(UDiE7J-R|*TRnC&C-P7e&CgUlarIFNt8_#w=V{C+InwB7w zw_EU^x8VM73Bbd^A24mwH>Jjpj>7=lJO2FqY!dMC@v%Lp21hm8k30eZHS&@cmI;P| zFaXt#-{0TwgZ=vYqH-8wqQ$^k0MwCh^bBL*OaQK+LjhqiQHy~D@LCLf{{DRrkNszju43+Hqmgl8vibnOn6`q#@Ky6Mc6AA-i09+!K zWrAT~O#mqAPG{f@094TR0+AgO)S+rtC4gig8JNZZlI4st>Pkdm091`QdM2|!{(?U-R;833#=;F9TzE>DC4e3V^8UafzEJ&d1*2yQ!Y(jSo0H0f!ax`Rmr7=tU>H~v07|jb88`y~ z6?DBoXoCWEWIabykpPl`WMCQt$QfajQCA`g1E6Ze;ZraYz%t^LiUoD#Oc0j)OWq$~ zeFCP;d$KDve!PWz0_mGlV{cK?dM^Pa1D9kVd>MuTXc`mS&jKJJ;&LrY{$)dU?u>{m?&K`>x#$iR{vpMxh5>FnbQEUp3pP~ z&ICXeq3WJ|{rdwb*fvJ2cHIn|4#1jYR<2iE^lI{h_Xkvm#F~oTcMg3{JG^FP`s)H< z#p*N$dH_hiz_%cM+!@*a-uj;#n0mmKlG%|n03;^VjxPm(^#v$z*wu*i1zg|uC?mBL zXtqxPmu4XE58R_W?+>WwgsyD0&xFhT`+`lXy}kWg$*Lyo-VMV6c$JVR1}KN*S%yE3 zc1Kt=u9B=AAp#;hB2m_#sQ&tr-d7$bEBY8d?0O}A0<**<` z9a+!A0Ob9FRL95{VDi8+^1KPe>kB9hECYb`2_(5EH70!m>6=nxZ#r(hmjIH1OEM64 zd>@{O^8P?Iq0KzLr|N;30P_C8fq^rR@2hivCIFW+g0BB}pPr*)WIBL`>~3O1P6>T- zdO84WSgc>b8WnkefQ|vQ3d|{s`T+!1Gfrcm2LQDVBg)eud0mG@SdNtFp&F6CK#7g| zHq}TNwcPX)04yzNftOCz83w`tG_|00PxyAb9o`#m*P{=P0KgtpHe-N2OO|&-m4R(t zGG)0-3sz#`5VVMiJp*e2V5Jzl8my@>88{O_mljkRD4F=nz?uLADaP&z_raH1(PZFs z0F8~D*|3;8pt`9&8HfbHpx?JaUa?e~dFY)EpxHtC18TsadjxVla2WtS^4zsIl+5%1 zNPnPYlbRBiaw$#jRWV9HGXWf8LRf}Wnx`72KOoR9H)W z(Dq(GP{q*JhR94(d#kix1AxaLV3HL>P&Gv_A~(jQ2F0Nt`re}LHwsAW0R nph$lJZBAX!nHcEm0KNP_3jvkS7Ad = { waiting: ['waiting'] } +const ROW_TO_STATE: Record = { + 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,9 +72,20 @@ 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(null) const stateRef = useRef($petState.get()) + const overrideRef = useRef(stateOverride) + const rowOverrideRef = useRef(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 @@ -116,6 +150,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 +158,16 @@ 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, 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) // 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 diff --git a/apps/desktop/src/components/pet/pet-star-shower.tsx b/apps/desktop/src/components/pet/pet-star-shower.tsx new file mode 100644 index 00000000000..ad5552cd1ff --- /dev/null +++ b/apps/desktop/src/components/pet/pet-star-shower.tsx @@ -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(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 +} diff --git a/apps/desktop/src/components/pet/pixel-egg-sprite.tsx b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx new file mode 100644 index 00000000000..7d3b6fa55a7 --- /dev/null +++ b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx @@ -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 0–5 are the intact squash/stretch bounce; 6–11 are the crack/hatch. + * `mode="bounce"` loops 0–5 (never shows a crack); `mode="hatch"` plays 6–11 + * 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 | null = null + +function loadSheet(): Promise { + 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(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 ( + + ) +} diff --git a/apps/desktop/src/components/ui/generate-button.tsx b/apps/desktop/src/components/ui/generate-button.tsx new file mode 100644 index 00000000000..80cb19172a3 --- /dev/null +++ b/apps/desktop/src/components/ui/generate-button.tsx @@ -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, '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 ( + + + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8a1a295ce92..fab233cd7ff 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -389,11 +389,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.' @@ -760,10 +772,32 @@ 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 every frame to life — this takes a moment.', + hatched: 'It hatched!', + hatchRow: (state, done, total) => `Drawing ${state}… ${done}/${total}`, + hatchComposing: 'Composing the spritesheet…', + hatchSaving: 'Saving your pet…', + namePlaceholder: 'Name your pet', + staleBackend: 'Update Hermes to generate pets.', + adopt: 'Adopt', + startOver: 'Start over' + }, installTheme: { title: 'Install theme...', placeholder: 'Search the VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ad1bf090657..e1c748c5ee6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -304,11 +304,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: 'ペットをオフにできませんでした。' @@ -880,10 +892,32 @@ 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) => `${state} を描画中… ${done}/${total}`, + hatchComposing: 'スプライトシートを合成中…', + hatchSaving: 'ペットを保存中…', + namePlaceholder: 'ペットに名前を付ける', + staleBackend: 'ペットを生成するには Hermes を更新してください。', + adopt: '迎え入れる', + startOver: 'やり直す' + }, installTheme: { title: 'テーマをインストール...', placeholder: 'VS Code Marketplace を検索...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 411c7d5847f..9d1e213b97d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -284,11 +284,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 @@ -635,10 +647,32 @@ 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 + adopt: string + startOver: string + } installTheme: { title: string placeholder: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index d5500570906..eb6e2ff7ead 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -291,11 +291,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: '無法關閉寵物。' @@ -850,10 +862,32 @@ 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) => `正在繪製 ${state}…… ${done}/${total}`, + hatchComposing: '正在合成精靈表……', + hatchSaving: '正在儲存你的寵物……', + namePlaceholder: '為寵物命名', + staleBackend: '請更新 Hermes 以生成寵物。', + adopt: '領養', + startOver: '重新開始' + }, installTheme: { title: '安裝主題...', placeholder: '搜尋 VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6423e1749a9..effbaf328f8 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -379,11 +379,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: '无法关闭宠物。' @@ -947,10 +959,32 @@ 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) => `正在绘制 ${state}…… ${done}/${total}`, + hatchComposing: '正在合成精灵表……', + hatchSaving: '正在保存你的宠物……', + namePlaceholder: '给宠物起个名字', + staleBackend: '请更新 Hermes 以生成宠物。', + adopt: '领养', + startOver: '重新开始' + }, installTheme: { title: '安装主题...', placeholder: '搜索 VS Code Marketplace...', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 9e07f529ce6..8e052bd76fc 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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, diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts index a9a23734b8b..d5aa9ea7d52 100644 --- a/apps/desktop/src/store/pet-gallery.ts +++ b/apps/desktop/src/store/pet-gallery.ts @@ -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 = (method: string, params?: Record) => Promise +export type GatewayRequest = ( + method: string, + params?: Record, + timeoutMs?: number, + signal?: AbortSignal +) => Promise /** 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(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(request, 'pet.gallery', { localOnly: true }), petRpc(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(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 { } } +/** + * 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 { + 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 { + $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 { + 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 { 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 }] + }) })) }) } diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts new file mode 100644 index 00000000000..29efe3d5f0e --- /dev/null +++ b/apps/desktop/src/store/pet-generate.ts @@ -0,0 +1,527 @@ +import { atom } from 'nanostores' + +import { $gateway } from '@/store/gateway' +import { type PetInfo } from '@/store/pet' +import { type GatewayRequest, applyAdoptedPet } from '@/store/pet-gallery' + +/** + * 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('idle') +export const $petGenStage = atom(null) +export const $petGenError = atom(null) + +/** Whether the dedicated "Generate a pet" Pokédex overlay is open. */ +export const $petGenerateOpen = atom(false) + +export function openPetGenerate(): void { + // Always open on a clean slate — don't resurface the last run's drafts/preview. + resetPetGen() + $petGenerateOpen.set(true) +} + +export function closePetGenerate(): void { + $petGenerateOpen.set(false) +} +export const $petGenToken = atom(null) +/** Prompt that produced the current draft token; hatch uses this for consistency. */ +export const $petGenPrompt = atom('') +export const $petGenDrafts = atom([]) +export const $petGenSelected = atom(null) +/** The hatched-but-unadopted pet: its renderer payload, played in the preview. */ +export const $petGenPreview = atom(null) + +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 (on close, or 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) +} + +/** + * Reset on palette close, deleting an unadopted preview pet first so a hatched- + * but-never-adopted creature doesn't linger in the gallery. Fire-and-forget. + */ +export function cleanupPetGen(request: GatewayRequest): void { + const preview = $petGenPreview.get() + + if ($petGenStatus.get() === 'preview' && preview?.slug) { + void request('pet.remove', { slug: preview.slug }).catch(() => {}) + } + + resetPetGen() +} + +interface GenerateOptions { + prompt: string + style?: string + count?: number +} + +// 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) +} + +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 { + const prompt = options.prompt.trim() + + if (!prompt) { + 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('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 + }, + 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) + $petGenPrompt.set(prompt) + $petGenDrafts.set(result.drafts) + $petGenSelected.set(result.drafts[0]?.index ?? 0) + $petGenStatus.set('ready') + + 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.') + } + + 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 { + 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 + } + + const hatchRunId = hatch.begin() + const controller = new AbortController() + hatch.arm(() => { + controller.abort() + void request('pet.cancel', { token }).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, + index, + name, + description: options.description ?? '', + prompt: concept, + style: options.style ?? 'auto' + }, + 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') + + 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.') + + 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 { + 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 { + 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') +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 58221224fbd..bd584237eea 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1426,3 +1426,235 @@ 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%; + background: radial-gradient(circle, color-mix(in srgb, #000 32%, transparent) 0%, transparent 72%); + animation: pet-egg-shadow 2.4s ease-in-out infinite; +} + +/* Contact shadow sized for the compact incubator egg (roughly its footprint). */ +.pet-egg-shadow--sm { + width: 3rem; + height: 0.6rem; +} + +/* 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; + } +} From b674f7ba28c40d8ee71583f5cb05ac4f2fea7033 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 14:10:19 -0500 Subject: [PATCH 5/9] feat(pets): offer backend setup when generation is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no reference-capable image backend is configured, generating a pet is impossible — so instead of a dead prompt + post-hoc error, the overlay now detects it up front and offers a way out: - pet.generate.status RPC reports whether a reference-capable provider (OpenRouter / Nous Portal / OpenAI) is set up; the overlay probes it on open and swaps the prompt for a friendly setup card (paw, one-line copy, "Set up image generation" → /settings?tab=providers, key links). - useRouteOverlayActive(): reusable hook so any portaled modal yields the screen to a full-screen route overlay (e.g. settings) and reappears — re-running its mount effects — on return, instead of closing. The probe re-runs on that remount, so adding a key flips the card to the prompt. --- .../src/app/hooks/use-route-overlay-active.ts | 19 ++++ .../app/pet-generate/pet-generate-overlay.tsx | 97 +++++++++++++++++-- apps/desktop/src/store/pet-generate.ts | 16 +++ tui_gateway/server.py | 21 ++++ 4 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/app/hooks/use-route-overlay-active.ts diff --git a/apps/desktop/src/app/hooks/use-route-overlay-active.ts b/apps/desktop/src/app/hooks/use-route-overlay-active.ts new file mode 100644 index 00000000000..261f9de15d5 --- /dev/null +++ b/apps/desktop/src/app/hooks/use-route-overlay-active.ts @@ -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)) +} diff --git a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx index 2ba12a22bc0..954dac23bfd 100644 --- a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx +++ b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx @@ -10,8 +10,11 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { SETTINGS_ROUTE } from '@/app/routes' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' import { PetEggHatch } from '@/components/pet/pet-egg-hatch' import { PetStarShower } from '@/components/pet/pet-star-shower' import { PetSprite } from '@/components/pet/pet-sprite' @@ -22,12 +25,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { GenerateButton } from '@/components/ui/generate-button' import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' +import { ExternalLink } from '@/lib/external-link' import { triggerHaptic } from '@/lib/haptics' -import { Egg, Loader2, PawPrint, RefreshCw } from '@/lib/icons' +import { Egg, Loader2, PawPrint, RefreshCw, Settings2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { type PetInfo } from '@/store/pet' import { + $petGenAvailable, $petGenDrafts, $petGenerateOpen, $petGenError, @@ -38,6 +43,7 @@ import { adoptHatched, cancelGenerate, cancelHatch, + checkPetGenAvailable, cleanPetName, cleanupPetGen, closePetGenerate, @@ -87,6 +93,13 @@ export function PetGenerateOverlay() { const status = useStore($petGenStatus) const { requestGateway } = useGatewayRequest() + // 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) { // Deletes a hatched-but-unadopted preview pet so it doesn't linger, then @@ -116,9 +129,14 @@ 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) @@ -126,6 +144,13 @@ function PetGenerateContent() { const [prompt, setPrompt] = useState('') + // 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' @@ -176,14 +201,23 @@ function PetGenerateContent() { // 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 - // Prompt input only belongs on the describe/draft screens. - const showPrompt = status !== 'hatching' && status !== 'preview' && status !== 'adopting' + // 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 ( <> - - {headerTitle} - + {unavailable ? ( + {copy.title} + ) : ( + + {headerTitle} + + )}
{/* Concept prompt with the inline sparkle generate/stop affordance (the @@ -215,13 +249,15 @@ function PetGenerateContent() {
)} - {error && status !== 'preview' && status !== 'adopting' && ( + {error && !unavailable && status !== 'preview' && status !== 'adopting' && ( {error} )} - {status === 'stale' ? ( + {unavailable ? ( + + ) : status === 'stale' ? ( {copy.staleBackend} @@ -257,6 +293,51 @@ function PetGenerateContent() { // Doubling as guidance and a one-click way to see the flow. const EXAMPLE_PROMPTS = ['a bubble-tea otter', 'a tiny sock elf', 'a pixel dragon', 'a grumpy office cat', 'a neon axolotl'] +// 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. +function GenerateUnavailable({ onSetup }: { onSetup: () => void }) { + return ( +
+ + + +
+

Add an image backend to generate

+

+ Hatching a custom pet needs a provider that can ground on a reference image. +

+
+ +

+ Grab a key from + + Nous Portal + + · + + OpenRouter + + · + + OpenAI + +

+
+ ) +} + function EmptyHint({ onExample }: { onExample: (prompt: string) => void }) { return (
diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts index 29efe3d5f0e..bfcd7117d50 100644 --- a/apps/desktop/src/store/pet-generate.ts +++ b/apps/desktop/src/store/pet-generate.ts @@ -96,6 +96,22 @@ export const $petGenStatus = atom('idle') export const $petGenStage = atom(null) export const $petGenError = atom(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(null) + +/** Probe whether generation is possible (a reference-capable backend exists). */ +export async function checkPetGenAvailable(request: GatewayRequest): Promise { + try { + const res = await request<{ available: boolean }>('pet.generate.status') + $petGenAvailable.set(Boolean(res?.available)) + } 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) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f29ef972017..750a6840270 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6144,6 +6144,27 @@ def _(rid, params: dict) -> dict: 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 (OpenRouter / Nous Portal / + 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, resolve_provider + + try: + resolve_provider(require_references=True) + return _ok(rid, {"available": True}) + except GenerationError: + return _ok(rid, {"available": False}) + except Exception as exc: # noqa: BLE001 - never break the surface + logger.debug("pet.generate.status failed: %s", exc) + return _ok(rid, {"available": False}) + + @method("pet.generate") def _(rid, params: dict) -> dict: """Generate candidate base looks for a new pet (the draft/variant step). From 1fe013ee16f19f6390f2efce39397447e7ec2f67 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 19:08:06 -0500 Subject: [PATCH 6/9] feat(pets): polish generate flow and reduce hatch CPU pressure Ship the final pet-generation UX polish (provider picker behavior, step-2 cancel flow, banner integration, and visual consistency) and make saturated-chroma background removal C-op driven so hatch processing no longer hammers the machine during long runs. --- agent/pet/generate/atlas.py | 251 +++++---- agent/pet/generate/imagegen.py | 73 ++- agent/pet/generate/orchestrate.py | 122 +++- agent/pet/generate/prompts.py | 43 +- .../pet-generate/components/draft-grid.tsx | 89 +++ .../pet-generate/components/empty-hint.tsx | 27 + .../components/generate-unavailable.tsx | 52 ++ .../pet-generate/components/hatch-preview.tsx | 137 +++++ .../pet-generate/components/hatching-view.tsx | 24 + .../components/provider-picker.tsx | 53 ++ .../components/reference-chip.tsx | 48 ++ .../src/app/pet-generate/lib/frame-count.ts | 26 + .../pet-generate/lib/read-reference-image.ts | 49 ++ .../app/pet-generate/pet-generate-content.tsx | 291 ++++++++++ .../app/pet-generate/pet-generate-overlay.tsx | 525 +----------------- .../app/session/hooks/use-prompt-actions.ts | 13 + .../src/components/pet/floating-pet.tsx | 65 ++- .../src/components/pet/pet-egg-hatch.tsx | 8 +- .../desktop/src/components/pet/pet-sprite.tsx | 19 +- apps/desktop/src/components/ui/dialog.tsx | 99 +++- apps/desktop/src/i18n/en.ts | 12 +- apps/desktop/src/i18n/ja.ts | 12 +- apps/desktop/src/i18n/types.ts | 4 + apps/desktop/src/i18n/zh-hant.ts | 12 +- apps/desktop/src/i18n/zh.ts | 12 +- .../desktop/src/lib/desktop-slash-commands.ts | 2 + apps/desktop/src/store/pet-generate.ts | 139 ++++- apps/desktop/src/store/pet.ts | 6 + apps/desktop/src/styles.css | 21 +- cli.py | 3 + hermes_cli/cli_commands_mixin.py | 68 +++ hermes_cli/commands.py | 2 + tests/agent/test_pet_generate.py | 90 ++- tests/tui_gateway/test_pet_generate_rpc.py | 71 ++- tui_gateway/server.py | 274 +++++++-- 35 files changed, 2013 insertions(+), 729 deletions(-) create mode 100644 apps/desktop/src/app/pet-generate/components/draft-grid.tsx create mode 100644 apps/desktop/src/app/pet-generate/components/empty-hint.tsx create mode 100644 apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx create mode 100644 apps/desktop/src/app/pet-generate/components/hatch-preview.tsx create mode 100644 apps/desktop/src/app/pet-generate/components/hatching-view.tsx create mode 100644 apps/desktop/src/app/pet-generate/components/provider-picker.tsx create mode 100644 apps/desktop/src/app/pet-generate/components/reference-chip.tsx create mode 100644 apps/desktop/src/app/pet-generate/lib/frame-count.ts create mode 100644 apps/desktop/src/app/pet-generate/lib/read-reference-image.ts create mode 100644 apps/desktop/src/app/pet-generate/pet-generate-content.tsx diff --git a/agent/pet/generate/atlas.py b/agent/pet/generate/atlas.py index 8559ddb530d..2d316110e73 100644 --- a/agent/pet/generate/atlas.py +++ b/agent/pet/generate/atlas.py @@ -141,6 +141,8 @@ def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, """ from collections import deque + from PIL import Image, ImageChops + rgba = image.convert("RGBA") if _has_transparency(rgba): return _repair_internal_alpha_holes(rgba) @@ -153,7 +155,21 @@ def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, 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. @@ -181,7 +197,7 @@ def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, while queue: x, y = queue.popleft() - px[x, y] = (0, 0, 0, 0) + 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 @@ -189,7 +205,11 @@ def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, visited[idx] = 1 if _is_bg(nx, ny): queue.append((nx, ny)) - return rgba + + # 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): @@ -298,9 +318,13 @@ def _fit_to_cell(image): 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.LANCZOS, + Image.Resampling.NEAREST, ) left = (CELL_WIDTH - sprite.width) // 2 top = (CELL_HEIGHT - sprite.height) // 2 @@ -324,23 +348,13 @@ def _drop_side_bleed(image): w, h = rgba.size profile = _column_profile(rgba) # mean alpha per column (fast C resize) - segments: list[tuple[int, int, int]] = [] # (left, right, mass) - start = mass = 0 - started = False - for x, v in enumerate(profile + [0]): - if v > 2: - if not started: - start, mass, started = x, 0, True - mass += v - elif started: - segments.append((start, x, mass)) - started = False - - if len(segments) < 2: + runs = _content_runs(profile) + if len(runs) < 2: return rgba - keep_mass = max(m for _, _, m in segments) * _SIDE_LOBE_RATIO - keep = [(l, r) for l, r, m in segments if m >= keep_mass] - if len(keep) == len(segments): + 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). @@ -355,53 +369,6 @@ def _drop_side_bleed(image): return rgba -def _connected_components(image) -> list[dict]: - """Flood-fill the alpha mask into connected blobs (4-connectivity).""" - alpha = image.getchannel("A") - w, h = image.size - data = alpha.tobytes() - visited = bytearray(w * h) - out: list[dict] = [] - - for start, a in enumerate(data): - if a <= _ALPHA_FLOOR or visited[start]: - continue - stack = [start] - visited[start] = 1 - pixels: list[int] = [] - min_x = w - min_y = h - max_x = 0 - max_y = 0 - while stack: - cur = stack.pop() - pixels.append(cur) - x = cur % w - y = cur // w - min_x = min(min_x, x) - min_y = min(min_y, y) - max_x = max(max_x, x) - max_y = max(max_y, y) - for nb, ok in ( - (cur - 1, x > 0), - (cur + 1, x + 1 < w), - (cur - w, y > 0), - (cur + w, y + 1 < h), - ): - if ok and not visited[nb] and data[nb] > _ALPHA_FLOOR: - visited[nb] = 1 - stack.append(nb) - out.append( - { - "pixels": pixels, - "area": len(pixels), - "bbox": (min_x, min_y, max_x + 1, max_y + 1), - "center_x": (min_x + max_x + 1) / 2, - } - ) - return out - - def _sever_expected_gutters(strip, frame_count: int): """Cut thin vertical gutters at expected frame boundaries before labeling. @@ -418,7 +385,7 @@ def _sever_expected_gutters(strip, frame_count: int): out = strip.copy() px = out.load() slot = out.width / frame_count - half = max(2, min(8, round(slot * 0.02))) + 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) @@ -430,21 +397,6 @@ def _sever_expected_gutters(strip, frame_count: int): return out -def _segmentable(strip, frame_count: int) -> bool: - """True if the (gutter-severed) strip yields ≥ *frame_count* distinct blobs. - - Used only as a quality gate: a row that can't show this many separable poses - is a bad generation (caller retries / falls back), never silently sliced into - merged frames. - """ - components = _connected_components(strip) - if not components: - return False - largest = max(c["area"] for c in components) - seed_threshold = max(120, largest * 0.20) - return sum(1 for c in components if c["area"] >= seed_threshold) >= frame_count - - def _slot_crops(strip, frame_count: int) -> list: """Slice *strip* into *frame_count* uniform columns (one coordinate space). @@ -458,6 +410,61 @@ def _slot_crops(strip, frame_count: int) -> list: 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, @@ -468,10 +475,15 @@ def extract_strip_frames( ) -> list: """Turn one generated row strip into *frame_count* frames. - Background is keyed out, the expected frame gutters are severed, then the - strip is sliced into equal columns. Connected components only *validate* that - the row holds *frame_count* separable poses (``components`` raises, ``auto`` - falls back to slicing the un-severed strip). + 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 @@ -487,12 +499,29 @@ def extract_strip_frames( strip = strip.convert("RGBA") strip = remove_background(strip, chroma_key=chroma_key) - severed = _sever_expected_gutters(strip, frame_count) - segmentable = _segmentable(severed, frame_count) - if method == "components" and not segmentable: - raise ValueError(f"could not segment {frame_count} sprites from strip") - frames = _slot_crops(severed if segmentable else strip, frame_count) + # 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 @@ -535,15 +564,22 @@ def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_P 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** the registered frames through one shared window and apply - **one shared scale** + bottom-anchor, so size and baseline are uniform and - intra-state vertical motion (a jump's lift) is preserved. + 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): @@ -572,14 +608,34 @@ def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_P shifted.alpha_composite(f, (margin + _best_shift(ref, prof, window), 0)) aligned.append(shifted) - # Shared window + scale over the registered set; bottom-anchored, centered. + # 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 - scale = min((CELL_WIDTH - pad) / uw, (CELL_HEIGHT - pad) / uh) + 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) @@ -587,7 +643,8 @@ def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_P for a in aligned: crop = a.crop((left, top, right, bottom)) if crop.size != (sw, sh): - crop = crop.resize((sw, sh), Image.Resampling.LANCZOS) + # 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) diff --git a/agent/pet/generate/imagegen.py b/agent/pet/generate/imagegen.py index 1e01592e4bf..00390d1ca8b 100644 --- a/agent/pet/generate/imagegen.py +++ b/agent/pet/generate/imagegen.py @@ -26,6 +26,16 @@ logger = logging.getLogger(__name__) # 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).""" @@ -49,16 +59,25 @@ def _discover() -> None: logger.debug("image-gen plugin discovery failed: %s", exc) -def resolve_provider(*, require_references: bool = True) -> SpriteProvider: +def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider: """Pick the image provider to use for sprite work. - Preference: the configured 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). + 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: @@ -83,11 +102,44 @@ def resolve_provider(*, require_references: bool = True) -> SpriteProvider: raise GenerationError( "Pet generation needs an image backend that supports reference images. " - "Open `hermes tools` → Image Generation and configure OpenRouter, Nous " - "Portal, or OpenAI (gpt-image-2) with an API key." + "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://")): @@ -116,10 +168,15 @@ def generate( reference_images: list[Path] | None = None, provider: SpriteProvider | None = None, prefix: str = "pet_gen", + aspect_ratio: str = "square", ) -> list[Path]: - """Generate *n* square sprite images and return their local paths. + """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. @@ -134,7 +191,7 @@ def generate( refs = [str(p) for p in (reference_images or [])] def _run(extra: dict) -> tuple[Path | None, str]: - kwargs: dict = {"aspect_ratio": "square", **extra} + 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 diff --git a/agent/pet/generate/orchestrate.py b/agent/pet/generate/orchestrate.py index 238c490a22a..f160046ebf9 100644 --- a/agent/pet/generate/orchestrate.py +++ b/agent/pet/generate/orchestrate.py @@ -35,6 +35,10 @@ ProgressFn = Callable[[str, str], None] # 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"}) @@ -80,6 +84,7 @@ def generate_base_drafts( *, 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, @@ -96,7 +101,10 @@ def generate_base_drafts( drafts and cancel any queued work (already-in-flight provider calls can't be hard-killed, but their results are dropped). """ - sprite = provider or imagegen.resolve_provider(require_references=False) + # 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 @@ -104,25 +112,26 @@ def generate_base_drafts( # 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]: + def _one(index: int) -> tuple[int, Path | None, str | None]: if cancelled(): - return index, None + 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, provider=sprite, prefix="pet_base") + 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 + return index, None, str(exc) if not out: logger.warning("pet generate: draft %d produced no image", index) - return index, None + 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]) + 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 @@ -134,8 +143,10 @@ def generate_base_drafts( for pending in futures: pending.cancel() break - index, path = fut.result() + index, path, err = fut.result() if path is None: + if err: + errors.append(err) continue results[index] = path if on_draft is not None: @@ -146,10 +157,42 @@ def generate_base_drafts( drafts = [results[i] for i in sorted(results)] if not drafts and not cancelled(): - raise GenerationError("image generation produced no usable drafts") + # 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, @@ -194,25 +237,48 @@ def hatch_pet( if cancelled(): return state, None t0 = time.monotonic() - 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}", - ) - # One image call per row (the expensive part). ``auto`` validates by - # connected components with an equal-slot fallback; raw (fit=False) so - # normalize_cells registers the whole pet at once. We deliberately do - # NOT re-generate a ragged row — the registration pass salvages it far - # cheaper than another image-model round-trip. - frames = atlas.extract_strip_frames(strips[0], count, method="auto", fit=False) - logger.info("pet hatch %r: row %r ready in %.1fs", slug, state, time.monotonic() - t0) - return state, frames - except Exception as exc: # noqa: BLE001 - one bad row is tolerated (idle guaranteed) - logger.warning("pet hatch %r: row %r failed after %.1fs: %s", slug, state, time.monotonic() - t0, exc) - return state, None + 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. diff --git a/agent/pet/generate/prompts.py b/agent/pet/generate/prompts.py index c6afbc28313..eab72e593f3 100644 --- a/agent/pet/generate/prompts.py +++ b/agent/pet/generate/prompts.py @@ -76,6 +76,29 @@ 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, @@ -118,14 +141,24 @@ def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | """ 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), " - f"draw a single horizontal strip of {frame_count} animation frames showing {action}. " - f"The {frame_count} poses must be evenly spaced left to right, each fully separated " - "by clear empty chroma-key gutters; silhouettes must NEVER touch, overlap, " - "share a shadow, share a ground line, share motion trails, or merge into " - "one connected shape. " + 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 " diff --git a/apps/desktop/src/app/pet-generate/components/draft-grid.tsx b/apps/desktop/src/app/pet-generate/components/draft-grid.tsx new file mode 100644 index 00000000000..abef61f027f --- /dev/null +++ b/apps/desktop/src/app/pet-generate/components/draft-grid.tsx @@ -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 ( +
+
+ + {copy.generating} + + + {Math.min(drafts.length, VARIANT_COUNT)}/{VARIANT_COUNT} + +
+ +
+ {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 ( + + ) + })} +
+ + {/* 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. */} + + {hasDrafts && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/app/pet-generate/components/empty-hint.tsx b/apps/desktop/src/app/pet-generate/components/empty-hint.tsx new file mode 100644 index 00000000000..99b9822ea82 --- /dev/null +++ b/apps/desktop/src/app/pet-generate/components/empty-hint.tsx @@ -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 ( +
+ {EXAMPLE_PROMPTS.map(example => ( + + ))} +
+ ) +} diff --git a/apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx b/apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx new file mode 100644 index 00000000000..d3161d2a771 --- /dev/null +++ b/apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx @@ -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 ( +
+ + + +
+

Add an image backend to generate

+

+ Hatching a custom pet needs a provider that can ground on a reference image. +

+
+ +

+ Grab a key from + + Nous Portal + + · + + OpenRouter + + · + + OpenAI + +

+
+ ) +} diff --git a/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx b/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx new file mode 100644 index 00000000000..8adb6c3f9f2 --- /dev/null +++ b/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx @@ -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 ( +
+ {/* Fills the (now narrow) dialog so the pet frame is the screen width. */} +
+ {revealed ? ( + <> +
+ +
+ +
+
+ + + ) : ( + // The egg cracks open, then we swap in the live pet. + { + setRevealed(true) + triggerHaptic('crisp') + }} + size={150} + /> + )} +
+ + setName(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + onAdopt(name) + } + }} + placeholder={copy.namePlaceholder} + value={name} + /> + + {error && ( + + {error} + + )} + +
+ + +
+
+ ) +} diff --git a/apps/desktop/src/app/pet-generate/components/hatching-view.tsx b/apps/desktop/src/app/pet-generate/components/hatching-view.tsx new file mode 100644 index 00000000000..8e347741d6b --- /dev/null +++ b/apps/desktop/src/app/pet-generate/components/hatching-view.tsx @@ -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 +} diff --git a/apps/desktop/src/app/pet-generate/components/provider-picker.tsx b/apps/desktop/src/app/pet-generate/components/provider-picker.tsx new file mode 100644 index 00000000000..bd40a30ba31 --- /dev/null +++ b/apps/desktop/src/app/pet-generate/components/provider-picker.tsx @@ -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 ( + + + {/* Plain text affordance (matches "Add a reference"), not a padded pill. */} + + + {/* 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. */} + + {providers.map(provider => ( + setPetGenProvider(provider.default ? '' : provider.name)} + > + + {provider.label} + {provider.name === current?.name && } + + {provider.note && {provider.note}} + + ))} + + + ) +} diff --git a/apps/desktop/src/app/pet-generate/components/reference-chip.tsx b/apps/desktop/src/app/pet-generate/components/reference-chip.tsx new file mode 100644 index 00000000000..266658a9dab --- /dev/null +++ b/apps/desktop/src/app/pet-generate/components/reference-chip.tsx @@ -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 ( +
+ + + {name || 'Reference'} + + + +
+ ) +} diff --git a/apps/desktop/src/app/pet-generate/lib/frame-count.ts b/apps/desktop/src/app/pet-generate/lib/frame-count.ts new file mode 100644 index 00000000000..97a49a8cd6b --- /dev/null +++ b/apps/desktop/src/app/pet-generate/lib/frame-count.ts @@ -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 = { + 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 +} diff --git a/apps/desktop/src/app/pet-generate/lib/read-reference-image.ts b/apps/desktop/src/app/pet-generate/lib/read-reference-image.ts new file mode 100644 index 00000000000..06c480e95ed --- /dev/null +++ b/apps/desktop/src/app/pet-generate/lib/read-reference-image.ts @@ -0,0 +1,49 @@ +const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024 + +function loadImage(url: string): Promise { + 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 { + 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) + } +} diff --git a/apps/desktop/src/app/pet-generate/pet-generate-content.tsx b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx new file mode 100644 index 00000000000..2c6f2a815de --- /dev/null +++ b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx @@ -0,0 +1,291 @@ +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(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 ? ( + {copy.title} + ) : ( + + {headerTitle} + + )} + +
+ {/* Concept prompt with the inline sparkle generate/stop affordance (the + same primitive as the commit-message + project-idea fields). */} + {showPrompt && ( +
+
+ $petGenInput.set(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + generate() + } + }} + placeholder={copy.placeholder} + value={prompt} + /> + +
+ +
+ + {refImage ? ( + + ) : ( + + )} +
+ + {/* Optional reference photo — make a pet from the user's own image. + Styled like the chat composer's attachment pill. */} + { + pickReference(event.target.files?.[0]) + event.target.value = '' + }} + ref={fileRef} + type="file" + /> +
+ )} + + {/* 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 && ( + + {error || copy.genericError} + + )} + + {unavailable ? ( + + ) : status === 'stale' ? ( + + {copy.staleBackend} + + ) : status === 'hatching' ? ( + + ) : (status === 'preview' || status === 'adopting') && preview ? ( + 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. + + ) : ( + $petGenSelected.set(index)} + selected={selected} + /> + )} +
+ + ) +} diff --git a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx index 954dac23bfd..cd262e142c6 100644 --- a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx +++ b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx @@ -6,92 +6,37 @@ * 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 { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { SETTINGS_ROUTE } from '@/app/routes' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' -import { PetEggHatch } from '@/components/pet/pet-egg-hatch' -import { PetStarShower } from '@/components/pet/pet-star-shower' -import { PetSprite } from '@/components/pet/pet-sprite' -import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { GenerateButton } from '@/components/ui/generate-button' -import { Input } from '@/components/ui/input' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { useI18n } from '@/i18n' -import { ExternalLink } from '@/lib/external-link' -import { triggerHaptic } from '@/lib/haptics' -import { Egg, Loader2, PawPrint, RefreshCw, Settings2 } from '@/lib/icons' -import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' -import { type PetInfo } from '@/store/pet' import { - $petGenAvailable, $petGenDrafts, $petGenerateOpen, $petGenError, - $petGenPreview, - $petGenSelected, - $petGenStage, $petGenStatus, - adoptHatched, - cancelGenerate, - cancelHatch, - checkPetGenAvailable, - cleanPetName, - cleanupPetGen, - closePetGenerate, - discardHatched, - generateDrafts, - hatchSelected + cleanupPetGenOnClose, + closePetGenerate } from '@/store/pet-generate' -const VARIANT_COUNT = 4 -const PREVIEW_SCALE = 0.7 -const PREVIEW_ROWS = [ - 'idle', - 'waving', - 'running-right', - 'running-left', - 'running', - 'review', - 'jumping', - 'failed', - 'waiting' -] -const PREVIEW_STATE_MS = 1400 - -const ROW_TO_FRAME_KEY: Record = { - 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' -} - -function frameCountForRow(pet: PetInfo, row: string): number { - const byState = pet.framesByState - const mapped = ROW_TO_FRAME_KEY[row] - return byState?.[row] ?? (mapped ? byState?.[mapped] : undefined) ?? pet.framesPerState ?? 0 -} +import { PetGenerateContent } from './pet-generate-content' export function PetGenerateOverlay() { + const { t } = useI18n() + const { requestGateway } = useGatewayRequest() const open = useStore($petGenerateOpen) const status = useStore($petGenStatus) - const { requestGateway } = useGatewayRequest() + 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, @@ -102,449 +47,39 @@ export function PetGenerateOverlay() { const handleOpenChange = (next: boolean) => { if (!next) { - // Deletes a hatched-but-unadopted preview pet so it doesn't linger, then - // resets all generation state. - cleanupPetGen(requestGateway) + 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 ( {open && } ) } - -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) - - const [prompt, setPrompt] = useState('') - - // 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-7.5) from the prompt; the working states stay compact. - const isEmptyState = - !hasDrafts && - !generating && - status !== 'hatching' && - status !== 'preview' && - status !== 'adopting' && - status !== 'stale' - - const close = () => { - cleanupPetGen(requestGateway) - closePetGenerate() - } - - const generate = () => { - if (prompt.trim() && !busy) { - void generateDrafts(requestGateway, { prompt: prompt.trim() }) - } - } - - // One-click an example prompt straight into a draft round. - const runExample = (example: string) => { - setPrompt(example) - void generateDrafts(requestGateway, { prompt: example }) - } - - // Hatch with a clean default name derived from the prompt (the prompt itself - // is grounding text, not a label); the user names it on the reveal screen. - const hatch = () => { - if (prompt.trim()) { - void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() }) - } - } - - const adopt = (finalName: string) => { - void adoptHatched(requestGateway, finalName).then(out => { - if (out.ok) { - triggerHaptic('crisp') - close() - } - }) - } - - // 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 ? ( - {copy.title} - ) : ( - - {headerTitle} - - )} - -
- {/* Concept prompt with the inline sparkle generate/stop affordance (the - same primitive as the commit-message + project-idea fields). */} - {showPrompt && ( -
- setPrompt(event.target.value)} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault() - generate() - } - }} - placeholder={copy.placeholder} - value={prompt} - /> - -
- )} - - {error && !unavailable && status !== 'preview' && status !== 'adopting' && ( - - {error} - - )} - - {unavailable ? ( - - ) : status === 'stale' ? ( - - {copy.staleBackend} - - ) : status === 'hatching' ? ( - - ) : (status === 'preview' || status === 'adopting') && preview ? ( - void discardHatched(requestGateway)} - pet={preview} - /> - ) : !hasDrafts && !generating ? ( - - ) : ( - $petGenSelected.set(index)} - selected={selected} - /> - )} -
- - ) -} - -// Creative seed prompts — specifics make better pets (petdex's own advice). -// Doubling as guidance and a one-click way to see the flow. -const EXAMPLE_PROMPTS = ['a bubble-tea otter', 'a tiny sock elf', 'a pixel dragon', 'a grumpy office cat', 'a neon axolotl'] - -// 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. -function GenerateUnavailable({ onSetup }: { onSetup: () => void }) { - return ( -
- - - -
-

Add an image backend to generate

-

- Hatching a custom pet needs a provider that can ground on a reference image. -

-
- -

- Grab a key from - - Nous Portal - - · - - OpenRouter - - · - - OpenAI - -

-
- ) -} - -function EmptyHint({ onExample }: { onExample: (prompt: string) => void }) { - return ( -
-

Need a spark?

-
- {EXAMPLE_PROMPTS.map(example => ( - - ))} -
-
- ) -} - -function HatchingView({ stage }: { stage: { phase: string; state?: string; done?: number; total?: number } | null }) { - 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 -} - -interface DraftGridProps { - busy: boolean - drafts: { index: number; dataUri: string }[] - generating: boolean - hasDrafts: boolean - onHatch: () => void - onSelect: (index: number) => void - selected: number | null -} - -function DraftGrid({ busy, drafts, generating, hasDrafts, 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 ( -
- {generating && ( -
- {copy.generating} - - {drafts.length}/{VARIANT_COUNT} - -
- )} - -
- {slots.map((draft, i) => { - const isSelected = !generating && draft != null && selected === draft.index - - return ( - - ) - })} -
- - {hasDrafts && ( - - )} -
- ) -} - -interface HatchPreviewProps { - pet: PetInfo - adopting: boolean - error: string | null - onAdopt: (name: string) => void - onDiscard: () => void -} - -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 ( -
- {/* Fills the (now narrow) dialog so the pet frame is the screen width. */} -
- {revealed ? ( - <> -
- -
- - - ) : ( - // The egg cracks open, then we swap in the live pet. - { - setRevealed(true) - triggerHaptic('crisp') - }} - size={150} - /> - )} -
- - setName(event.target.value)} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault() - onAdopt(name) - } - }} - placeholder={copy.namePlaceholder} - value={name} - /> - - {error && ( - - {error} - - )} - -
- - -
-
- ) -} - diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 92d5a540351..863854a738b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -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() diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index d69c35ab6b6..2ceba5ac0c8 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -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 ` shows up live. Stops polling once a pet is enabled. + // Fetch pet.info on connect. Poll quickly while inactive so an in-app + // `/pet ` 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('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('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]) diff --git a/apps/desktop/src/components/pet/pet-egg-hatch.tsx b/apps/desktop/src/components/pet/pet-egg-hatch.tsx index a677d84b13c..f542a5a0488 100644 --- a/apps/desktop/src/components/pet/pet-egg-hatch.tsx +++ b/apps/desktop/src/components/pet/pet-egg-hatch.tsx @@ -44,14 +44,16 @@ export function PetProgress({ done, total }: { done?: number; total?: number }) export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) { return ( -
+
- + {/* 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. */} +
{subtitle && ( -

+

{subtitle}

)} diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index ed9e4fbfcdc..455f6a956aa 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -91,6 +91,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite 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 @@ -134,6 +135,8 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite 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]) { @@ -161,13 +164,25 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite const resolveRow = (rowName: string): { row: number; count: number } => { const row = rows.indexOf(rowName) const state = ROW_TO_STATE[rowName] - const count = Math.max(1, framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames) + 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 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 @@ -201,7 +216,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite cancelAnimationFrame(raf) unsubState() } - }, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows]) + }, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows]) return ( = { + 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 & { 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 ? ( + + + + ) : 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 ( + + + + {/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */} +
+
{children}
+
+
+ {banner} +
+ {closeButton} +
+
+ ) + } + return ( @@ -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 && ( - - - - )} + {closeButton} ) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fab233cd7ff..b1da6d1aae5 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -788,13 +788,17 @@ export const en: Translations = { hatch: 'Hatch', spawning: 'Spawning…', hatching: 'Hatching your pet…', - hatchingSub: 'Bringing every frame to life — this takes a moment.', + hatchingSub: 'Bringing it to life…', hatched: 'It hatched!', - hatchRow: (state, done, total) => `Drawing ${state}… ${done}/${total}`, - hatchComposing: 'Composing the spritesheet…', - hatchSaving: 'Saving your pet…', + 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 it’s 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' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index e1c748c5ee6..6e56e941de4 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -908,13 +908,17 @@ export const ja = defineLocale({ hatch: '孵化', spawning: 'スポーン中…', hatching: 'ペットを孵化しています…', - hatchingSub: 'すべてのフレームに命を吹き込んでいます。少々お待ちください。', + hatchingSub: '命を吹き込んでいます…', hatched: '孵化しました!', - hatchRow: (state, done, total) => `${state} を描画中… ${done}/${total}`, - hatchComposing: 'スプライトシートを合成中…', - hatchSaving: 'ペットを保存中…', + hatchRow: (_state, done, total) => `フレームを描画中… ${done}/${total}`, + hatchComposing: 'まとめています…', + hatchSaving: 'もうすぐです…', namePlaceholder: 'ペットに名前を付ける', staleBackend: 'ペットを生成するには Hermes を更新してください。', + backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。', + genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。', + referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。', + referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。', adopt: '迎え入れる', startOver: 'やり直す' }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9d1e213b97d..badafd549fd 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -670,6 +670,10 @@ export interface Translations { hatchSaving: string namePlaceholder: string staleBackend: string + backgroundHint: string + genericError: string + referenceImageTooLarge: string + referenceImageInvalid: string adopt: string startOver: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index eb6e2ff7ead..92a00637baf 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -878,13 +878,17 @@ export const zhHant = defineLocale({ hatch: '孵化', spawning: '召喚中……', hatching: '正在孵化你的寵物……', - hatchingSub: '正在為每一格注入生命——請稍候。', + hatchingSub: '正在注入生命……', hatched: '孵化成功!', - hatchRow: (state, done, total) => `正在繪製 ${state}…… ${done}/${total}`, - hatchComposing: '正在合成精靈表……', - hatchSaving: '正在儲存你的寵物……', + hatchRow: (_state, done, total) => `正在繪製畫面…… ${done}/${total}`, + hatchComposing: '正在拼合……', + hatchSaving: '快好了……', namePlaceholder: '為寵物命名', staleBackend: '請更新 Hermes 以生成寵物。', + backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。', + genericError: '生成失敗——請重試或選一個建議。', + referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。', + referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。', adopt: '領養', startOver: '重新開始' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index effbaf328f8..83781ce0eba 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -975,13 +975,17 @@ export const zh: Translations = { hatch: '孵化', spawning: '召唤中……', hatching: '正在孵化你的宠物……', - hatchingSub: '正在为每一帧注入生命——请稍候。', + hatchingSub: '正在注入生命……', hatched: '孵化成功!', - hatchRow: (state, done, total) => `正在绘制 ${state}…… ${done}/${total}`, - hatchComposing: '正在合成精灵表……', - hatchSaving: '正在保存你的宠物……', + hatchRow: (_state, done, total) => `正在绘制画面…… ${done}/${total}`, + hatchComposing: '正在拼合……', + hatchSaving: '马上就好……', namePlaceholder: '给宠物起个名字', staleBackend: '请更新 Hermes 以生成宠物。', + backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。', + genericError: '生成失败——请重试或选择一个建议。', + referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。', + referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。', adopt: '领养', startOver: '重新开始' }, diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 5f2b51f8d9a..5cc11e00424 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -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() }, diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts index bfcd7117d50..b47c858bd76 100644 --- a/apps/desktop/src/store/pet-generate.ts +++ b/apps/desktop/src/store/pet-generate.ts @@ -1,8 +1,12 @@ 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 { type GatewayRequest, applyAdoptedPet } from '@/store/pet-gallery' +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). @@ -57,8 +61,10 @@ export function cleanPetName(prompt: string): string { .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(' ') @@ -101,11 +107,42 @@ export const $petGenError = atom(null) // re-probes on open and on return from settings. export const $petGenAvailable = atom(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([]) +/** 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 { try { - const res = await request<{ available: boolean }>('pet.generate.status') + 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) @@ -116,14 +153,20 @@ export async function checkPetGenAvailable(request: GatewayRequest): Promise(null) /** Prompt that produced the current draft token; hatch uses this for consistency. */ export const $petGenPrompt = atom('') @@ -132,13 +175,20 @@ export const $petGenSelected = atom(null) /** The hatched-but-unadopted pet: its renderer payload, played in the preview. */ export const $petGenPreview = atom(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(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 (on close, or before a fresh run). */ +/** Clear all generation state (before a fresh run). */ export function resetPetGen(): void { $petGenStatus.set('idle') $petGenStage.set(null) @@ -148,26 +198,44 @@ export function resetPetGen(): void { $petGenDrafts.set([]) $petGenSelected.set(null) $petGenPreview.set(null) + $petGenInput.set('') + $petGenRefImage.set(null) + $petGenRefName.set('') } /** - * Reset on palette close, deleting an unadopted preview pet first so a hatched- - * but-never-adopted creature doesn't linger in the gallery. Fire-and-forget. + * 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 cleanupPetGen(request: GatewayRequest): void { +export function cleanupPetGenOnClose(request: GatewayRequest): void { + const status = $petGenStatus.get() const preview = $petGenPreview.get() - if ($petGenStatus.get() === 'preview' && preview?.slug) { + 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 } - resetPetGen() + 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 @@ -185,6 +253,7 @@ interface Run { function cancelableRun(): Run { let id = 0 let cancel: (() => void) | null = null + return { begin: () => (id += 1), isCurrent: n => n === id, @@ -216,11 +285,14 @@ export function cancelGenerate(): void { $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 } @@ -230,6 +302,19 @@ export function cancelGenerate(): void { $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 @@ -245,8 +330,10 @@ export function cancelHatch(): void { /** Generate (or retry) a fresh set of base-look drafts for `prompt`. */ export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise { const prompt = options.prompt.trim() + const referenceImage = options.referenceImage - if (!prompt) { + // Need *something* to ground on: a description or a reference image. + if (!prompt && !referenceImage) { return false } @@ -255,6 +342,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO gen.arm(() => { controller.abort() const token = $petGenToken.get() + if (token) { void request('pet.cancel', { token }).catch(() => {}) } @@ -262,6 +350,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO // 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(() => {}) } @@ -284,6 +373,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO if (gen.isCurrent(runId) && $petGenStatus.get() === 'generating') { $petGenToken.set(draft.token) } + return } @@ -302,6 +392,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO } const current = $petGenDrafts.get() + if (current.some(d => d.index === draft.index)) { return } @@ -317,7 +408,9 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO { prompt, style: options.style ?? 'auto', - count: options.count ?? 4 + count: options.count ?? 4, + ...(referenceImage ? { referenceImage } : {}), + ...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {}) }, GENERATE_TIMEOUT_MS, controller.signal @@ -333,10 +426,12 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO } $petGenToken.set(result.token) - $petGenPrompt.set(prompt) + // 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) { @@ -349,6 +444,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO } 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 @@ -381,11 +477,15 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio 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 }).catch(() => {}) + void request('pet.cancel', { token: cancelToken }).catch(() => {}) }) $petGenStatus.set('hatching') @@ -399,6 +499,7 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio .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 } @@ -422,11 +523,13 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio 'pet.hatch', { token, + cancelToken, index, name, description: options.description ?? '', prompt: concept, - style: options.style ?? 'auto' + style: options.style ?? 'auto', + ...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {}) }, HATCH_TIMEOUT_MS, controller.signal @@ -437,6 +540,7 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio if (result?.slug) { void request('pet.remove', { slug: result.slug }).catch(() => {}) } + return false } @@ -446,6 +550,7 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio $petGenPreview.set({ ...result.pet, enabled: true }) $petGenStatus.set('preview') + notifyPetGenDone('Your pet hatched', 'Reopen to name and adopt it.', 'success') return true } catch (e) { @@ -455,10 +560,12 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio $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) @@ -494,11 +601,13 @@ export async function adoptHatched(request: GatewayRequest, name?: string): Prom // 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 } diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts index e4863f45712..f62ee25745d 100644 --- a/apps/desktop/src/store/pet.ts +++ b/apps/desktop/src/store/pet.ts @@ -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 + // 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 loopMs?: number scale?: number stateRows?: string[] diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index bd584237eea..f4026ba5e29 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1491,16 +1491,35 @@ canvas { width: 4.5rem; height: 0.8rem; border-radius: 50%; - background: radial-gradient(circle, color-mix(in srgb, #000 32%, transparent) 0%, transparent 72%); + /* 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%; diff --git a/cli.py b/cli.py index 63d6fb71153..289d5ec7b12 100644 --- a/cli.py +++ b/cli.py @@ -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'): diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 45e6bdbe79d..eefce82461a 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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 `` 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 (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 "::"; 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 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c7ce5566e40..63f316bde16 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -181,6 +181,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ "Tools & Skills"), CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet )", "Tools & Skills", cli_only=True, args_hint="[toggle|list|scale |]", 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=""), CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", diff --git a/tests/agent/test_pet_generate.py b/tests/agent/test_pet_generate.py index 1a3ad424bb8..82bd3f15de5 100644 --- a/tests/agent/test_pet_generate.py +++ b/tests/agent/test_pet_generate.py @@ -106,6 +106,23 @@ def test_extract_strip_frames_drops_small_side_lobes_from_adjacent_frames(): 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)) @@ -181,6 +198,27 @@ def test_single_frame_fits_cell(): 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 ───────────────────────── @@ -252,7 +290,7 @@ def test_generate_base_drafts_returns_n(monkeypatch, tmp_path): calls = {"n": 0} - def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"): + 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 @@ -272,7 +310,7 @@ 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"): + 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) @@ -300,7 +338,7 @@ def test_hatch_pet_end_to_end(monkeypatch, tmp_path): base = tmp_path / "base.png" _strip(1).save(base) - def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"): + 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) @@ -337,7 +375,7 @@ def test_hatch_pet_idle_fallback_when_row_fails(monkeypatch, tmp_path): base = tmp_path / "base.png" _strip(1).save(base) - def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"): + 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_", "") @@ -361,7 +399,7 @@ def test_hatch_pet_rejects_missing_required_animation_rows(monkeypatch, tmp_path base = tmp_path / "base.png" _strip(1).save(base) - def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"): + 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_", "") @@ -388,6 +426,48 @@ def test_resolve_provider_errors_without_backend(monkeypatch): 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 diff --git a/tests/tui_gateway/test_pet_generate_rpc.py b/tests/tui_gateway/test_pet_generate_rpc.py index 99d65b3d85a..98dd494bd52 100644 --- a/tests/tui_gateway/test_pet_generate_rpc.py +++ b/tests/tui_gateway/test_pet_generate_rpc.py @@ -23,10 +23,32 @@ def test_pet_generate_requires_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", on_draft=None, is_cancelled=None): + 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" @@ -66,7 +88,7 @@ def test_pet_generate_cancel_stops_run(monkeypatch, tmp_path): monkeypatch.setattr(server, "_emit", cap_emit) - def fake_drafts(prompt, *, n=4, style="auto", on_draft=None, is_cancelled=None): + 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 @@ -93,7 +115,7 @@ def test_pet_hatch_expired_draft(): def _fake_drafts_factory(tmp_path): - def fake_drafts(prompt, *, n=4, style="auto", on_draft=None, is_cancelled=None): + 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" @@ -178,3 +200,46 @@ def test_pet_hatch_then_adopt_activates(monkeypatch, tmp_path): 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"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 750a6840270..726a143d87e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5579,6 +5579,75 @@ 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 @@ -5604,22 +5673,57 @@ def _pet_sprite_payload(pet, *, scale: float) -> dict: 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" - return { + 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]: @@ -5658,31 +5762,40 @@ def _(rid, params: dict) -> dict: on any error rather than erroring the surface. """ 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}) - scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) 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, + "scale": scale, + "spritesheetRevision": _pet_sheet_revision(pet.spritesheet), + }, + ) + except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface + logger.debug("pet.info.meta failed: %s", exc) + return _ok(rid, {"enabled": False}) + + @method("pet.cells") @_profile_scoped def _(rid, params: dict) -> dict: @@ -6107,6 +6220,53 @@ def _pet_png_data_uri(path, *, max_px: int = 160) -> str: # 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: @@ -6148,34 +6308,46 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Whether pet generation is possible right now. - True only when a reference-capable image backend (OpenRouter / Nous Portal / + 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, resolve_provider + from agent.pet.generate.imagegen import ( + GenerationError, + list_sprite_providers, + resolve_provider, + ) try: resolve_provider(require_references=True) - return _ok(rid, {"available": True}) + available = True except GenerationError: - return _ok(rid, {"available": False}) + 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}) + 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), ``count`` (default 4), ``style`` (default - ``auto``). Returns ``{ok, token, drafts:[{index, dataUri}]}`` — the token - keys the staged base images for a later ``pet.hatch``. Retry == call again - (fresh token). Heavy (network): runs on the worker pool. + 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() - if not prompt: + 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))) @@ -6188,7 +6360,7 @@ def _(rid, params: dict) -> dict: import uuid from agent.pet.generate import generate_base_drafts - from agent.pet.generate.imagegen import GenerationError + from agent.pet.generate.imagegen import GenerationError, resolve_provider root = _pet_gen_root() _pet_gen_sweep(root) @@ -6199,6 +6371,27 @@ def _(rid, params: dict) -> dict: _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 @@ -6230,9 +6423,11 @@ def _(rid, params: dict) -> dict: try: generate_base_drafts( - prompt, + concept, n=count, style=style, + reference_images=reference_images, + provider=sprite, on_draft=_on_draft, is_cancelled=lambda: _pet_is_cancelled(token), ) @@ -6268,6 +6463,11 @@ def _(rid, params: dict) -> dict: ``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: @@ -6282,13 +6482,22 @@ def _(rid, params: dict) -> dict: try: from agent.pet import store from agent.pet.generate import hatch_pet - from agent.pet.generate.imagegen import GenerationError + 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") - _pet_cancel_arm(token) + # 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: @@ -6312,13 +6521,14 @@ def _(rid, params: dict) -> dict: 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(token), + is_cancelled=lambda: _pet_is_cancelled(cancel_token), ) except GenerationError as exc: return _err(rid, 5031, str(exc)) finally: - _pet_cancel_release(token) + _pet_cancel_release(cancel_token) pet = store.load_pet(result.slug) payload = _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {} From 592c462e3cf86d709e7eb51a94b10861c6e28a25 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 19:22:00 -0500 Subject: [PATCH 7/9] refine(pets): preserve user-requested tone in generation prompts Remove cute/chibi-biased wording from base draft variations and explicitly preserve the requested mood across base and row prompts so scary, eerie, or other non-cute concepts are honored while keeping sprite constraints. --- agent/pet/generate/prompts.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/agent/pet/generate/prompts.py b/agent/pet/generate/prompts.py index eab72e593f3..584cf99155d 100644 --- a/agent/pet/generate/prompts.py +++ b/agent/pet/generate/prompts.py @@ -106,10 +106,10 @@ def _spacing_spec(frame_count: int) -> tuple[int, int]: BASE_VARIATIONS: tuple[str, ...] = ( "", "a distinctly different colour palette and markings", - "rounder, chunkier chibi proportions and a bigger head", - "a different face and expression, with unique accent/accessory details", + "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 playful expression", + "bolder, more saturated colours and a stronger expression matching the concept tone", ) @@ -118,12 +118,14 @@ def build_base_prompt(concept: str, *, style: str | None = "auto", variation: st *variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`). """ - concept = (concept or "a cute friendly mascot creature").strip() + concept = (concept or "a distinctive mascot creature").strip() nudge = f" Make this design distinct: {variation}." if variation else "" return ( - f"A cute, characterful mascot pet: {concept}. " + 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, " - "appealing face, simple consistent palette. " + "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 " @@ -145,6 +147,7 @@ def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | 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. " From 2ea94c6c45814862e9ebacf80d8051b58da980f7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 19:33:33 -0500 Subject: [PATCH 8/9] fix(pets): make inline generate cancel discard draft flow Wire the sparkle generate button's cancel action to the same discard/reset path as step-2 cancel so abort semantics are consistent and always return to step 1 while retaining the prompt input. --- apps/desktop/src/app/pet-generate/pet-generate-content.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/pet-generate/pet-generate-content.tsx b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx index 2c6f2a815de..87d34897cc7 100644 --- a/apps/desktop/src/app/pet-generate/pet-generate-content.tsx +++ b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx @@ -210,7 +210,9 @@ export function PetGenerateContent() { generating={generating} generatingLabel={t.common.cancel} label={copy.generate} - onCancel={cancelGenerate} + // Inline cancel should match step-2 cancel semantics: abort and + // return to step 1 (prompt retained for quick tweaks). + onCancel={discardDrafts} onGenerate={generate} />
From a05a9b0e07cdec601d4bb58572e1553e9365fb5c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 19:33:40 -0500 Subject: [PATCH 9/9] test(delegate): harden heartbeat in-tool stale timing assertion Stabilize the long-running-tool heartbeat test by patching stale thresholds inside the test and asserting the heartbeat exceeds the idle ceiling, which preserves intent while removing scheduler-sensitive assumptions that flake in CI. --- tests/tools/test_delegate.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 911025e9993..dfc36743811 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1901,12 +1901,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", @@ -1914,11 +1916,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", )