mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-28 11:32:22 +00:00
Merge pull request #47959 from NousResearch/bb/pets-gen
Pet generation: frame-perfect hatch flow, backend picker, CPU-safe chroma, and CI-hardening
This commit is contained in:
commit
7157b213f5
56 changed files with 7138 additions and 96 deletions
29
agent/pet/generate/__init__.py
Normal file
29
agent/pet/generate/__init__.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Pet generation — base-draft → hatch pipeline.
|
||||
|
||||
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
|
||||
command, and tests:
|
||||
|
||||
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
|
||||
- :class:`HatchResult`, :class:`GenerationError`.
|
||||
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
|
||||
|
||||
Image generation is delegated to the active reference-capable
|
||||
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
|
||||
atlas assembly is fully deterministic so it's testable without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
from agent.pet.generate.orchestrate import (
|
||||
HatchResult,
|
||||
generate_base_drafts,
|
||||
hatch_pet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GenerationError",
|
||||
"HatchResult",
|
||||
"generate_base_drafts",
|
||||
"hatch_pet",
|
||||
]
|
||||
781
agent/pet/generate/atlas.py
Normal file
781
agent/pet/generate/atlas.py
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
"""Deterministic spritesheet assembly — generated row strips → Hermes atlas.
|
||||
|
||||
Image-generation models are good at *drawing* a row of poses but bad at exact
|
||||
grid geometry, so the model never owns the atlas layout: it produces one loose
|
||||
horizontal strip per state, and these deterministic ops slice that strip into
|
||||
clean, centered, transparent ``192x208`` cells and pack them into the sheet our
|
||||
renderer reads.
|
||||
|
||||
The atlas follows the **petdex/Codex standard**: 8 columns x 9 rows of
|
||||
``192x208`` cells (``1536x1872``), with the row order + per-row frame counts
|
||||
from OpenAI's ``hatch-pet`` skill. Our renderer (:mod:`agent.pet.render`) keys
|
||||
frames as ``rows = states, cols = frames`` via
|
||||
:data:`agent.pet.constants.CODEX_STATE_ROWS`, and a pet built here is a valid
|
||||
``petdex submit`` spritesheet. Rows shorter than 8 columns leave the trailing
|
||||
cells fully transparent.
|
||||
|
||||
Note ``running`` is the *working* state (in-place processing), NOT locomotion —
|
||||
``running-right`` / ``running-left`` are the actual directional walk cycles.
|
||||
|
||||
The frame-segmentation, fit-to-cell, and transparency-residue logic is adapted
|
||||
from OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from agent.pet.constants import FRAME_H, FRAME_W
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CELL_WIDTH = FRAME_W
|
||||
CELL_HEIGHT = FRAME_H
|
||||
|
||||
# (state, row index, frame count). Order/row indices MUST match
|
||||
# ``constants.CODEX_STATE_ROWS`` so the renderer crops the right row for each
|
||||
# driven state, and the per-row frame counts mirror the petdex/Codex
|
||||
# ``hatch-pet`` ``animation-rows`` spec. The renderer trims trailing blank
|
||||
# columns, so rows shorter than ``COLUMNS`` (8) just leave the tail transparent.
|
||||
ROW_SPECS: list[tuple[str, int, int]] = [
|
||||
("idle", 0, 6),
|
||||
("running-right", 1, 8),
|
||||
("running-left", 2, 8),
|
||||
("waving", 3, 4),
|
||||
("jumping", 4, 5),
|
||||
("failed", 5, 8),
|
||||
("waiting", 6, 6),
|
||||
("running", 7, 6),
|
||||
("review", 8, 6),
|
||||
]
|
||||
|
||||
ROWS = len(ROW_SPECS)
|
||||
COLUMNS = max(count for _, _, count in ROW_SPECS)
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
||||
|
||||
FRAME_COUNTS: dict[str, int] = {state: count for state, _, count in ROW_SPECS}
|
||||
|
||||
# Alpha at/below which a pixel is "background" for component detection.
|
||||
_ALPHA_FLOOR = 16
|
||||
# Cell padding kept around a fitted sprite so poses never touch the edge.
|
||||
_CELL_PAD = 10
|
||||
# Margin for the normalized pass — small, to fill the cell like real petdex pets
|
||||
# (they sit ~5px from the edges); the width clamp, not the pad, prevents clipping.
|
||||
_NORMALIZE_PAD = 14
|
||||
# Side-lobe cutoff for fitted frames. Adjacent-pose bleed usually appears as a
|
||||
# small separated horizontal lobe beside the real subject; keep sizeable lobes so
|
||||
# we don't punish a legitimate wide pose.
|
||||
_SIDE_LOBE_RATIO = 0.18
|
||||
|
||||
|
||||
# ───────────────────────── background removal ─────────────────────────
|
||||
|
||||
|
||||
def _color_distance(r: int, g: int, b: int, key: tuple[int, int, int]) -> float:
|
||||
return math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2)
|
||||
|
||||
|
||||
def _has_transparency(image) -> bool:
|
||||
"""True if the strip already carries a real alpha background."""
|
||||
extrema = image.getchannel("A").getextrema()
|
||||
# Min alpha 0 somewhere and a meaningful share of fully-transparent pixels.
|
||||
if extrema[0] > _ALPHA_FLOOR:
|
||||
return False
|
||||
hist = image.getchannel("A").histogram()
|
||||
transparent = sum(hist[: _ALPHA_FLOOR + 1])
|
||||
total = image.width * image.height
|
||||
return transparent > total * 0.05
|
||||
|
||||
|
||||
def _dominant_corner_color(image) -> tuple[int, int, int]:
|
||||
"""Sample the four corners and return the most common opaque color."""
|
||||
from collections import Counter
|
||||
|
||||
w, h = image.width, image.height
|
||||
px = image.load()
|
||||
counter: Counter = Counter()
|
||||
for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
|
||||
r, g, b, a = px[x, y]
|
||||
if a > _ALPHA_FLOOR:
|
||||
counter[(r, g, b)] += 1
|
||||
if not counter:
|
||||
return (0, 255, 0)
|
||||
return counter.most_common(1)[0][0]
|
||||
|
||||
|
||||
def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48):
|
||||
"""An ``L`` mask, 255 where a pixel is within *tol* per-channel of *key*.
|
||||
|
||||
Tight on purpose: it only marks near-pure backdrop so trapped chroma pockets
|
||||
seed the flood, while chroma-*tinted* character pixels stay outside it. Built
|
||||
with channel point-ops (fast C), no per-pixel Python.
|
||||
"""
|
||||
from PIL import ImageChops
|
||||
|
||||
r, g, b, _a = image.split()
|
||||
kr, kg, kb = key
|
||||
return ImageChops.darker(
|
||||
ImageChops.darker(
|
||||
r.point(lambda v: 255 if abs(v - kr) <= tol else 0),
|
||||
g.point(lambda v: 255 if abs(v - kg) <= tol else 0),
|
||||
),
|
||||
b.point(lambda v: 255 if abs(v - kb) <= tol else 0),
|
||||
)
|
||||
|
||||
|
||||
def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 90.0):
|
||||
"""Return *image* (RGBA) with its flat background keyed out to transparent.
|
||||
|
||||
If the strip already has a transparent background we leave it alone; else we
|
||||
key out *chroma_key* (or the dominant corner color when not given) via a
|
||||
**border flood-fill**: only background-coloured pixels *connected to an edge*
|
||||
are removed. A global color match (the old approach) punched holes in the pet
|
||||
wherever an interior highlight happened to match the backdrop — e.g. a pug's
|
||||
light belly against a near-white background — which then showed through as the
|
||||
window behind. Flood-fill keeps those interior pixels because they aren't
|
||||
reachable from the border without crossing the (non-background) pet.
|
||||
"""
|
||||
from collections import deque
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
rgba = image.convert("RGBA")
|
||||
if _has_transparency(rgba):
|
||||
return _repair_internal_alpha_holes(rgba)
|
||||
|
||||
key = chroma_key or _dominant_corner_color(rgba)
|
||||
w, h = rgba.width, rgba.height
|
||||
px = rgba.load()
|
||||
|
||||
def _is_bg(x: int, y: int) -> bool:
|
||||
r, g, b, a = px[x, y]
|
||||
return a > _ALPHA_FLOOR and _color_distance(r, g, b, key) <= threshold
|
||||
|
||||
# Fast path for strongly-saturated chroma keys (our normal sprite prompts use
|
||||
# hot magenta): remove all near-key opaque pixels with C-level channel ops.
|
||||
# This clears both border-connected backdrop and enclosed triangular pockets
|
||||
# between connected limbs/capes, without a Python flood over ~1.5M pixels.
|
||||
if max(key) - min(key) >= 120:
|
||||
near = _near_key_mask(rgba, key) # L mask, 255 where near key
|
||||
opaque = rgba.getchannel("A").point(lambda a: 255 if a > _ALPHA_FLOOR else 0)
|
||||
remove_mask = ImageChops.darker(near, opaque)
|
||||
return Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, remove_mask)
|
||||
|
||||
visited = bytearray(w * h)
|
||||
# Mark removals in a flat mask and apply them in one C composite at the end —
|
||||
# writing `px[x, y] = (0,0,0,0)` per pixel was ~3M PixelAccess calls (84% of
|
||||
# the whole pipeline) and pegged a core in pure Python, stalling the gateway.
|
||||
remove = bytearray(w * h)
|
||||
queue: deque[tuple[int, int]] = deque()
|
||||
|
||||
# Seed from every border pixel that looks like background.
|
||||
for x in range(w):
|
||||
for y in (0, h - 1):
|
||||
if _is_bg(x, y) and not visited[y * w + x]:
|
||||
visited[y * w + x] = 1
|
||||
queue.append((x, y))
|
||||
for y in range(h):
|
||||
for x in (0, w - 1):
|
||||
if _is_bg(x, y) and not visited[y * w + x]:
|
||||
visited[y * w + x] = 1
|
||||
queue.append((x, y))
|
||||
|
||||
# Trapped pockets: background enclosed by the character (the magenta between
|
||||
# an arm and the body) isn't border-reachable, so also seed the flood from
|
||||
# interior near-key pixels. Gated to a *saturated* key (our magenta backdrop)
|
||||
# so we never seed from a character sharing a desaturated near-white/gray key
|
||||
# — that's the hole-punching the border-only flood exists to avoid.
|
||||
if max(key) - min(key) >= 120:
|
||||
for i, near in enumerate(_near_key_mask(rgba, key).getdata()):
|
||||
if near and not visited[i]:
|
||||
visited[i] = 1
|
||||
queue.append((i % w, i // w))
|
||||
|
||||
while queue:
|
||||
x, y = queue.popleft()
|
||||
remove[y * w + x] = 1
|
||||
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
||||
if 0 <= nx < w and 0 <= ny < h:
|
||||
idx = ny * w + nx
|
||||
if not visited[idx]:
|
||||
visited[idx] = 1
|
||||
if _is_bg(nx, ny):
|
||||
queue.append((nx, ny))
|
||||
|
||||
# One C-level composite instead of millions of per-pixel writes: paint the
|
||||
# flooded pixels to (0,0,0,0) wherever the mask is set.
|
||||
mask = Image.frombytes("L", (w, h), bytes(remove)).point(lambda v: 255 if v else 0)
|
||||
return Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, mask)
|
||||
|
||||
|
||||
def _repair_internal_alpha_holes(image):
|
||||
"""Fill transparent islands fully enclosed by opaque sprite pixels.
|
||||
|
||||
Some providers return "transparent" PNGs with swiss-cheese alpha inside the
|
||||
character. Border flood-fill cannot see those because there is no opaque
|
||||
backdrop to key, so repair the alpha mask itself: transparent components that
|
||||
touch an image edge remain background; transparent components enclosed by
|
||||
the sprite are filled with the average color of their opaque neighbours.
|
||||
"""
|
||||
from collections import deque
|
||||
|
||||
rgba = image.convert("RGBA")
|
||||
w, h = rgba.size
|
||||
px = rgba.load()
|
||||
visited = bytearray(w * h)
|
||||
|
||||
def _is_transparent(x: int, y: int) -> bool:
|
||||
return px[x, y][3] <= _ALPHA_FLOOR
|
||||
|
||||
def _mark_border_component(sx: int, sy: int) -> None:
|
||||
queue: deque[tuple[int, int]] = deque([(sx, sy)])
|
||||
visited[sy * w + sx] = 1
|
||||
while queue:
|
||||
x, y = queue.popleft()
|
||||
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
||||
if 0 <= nx < w and 0 <= ny < h:
|
||||
idx = ny * w + nx
|
||||
if not visited[idx] and _is_transparent(nx, ny):
|
||||
visited[idx] = 1
|
||||
queue.append((nx, ny))
|
||||
|
||||
# First mark true background: all transparent pixels reachable from the edge.
|
||||
for x in range(w):
|
||||
for y in (0, h - 1):
|
||||
if _is_transparent(x, y) and not visited[y * w + x]:
|
||||
_mark_border_component(x, y)
|
||||
for y in range(h):
|
||||
for x in (0, w - 1):
|
||||
if _is_transparent(x, y) and not visited[y * w + x]:
|
||||
_mark_border_component(x, y)
|
||||
|
||||
def _collect_hole(sx: int, sy: int) -> list[tuple[int, int]]:
|
||||
queue: deque[tuple[int, int]] = deque([(sx, sy)])
|
||||
visited[sy * w + sx] = 1
|
||||
pixels: list[tuple[int, int]] = []
|
||||
while queue:
|
||||
x, y = queue.popleft()
|
||||
pixels.append((x, y))
|
||||
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
||||
if 0 <= nx < w and 0 <= ny < h:
|
||||
idx = ny * w + nx
|
||||
if not visited[idx] and _is_transparent(nx, ny):
|
||||
visited[idx] = 1
|
||||
queue.append((nx, ny))
|
||||
return pixels
|
||||
|
||||
def _fill_color(hole: list[tuple[int, int]]) -> tuple[int, int, int, int]:
|
||||
samples: list[tuple[int, int, int]] = []
|
||||
seen = set(hole)
|
||||
for x, y in hole:
|
||||
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
||||
if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen:
|
||||
r, g, b, a = px[nx, ny]
|
||||
if a > _ALPHA_FLOOR:
|
||||
samples.append((r, g, b))
|
||||
if not samples:
|
||||
return (0, 0, 0, 255)
|
||||
return (
|
||||
round(sum(c[0] for c in samples) / len(samples)),
|
||||
round(sum(c[1] for c in samples) / len(samples)),
|
||||
round(sum(c[2] for c in samples) / len(samples)),
|
||||
255,
|
||||
)
|
||||
|
||||
for start, _ in enumerate(visited):
|
||||
if visited[start]:
|
||||
continue
|
||||
x = start % w
|
||||
y = start // w
|
||||
if not _is_transparent(x, y):
|
||||
continue
|
||||
hole = _collect_hole(x, y)
|
||||
color = _fill_color(hole)
|
||||
for hx, hy in hole:
|
||||
px[hx, hy] = color
|
||||
return rgba
|
||||
|
||||
|
||||
# ───────────────────────── frame extraction ─────────────────────────
|
||||
|
||||
|
||||
def _fit_to_cell(image):
|
||||
"""Crop to content, scale to fit a padded cell, and center on transparent."""
|
||||
from PIL import Image
|
||||
|
||||
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
image = _drop_side_bleed(image)
|
||||
bbox = image.getbbox()
|
||||
if bbox is None:
|
||||
return target
|
||||
|
||||
sprite = image.crop(bbox)
|
||||
max_w = CELL_WIDTH - _CELL_PAD
|
||||
max_h = CELL_HEIGHT - _CELL_PAD
|
||||
scale = min(max_w / sprite.width, max_h / sprite.height, 1.0)
|
||||
if scale != 1.0:
|
||||
# NEAREST, not LANCZOS: the generated "pixel art" has hard edges, and any
|
||||
# interpolating resample anti-aliases them into a blurry, washed-out
|
||||
# sprite once the renderer upscales the cell. Crisp blocky downscale reads
|
||||
# as real pixel art.
|
||||
sprite = sprite.resize(
|
||||
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
||||
Image.Resampling.NEAREST,
|
||||
)
|
||||
left = (CELL_WIDTH - sprite.width) // 2
|
||||
top = (CELL_HEIGHT - sprite.height) // 2
|
||||
target.alpha_composite(sprite, (left, top))
|
||||
return target
|
||||
|
||||
|
||||
def _drop_side_bleed(image):
|
||||
"""Remove tiny separated left/right lobes before fitting a frame.
|
||||
|
||||
Frogger showed the failure mode: a good centered pose plus a thin vertical
|
||||
sliver from the neighbouring pose. By the time it reaches a cell, that sliver
|
||||
may be close enough to the subject that component extraction already grouped
|
||||
it. A horizontal alpha projection still reveals it as a small side lobe with
|
||||
a low mass compared to the main silhouette. Drop only those low-mass lobes;
|
||||
keep large lobes so wide poses and real limbs survive.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
rgba = image.convert("RGBA")
|
||||
w, h = rgba.size
|
||||
profile = _column_profile(rgba) # mean alpha per column (fast C resize)
|
||||
|
||||
runs = _content_runs(profile)
|
||||
if len(runs) < 2:
|
||||
return rgba
|
||||
masses = [sum(profile[l:r]) for l, r in runs]
|
||||
keep_mass = max(masses) * _SIDE_LOBE_RATIO
|
||||
keep = [run for run, m in zip(runs, masses) if m >= keep_mass]
|
||||
if len(keep) == len(runs):
|
||||
return rgba
|
||||
|
||||
# Zero every column band that isn't a kept segment (box paste, not per-pixel).
|
||||
rgba = rgba.copy()
|
||||
cut, prev = Image.new("RGBA", (w, h), (0, 0, 0, 0)), 0
|
||||
for left, right in keep:
|
||||
if left > prev:
|
||||
rgba.paste(cut.crop((prev, 0, left, h)), (prev, 0))
|
||||
prev = right
|
||||
if prev < w:
|
||||
rgba.paste(cut.crop((prev, 0, w, h)), (prev, 0))
|
||||
return rgba
|
||||
|
||||
|
||||
def _sever_expected_gutters(strip, frame_count: int):
|
||||
"""Cut thin vertical gutters at expected frame boundaries before labeling.
|
||||
|
||||
Generated rows often have a shared shadow, glow, motion smear, or 1px bridge
|
||||
that connects neighbouring poses. Component detection then sees one giant
|
||||
blob and either fails or falls back to slot slicing. We know the requested
|
||||
frame count, so cut a very narrow transparent band at each expected boundary
|
||||
before connected-component labeling. If a pose truly overlaps the boundary,
|
||||
losing a few pixels is better than exporting merged frames.
|
||||
"""
|
||||
if frame_count <= 1:
|
||||
return strip
|
||||
|
||||
out = strip.copy()
|
||||
px = out.load()
|
||||
slot = out.width / frame_count
|
||||
half = max(3, min(18, round(slot * 0.06)))
|
||||
for i in range(1, frame_count):
|
||||
x = round(i * slot)
|
||||
left = max(0, x - half)
|
||||
right = min(out.width, x + half + 1)
|
||||
for gx in range(left, right):
|
||||
for gy in range(out.height):
|
||||
r, g, b, _a = px[gx, gy]
|
||||
px[gx, gy] = (r, g, b, 0)
|
||||
return out
|
||||
|
||||
|
||||
def _slot_crops(strip, frame_count: int) -> list:
|
||||
"""Slice *strip* into *frame_count* uniform columns (one coordinate space).
|
||||
|
||||
Equal-width columns keep every frame in a single shared coordinate frame, so
|
||||
a later union-crop + shared placement (:func:`normalize_cells`) preserves the
|
||||
row's real motion without the per-frame re-centering that makes a pet visibly
|
||||
slide. Neighbour side-bleed is trimmed per column.
|
||||
"""
|
||||
w0 = max(1, strip.width // frame_count)
|
||||
h = strip.height
|
||||
return [_drop_side_bleed(strip.crop((i * w0, 0, i * w0 + w0, h))) for i in range(frame_count)]
|
||||
|
||||
|
||||
def _content_runs(profile: list[int], *, threshold: int = 2) -> list[tuple[int, int]]:
|
||||
"""Contiguous column spans whose alpha mass exceeds *threshold*.
|
||||
|
||||
A column-projection of the alpha mask: empty (background) columns separate
|
||||
one pose from the next, so the runs ARE the candidate frames.
|
||||
"""
|
||||
runs: list[tuple[int, int]] = []
|
||||
start: int | None = None
|
||||
for x, v in enumerate(list(profile) + [0]):
|
||||
if v > threshold:
|
||||
if start is None:
|
||||
start = x
|
||||
elif start is not None:
|
||||
runs.append((start, x))
|
||||
start = None
|
||||
return runs
|
||||
|
||||
|
||||
def _frame_x_ranges(strip, frame_count: int) -> list[tuple[int, int]] | None:
|
||||
"""Per-frame ``(left, right)`` column ranges from the row's empty gutters.
|
||||
|
||||
The standard sprite-sheet slice — once poses are separated by real gaps
|
||||
(which generation now enforces), splitting is just "find the empty columns":
|
||||
|
||||
* spans == frames → one span per frame.
|
||||
* spans > frames → merge across the smallest gaps. A detached halo/ear sits
|
||||
a tiny gap from its body, while the inter-pose gutter is the big gap that
|
||||
survives — so over-segmentation (and any over-eager gutter sever) repairs
|
||||
itself by collapsing only the small internal gaps.
|
||||
* spans < frames → poses are touching; not separable by gutters (the caller
|
||||
raises for ``components`` or falls back to even slots for ``auto``).
|
||||
|
||||
Ranges span content only; the caller crops full cell height, so tall ears /
|
||||
halos are never cut.
|
||||
"""
|
||||
profile = _column_profile(strip)
|
||||
runs = _content_runs(profile)
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
# Drop trivial specks so stray noise never counts as a pose.
|
||||
masses = [sum(profile[l:r]) for l, r in runs]
|
||||
floor = max(masses) * 0.02
|
||||
runs = [run for run, m in zip(runs, masses) if m >= floor]
|
||||
if len(runs) < frame_count:
|
||||
return None
|
||||
|
||||
groups = [[l, r] for l, r in runs]
|
||||
while len(groups) > frame_count:
|
||||
gi = min(range(len(groups) - 1), key=lambda i: groups[i + 1][0] - groups[i][1])
|
||||
groups[gi][1] = groups[gi + 1][1]
|
||||
del groups[gi + 1]
|
||||
return [(l, r) for l, r in groups]
|
||||
|
||||
|
||||
def extract_strip_frames(
|
||||
strip,
|
||||
frame_count: int,
|
||||
*,
|
||||
chroma_key: tuple[int, int, int] | None = None,
|
||||
method: str = "auto",
|
||||
fit: bool = True,
|
||||
) -> list:
|
||||
"""Turn one generated row strip into *frame_count* frames.
|
||||
|
||||
The background is keyed out, thin connecting bridges at the expected
|
||||
boundaries are severed, then the strip is sliced at its empty chroma gutters
|
||||
(:func:`_frame_x_ranges`) — the plain "find each object, make a frame" cut
|
||||
that works once poses are spaced apart (which generation now enforces).
|
||||
|
||||
Each frame is cropped at full cell height so tall ears / halos are never
|
||||
clipped; :func:`_drop_side_bleed` trims any faint neighbour sliver. When the
|
||||
poses are touching (fewer gutters than frames) ``components`` raises and
|
||||
``auto`` falls back to equal-width slots.
|
||||
|
||||
*fit* (default) fits+centers each frame into a 192x208 cell — the standalone
|
||||
contract for callers that don't normalize. Hatching passes ``fit=False`` to
|
||||
keep raw, coordinate-aligned columns for :func:`normalize_cells`, which lays
|
||||
one shared scale + baseline across the whole pet (no slide, no size pulse).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(strip, (str, Path)):
|
||||
with Image.open(strip) as opened:
|
||||
strip = opened.convert("RGBA")
|
||||
else:
|
||||
strip = strip.convert("RGBA")
|
||||
|
||||
strip = remove_background(strip, chroma_key=chroma_key)
|
||||
|
||||
# Prefer the real gutters as-is: when poses are already spaced (generation
|
||||
# enforces this), slicing the strip untouched keeps each pose's own bounds and
|
||||
# never cuts through an unevenly-placed silhouette. Only fall back to severing
|
||||
# the expected boundaries when gaps alone can't separate the row — i.e. poses
|
||||
# are bridged by a shared shadow/glow/1px line and read as one blob.
|
||||
source = strip
|
||||
ranges = _frame_x_ranges(source, frame_count)
|
||||
if ranges is None:
|
||||
source = _sever_expected_gutters(strip, frame_count)
|
||||
ranges = _frame_x_ranges(source, frame_count)
|
||||
|
||||
if ranges is None:
|
||||
if method == "components":
|
||||
raise ValueError(f"could not segment {frame_count} sprites from strip")
|
||||
frames = _slot_crops(source, frame_count)
|
||||
else:
|
||||
h = source.height
|
||||
pad = max(2, min(16, round((source.width / max(1, frame_count)) * 0.04)))
|
||||
frames = [
|
||||
_drop_side_bleed(source.crop((max(0, left - pad), 0, min(source.width, right + pad), h)))
|
||||
for left, right in ranges
|
||||
]
|
||||
return [_fit_to_cell(f) for f in frames] if fit else frames
|
||||
|
||||
|
||||
def _column_profile(image) -> list[int]:
|
||||
"""Per-column alpha mass — collapse the frame to a 1px-tall strip (fast in C)."""
|
||||
from PIL import Image
|
||||
|
||||
return list(image.getchannel("A").resize((image.width, 1), Image.BILINEAR).getdata())
|
||||
|
||||
|
||||
def _best_shift(ref: list[int], prof: list[int], window: int) -> int:
|
||||
"""Integer dx that best aligns *prof* onto *ref* by cross-correlation.
|
||||
|
||||
This is 1-D phase correlation: the body is the dominant mass in the column
|
||||
profile, so the peak overlap locks onto the body and a flipping arm/cape (a
|
||||
small secondary bump) doesn't move the match. Proven on the jitter case to
|
||||
cut body drift from ~9px to ~1px where a centroid/bbox anchor cannot.
|
||||
"""
|
||||
n = len(ref)
|
||||
best_score: float | None = None
|
||||
best = 0
|
||||
for d in range(-window, window + 1):
|
||||
score = 0
|
||||
for x in range(max(0, d), min(n, n + d)):
|
||||
score += ref[x] * prof[x - d]
|
||||
if best_score is None or score > best_score:
|
||||
best_score = score
|
||||
best = d
|
||||
return best
|
||||
|
||||
|
||||
def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_PAD) -> dict[str, list]:
|
||||
"""Register every frame into a 192x208 cell — the deterministic anti-jitter math.
|
||||
|
||||
A per-frame "crop→scale→center" pipeline jitters because a moving limb/cape
|
||||
shifts the bbox (or even the centroid) and a per-frame scale pulses the size.
|
||||
The rigorous fix, matching image-registration practice (phase correlation)
|
||||
and AI-sprite pipelines (perfectpixel-studio / sprite-gen):
|
||||
|
||||
1. **Cross-correlate** each frame's column profile against the per-state
|
||||
*median* profile to find the integer shift that locks the **body** in
|
||||
place — robust to limbs/cape because the body dominates the profile.
|
||||
2. **Union-crop** through one shared state window, then scale every state by a
|
||||
single global factor keyed to its median pose height, so the character is
|
||||
the same on-screen size in every row while a jump's lift still fits.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
blank = lambda: Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
med = lambda vs: sorted(vs)[len(vs) // 2] # robust center; ignores a limb/cape outlier
|
||||
|
||||
out: dict[str, list] = {}
|
||||
prepared: dict[str, tuple[list, tuple[int, int, int, int], tuple[int, int]]] = {}
|
||||
# Fill the cell — real petdex pets sit ~pad from the edges; the K cap below
|
||||
# keeps a tall pose (a jump's lift) from clipping.
|
||||
target_w = CELL_WIDTH - pad
|
||||
target_h = CELL_HEIGHT - pad
|
||||
|
||||
for state, frames in frames_by_state.items():
|
||||
rgba = [f.convert("RGBA") for f in frames]
|
||||
if not any(f.getbbox() for f in rgba):
|
||||
out[state] = [blank() for _ in frames]
|
||||
continue
|
||||
|
||||
# Pad every frame to a common canvas so column profiles are comparable.
|
||||
w0 = max(f.width for f in rgba)
|
||||
h0 = max(f.height for f in rgba)
|
||||
canvas = []
|
||||
for f in rgba:
|
||||
if f.size != (w0, h0):
|
||||
c = Image.new("RGBA", (w0, h0), (0, 0, 0, 0))
|
||||
c.alpha_composite(f, (0, 0))
|
||||
f = c
|
||||
canvas.append(f)
|
||||
|
||||
# Register horizontally: shift each frame to lock the body (xcorr).
|
||||
profiles = [_column_profile(f) for f in canvas]
|
||||
ref = [sorted(p[x] for p in profiles)[len(profiles) // 2] for x in range(w0)]
|
||||
window = max(8, w0 // 5)
|
||||
margin = window
|
||||
aligned = []
|
||||
for f, prof in zip(canvas, profiles):
|
||||
shifted = Image.new("RGBA", (w0 + 2 * margin, h0), (0, 0, 0, 0))
|
||||
shifted.alpha_composite(f, (margin + _best_shift(ref, prof, window), 0))
|
||||
aligned.append(shifted)
|
||||
|
||||
# Shared window over the registered set; scale is resolved against a
|
||||
# common apparent-character target below.
|
||||
boxes = [b for b in (a.getbbox() for a in aligned) if b]
|
||||
left = min(b[0] for b in boxes)
|
||||
top = min(b[1] for b in boxes)
|
||||
right = max(b[2] for b in boxes)
|
||||
bottom = max(b[3] for b in boxes)
|
||||
prepared[state] = (
|
||||
aligned,
|
||||
(left, top, right, bottom),
|
||||
(med([b[2] - b[0] for b in boxes]), med([b[3] - b[1] for b in boxes])),
|
||||
)
|
||||
|
||||
if not prepared:
|
||||
return out
|
||||
|
||||
# Uniform apparent size: scale each state by K / pose_h, so a row the model
|
||||
# drew small renders as big as one it drew large. K is the one global cap that
|
||||
# keeps the tallest/widest motion envelope (a jump's lift) inside the cell —
|
||||
# for a still row union ≈ pose so its term ≈ target_h (full fill).
|
||||
K = target_h
|
||||
for (_aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.values():
|
||||
uw, uh = right - left, bottom - top
|
||||
K = min(K, target_h * pose_h / max(1, uh), target_w * pose_h / max(1, uw))
|
||||
|
||||
for state, (aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.items():
|
||||
uw, uh = right - left, bottom - top
|
||||
scale = K / max(1, pose_h)
|
||||
sw, sh = max(1, round(uw * scale)), max(1, round(uh * scale))
|
||||
px, py = round((CELL_WIDTH - sw) / 2), round((CELL_HEIGHT - pad // 2) - sh)
|
||||
|
||||
cells = []
|
||||
for a in aligned:
|
||||
crop = a.crop((left, top, right, bottom))
|
||||
if crop.size != (sw, sh):
|
||||
# NEAREST keeps the pixel-art edges crisp; LANCZOS blurred them.
|
||||
crop = crop.resize((sw, sh), Image.Resampling.NEAREST)
|
||||
cell = blank()
|
||||
cell.alpha_composite(crop, (px, py))
|
||||
cells.append(cell)
|
||||
out[state] = cells
|
||||
return out
|
||||
|
||||
|
||||
# ───────────────────────── atlas composition ─────────────────────────
|
||||
|
||||
|
||||
def single_frame(image, *, fit: bool = True):
|
||||
"""One frame from a standalone image (e.g. the base look).
|
||||
|
||||
Used as an idle fallback so a pet always renders even if the idle row
|
||||
generation failed. *fit* yields a finished 192x208 cell; ``fit=False`` yields
|
||||
the raw keyed sprite for :func:`normalize_cells` to place with the rest.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(image, (str, Path)):
|
||||
with Image.open(image) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
keyed = remove_background(image)
|
||||
return _fit_to_cell(keyed) if fit else _drop_side_bleed(keyed)
|
||||
|
||||
|
||||
def _clear_transparent_rgb(image):
|
||||
"""Zero the RGB of fully-transparent pixels (no colored-halo residue)."""
|
||||
from PIL import Image
|
||||
|
||||
rgba = image.convert("RGBA")
|
||||
data = bytearray(rgba.tobytes())
|
||||
for i in range(0, len(data), 4):
|
||||
if data[i + 3] == 0:
|
||||
data[i] = data[i + 1] = data[i + 2] = 0
|
||||
return Image.frombytes("RGBA", rgba.size, bytes(data))
|
||||
|
||||
|
||||
def mirror_frames(frames: list) -> list:
|
||||
"""Horizontally flip each frame *in place* (RGBA-safe).
|
||||
|
||||
Used to derive ``running-left`` from an approved ``running-right`` row. The
|
||||
flip is per-frame so the leftward loop preserves the rightward loop's frame
|
||||
order and timing — this is NOT a whole-strip reverse (which would play the
|
||||
animation backwards), matching the petdex/Codex mirror rule.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
flip = getattr(Image, "Transpose", Image).FLIP_LEFT_RIGHT
|
||||
return [frame.convert("RGBA").transpose(flip) for frame in frames]
|
||||
|
||||
|
||||
def compose_atlas(frames_by_state: dict[str, list]):
|
||||
"""Pack per-state frame lists into the Hermes atlas (RGBA, residue-cleared).
|
||||
|
||||
Missing/short states leave their trailing cells transparent; extra frames
|
||||
beyond a state's spec are dropped.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
||||
for state, row, count in ROW_SPECS:
|
||||
frames = frames_by_state.get(state) or []
|
||||
for col, frame in enumerate(frames[:count]):
|
||||
cell = frame.convert("RGBA")
|
||||
if cell.size != (CELL_WIDTH, CELL_HEIGHT):
|
||||
cell = _fit_to_cell(cell)
|
||||
atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT))
|
||||
return _clear_transparent_rgb(atlas)
|
||||
|
||||
|
||||
def atlas_to_webp_bytes(atlas) -> bytes:
|
||||
"""Encode an atlas image to lossless WebP bytes (the on-disk pet format)."""
|
||||
buf = io.BytesIO()
|
||||
atlas.save(buf, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def validate_atlas(atlas) -> dict:
|
||||
"""Check geometry, per-cell occupancy, and transparency invariants.
|
||||
|
||||
Returns ``{ok, width, height, errors, warnings, filled_states}``. Errors are
|
||||
blockers (wrong size, empty used cell, opaque/dirty transparency); warnings
|
||||
are soft (a whole state row blank — generation likely dropped a row).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(atlas, (str, Path)):
|
||||
with Image.open(atlas) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
else:
|
||||
atlas = atlas.convert("RGBA")
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
|
||||
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}")
|
||||
return {"ok": False, "width": atlas.width, "height": atlas.height, "errors": errors, "warnings": warnings, "filled_states": []}
|
||||
|
||||
filled_states: list[str] = []
|
||||
for state, row, count in ROW_SPECS:
|
||||
row_pixels = 0
|
||||
for col in range(count):
|
||||
left = col * CELL_WIDTH
|
||||
top = row * CELL_HEIGHT
|
||||
cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
nonblank = sum(cell.getchannel("A").histogram()[1:])
|
||||
row_pixels += nonblank
|
||||
if row_pixels > 0:
|
||||
filled_states.append(state)
|
||||
else:
|
||||
warnings.append(f"state '{state}' has no frames")
|
||||
|
||||
if not filled_states:
|
||||
errors.append("atlas is empty — no state produced any frames")
|
||||
|
||||
# Transparent pixels must carry zero RGB (no halo residue).
|
||||
data = atlas.tobytes()
|
||||
residue = 0
|
||||
for i in range(0, len(data), 4):
|
||||
if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]):
|
||||
residue += 1
|
||||
if residue:
|
||||
errors.append(f"{residue} transparent pixels retain RGB residue")
|
||||
|
||||
return {
|
||||
"ok": not errors,
|
||||
"width": atlas.width,
|
||||
"height": atlas.height,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"filled_states": filled_states,
|
||||
}
|
||||
233
agent/pet/generate/imagegen.py
Normal file
233
agent/pet/generate/imagegen.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Thin image-generation layer for pet sprites.
|
||||
|
||||
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
|
||||
two things sprite generation needs that the agent-facing ``image_generate`` tool
|
||||
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
|
||||
animation row stays the same character as the chosen base).
|
||||
|
||||
Reference grounding only works on providers that support it — currently OpenAI
|
||||
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
|
||||
those and surface a clear, actionable error otherwise rather than silently
|
||||
producing an ungrounded, drifting pet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Providers that can ground generation on a reference image.
|
||||
# openrouter / nous reach Gemini Flash Image (and friends) over the
|
||||
# OpenRouter-compatible chat-completions image protocol, which accepts
|
||||
# reference images for grounding. Nous Portal proxies OpenRouter, so both
|
||||
# qualify.
|
||||
_REF_CAPABLE = ("openai", "openai-codex", "krea", "openrouter", "nous")
|
||||
|
||||
# Friendly label + one-line speed/quality note per reference-capable provider,
|
||||
# surfaced in the desktop pet-gen picker so users can trade speed for fidelity.
|
||||
_PROVIDER_META: dict[str, dict[str, str]] = {
|
||||
"nous": {"label": "Nous Portal", "note": "Fast, balanced quality"},
|
||||
"openrouter": {"label": "OpenRouter", "note": "Fastest — Gemini Flash Image"},
|
||||
"openai": {"label": "OpenAI", "note": "Highest fidelity, slower"},
|
||||
"openai-codex": {"label": "OpenAI (Codex)", "note": "Highest fidelity, slower"},
|
||||
"krea": {"label": "Krea", "note": "Stylized, style-reference grounding"},
|
||||
}
|
||||
|
||||
|
||||
class GenerationError(RuntimeError):
|
||||
"""Raised on any image-generation failure (no provider, API error, IO)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpriteProvider:
|
||||
"""Resolved provider plus whether it can take reference images."""
|
||||
|
||||
name: str
|
||||
provider: object
|
||||
supports_references: bool
|
||||
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc: # noqa: BLE001 - discovery is best-effort
|
||||
logger.debug("image-gen plugin discovery failed: %s", exc)
|
||||
|
||||
|
||||
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
|
||||
"""Pick the image provider to use for sprite work.
|
||||
|
||||
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
|
||||
reference-capable and configured, then the configured/active provider when
|
||||
it's reference-capable, else the first available reference-capable provider.
|
||||
With *require_references* off we fall back to any available provider (used for
|
||||
prompt-only base drafts).
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_active_provider, get_provider
|
||||
|
||||
# An explicit user pick wins when it's reference-capable and has credentials;
|
||||
# otherwise we ignore it and fall through to the normal resolution.
|
||||
if prefer:
|
||||
chosen = get_provider(prefer)
|
||||
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
|
||||
|
||||
# Configured / active provider first.
|
||||
active = None
|
||||
try:
|
||||
active = get_active_provider()
|
||||
except Exception: # noqa: BLE001
|
||||
active = None
|
||||
if active is not None:
|
||||
name = getattr(active, "name", "")
|
||||
if name in _REF_CAPABLE and active.is_available():
|
||||
return SpriteProvider(name=name, provider=active, supports_references=True)
|
||||
|
||||
# Any available reference-capable provider.
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is not None and provider.is_available():
|
||||
return SpriteProvider(name=name, provider=provider, supports_references=True)
|
||||
|
||||
if not require_references and active is not None and active.is_available():
|
||||
return SpriteProvider(
|
||||
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
|
||||
)
|
||||
|
||||
raise GenerationError(
|
||||
"Pet generation needs an image backend that supports reference images. "
|
||||
"Open `hermes tools` → Image Generation and configure Nous Portal, "
|
||||
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
|
||||
)
|
||||
|
||||
|
||||
def list_sprite_providers() -> list[dict]:
|
||||
"""The reference-capable providers available to pick for pet generation.
|
||||
|
||||
Returns ``[{name, label, note, default}]`` for every ref-capable provider the
|
||||
user actually has credentials for, marking the one :func:`resolve_provider`
|
||||
would choose with no explicit preference. Empty when none is configured (the
|
||||
picker hides itself). Best-effort: discovery hiccups yield an empty list.
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_provider
|
||||
|
||||
try:
|
||||
default_name = resolve_provider(require_references=True).name
|
||||
except GenerationError:
|
||||
default_name = ""
|
||||
|
||||
out: list[dict] = []
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is None or not provider.is_available():
|
||||
continue
|
||||
meta = _PROVIDER_META.get(name, {})
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": meta.get("label", name),
|
||||
"note": meta.get("note", ""),
|
||||
"default": name == default_name,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _save_local(image_ref: str, *, prefix: str) -> Path:
|
||||
"""Return a local path for *image_ref*, downloading it if it's a URL."""
|
||||
if image_ref.startswith(("http://", "https://")):
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
return Path(save_url_image(image_ref, prefix=prefix))
|
||||
return Path(image_ref)
|
||||
|
||||
|
||||
def _rejected_background(error: str) -> bool:
|
||||
"""True when a provider error is specifically about the ``background`` param.
|
||||
|
||||
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
|
||||
reject ``background=transparent`` outright). We detect that one rejection so
|
||||
we can retry without the flag rather than failing the whole pet — our chroma
|
||||
key pass makes the result transparent regardless.
|
||||
"""
|
||||
lowered = (error or "").lower()
|
||||
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
|
||||
|
||||
|
||||
def generate(
|
||||
prompt: str,
|
||||
*,
|
||||
n: int = 1,
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
prefix: str = "pet_gen",
|
||||
aspect_ratio: str = "square",
|
||||
) -> list[Path]:
|
||||
"""Generate *n* sprite images and return their local paths.
|
||||
|
||||
*reference_images* grounds the output on a base image (required for rows).
|
||||
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
|
||||
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
|
||||
gives every frame real horizontal room so winged poses don't have to be
|
||||
shrunk to avoid touching their neighbors).
|
||||
We *ask* for a transparent background, but fall back to an opaque generation
|
||||
(cleaned up downstream by the chroma-key pass) on models that reject the
|
||||
flag. Raises :class:`GenerationError` if nothing usable comes back.
|
||||
"""
|
||||
sprite = provider or resolve_provider(require_references=bool(reference_images))
|
||||
if reference_images and not sprite.supports_references:
|
||||
raise GenerationError(
|
||||
f"image backend '{sprite.name}' cannot use reference images; "
|
||||
"configure OpenAI gpt-image-2 or Krea for pet generation"
|
||||
)
|
||||
|
||||
refs = [str(p) for p in (reference_images or [])]
|
||||
|
||||
def _run(extra: dict) -> tuple[Path | None, str]:
|
||||
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
|
||||
if refs:
|
||||
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
|
||||
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
|
||||
# ``reference_image_urls``. Send both; each ignores the other.
|
||||
kwargs["reference_images"] = refs
|
||||
kwargs["reference_image_urls"] = refs
|
||||
try:
|
||||
result = sprite.provider.generate(prompt, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - normalize provider crashes
|
||||
logger.debug("provider.generate crashed: %s", exc)
|
||||
return None, str(exc)
|
||||
if not isinstance(result, dict) or not result.get("success"):
|
||||
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
|
||||
image_ref = result.get("image")
|
||||
if not image_ref:
|
||||
return None, "provider returned no image"
|
||||
try:
|
||||
return _save_local(str(image_ref), prefix=prefix), ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, f"could not save generated image: {exc}"
|
||||
|
||||
out: list[Path] = []
|
||||
last_error = ""
|
||||
allow_transparent = True
|
||||
for _ in range(max(1, n)):
|
||||
path, err = _run({"background": "transparent"} if allow_transparent else {})
|
||||
# Model doesn't support the transparent flag → drop it for this and every
|
||||
# remaining variant (no point re-probing a capability we just disproved).
|
||||
if path is None and allow_transparent and _rejected_background(err):
|
||||
allow_transparent = False
|
||||
path, err = _run({})
|
||||
if path is not None:
|
||||
out.append(path)
|
||||
else:
|
||||
last_error = err
|
||||
|
||||
if not out:
|
||||
raise GenerationError(last_error or "image generation produced no output")
|
||||
return out
|
||||
358
agent/pet/generate/orchestrate.py
Normal file
358
agent/pet/generate/orchestrate.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
"""Pet generation orchestration — the base-draft → hatch flow.
|
||||
|
||||
Two steps, mirroring the UX across every surface:
|
||||
|
||||
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
|
||||
look like" variants. Cheap; the user picks one (or retries for a fresh set).
|
||||
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
|
||||
strip per Hermes state, slices each into frames, composes the atlas, validates
|
||||
it, and writes the pet into the store.
|
||||
|
||||
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
|
||||
calls happen once, on the pet you actually keep) and gives each UI a natural
|
||||
preview/loading point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agent.pet.generate import atlas, imagegen, prompts
|
||||
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
|
||||
ProgressFn = Callable[[str, str], None]
|
||||
|
||||
# Image generations are independent network calls, so we fan them out instead of
|
||||
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
|
||||
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
|
||||
# don't hammer the provider's rate limit (one cold call can still be slow).
|
||||
_MAX_PARALLEL_GENERATIONS = 4
|
||||
# How many times to (re)generate a single row before accepting a best-effort
|
||||
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
|
||||
# stubborn row still yields frames instead of dropping out entirely.
|
||||
_ROW_GEN_ATTEMPTS = 2
|
||||
_MIN_FILLED_STATES = 6
|
||||
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HatchResult:
|
||||
"""Outcome of a successful :func:`hatch_pet`."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
spritesheet: Path
|
||||
states: list[str]
|
||||
validation: dict
|
||||
|
||||
|
||||
def _harden_transparency(path: Path) -> Path:
|
||||
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
|
||||
|
||||
``background=transparent`` is requested on every call, but image models honor
|
||||
it inconsistently — some still paint a flat (often near-white) backdrop. We
|
||||
run the same chroma-key pass the row extractor uses so every base draft the
|
||||
user picks between (and the reference the rows are grounded on) is a clean
|
||||
cutout. Best-effort: a decode failure leaves the original untouched.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
keyed = atlas.remove_background(opened.convert("RGBA"))
|
||||
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
|
||||
# draft has no colored halo when composited on the dark UI.
|
||||
keyed = atlas._clear_transparent_rgb(keyed)
|
||||
out = path.with_suffix(".png")
|
||||
keyed.save(out, format="PNG")
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
|
||||
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
|
||||
return path
|
||||
|
||||
|
||||
def generate_base_drafts(
|
||||
concept: str,
|
||||
*,
|
||||
n: int = 4,
|
||||
style: str = "auto",
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
on_draft: Callable[[int, Path], None] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Generate *n* candidate base looks for *concept*; returns image paths.
|
||||
|
||||
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
|
||||
Drafts are generated concurrently and *on_draft(index, path)* fires as each
|
||||
one finishes (not at the end) so callers can stream previews to the UI
|
||||
instead of leaving it blank until the whole batch is done.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
|
||||
started yet is skipped, and once it trips we stop staging/streaming further
|
||||
drafts and cancel any queued work (already-in-flight provider calls can't be
|
||||
hard-killed, but their results are dropped).
|
||||
"""
|
||||
# A user reference image (e.g. their own pet) grounds every draft, so it
|
||||
# needs a reference-capable provider — same requirement as the row passes.
|
||||
refs = reference_images or None
|
||||
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
|
||||
# Each draft is its own one-shot generation, run concurrently so the user
|
||||
# waits for one image, not N. A single draft failing must not sink the set.
|
||||
# Each gets a distinct variation nudge so the options aren't near-duplicates.
|
||||
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
|
||||
|
||||
def _one(index: int) -> tuple[int, Path | None, str | None]:
|
||||
if cancelled():
|
||||
return index, None, None
|
||||
t0 = time.monotonic()
|
||||
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
|
||||
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
|
||||
try:
|
||||
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
|
||||
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
|
||||
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
|
||||
return index, None, str(exc)
|
||||
if not out:
|
||||
logger.warning("pet generate: draft %d produced no image", index)
|
||||
return index, None, "the image provider returned no image"
|
||||
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
|
||||
return index, _harden_transparency(out[0]), None
|
||||
|
||||
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
|
||||
results: dict[int, Path] = {}
|
||||
errors: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_one, i) for i in range(n)]
|
||||
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
|
||||
# gateway event it emits — inherits the request's bound transport, unlike
|
||||
# the worker threads above.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet generate: cancelled — dropping remaining drafts")
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
index, path, err = fut.result()
|
||||
if path is None:
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
results[index] = path
|
||||
if on_draft is not None:
|
||||
try:
|
||||
on_draft(index, path)
|
||||
except Exception as exc: # noqa: BLE001 - progress is best-effort
|
||||
logger.debug("on_draft callback failed: %s", exc)
|
||||
|
||||
drafts = [results[i] for i in sorted(results)]
|
||||
if not drafts and not cancelled():
|
||||
# Surface *why* — every draft failed for a reason (a content-policy refusal
|
||||
# on a name like "minion", a provider/auth error, …); the most common one
|
||||
# is the representative cause. Far more useful than "no usable drafts".
|
||||
raise GenerationError(_drafts_failed_reason(errors))
|
||||
return drafts
|
||||
|
||||
|
||||
def _drafts_failed_reason(errors: list[str]) -> str:
|
||||
"""The representative reason a draft round produced nothing, humanized."""
|
||||
if not errors:
|
||||
return "image generation produced no usable drafts"
|
||||
from collections import Counter
|
||||
|
||||
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
|
||||
|
||||
|
||||
def _humanize_image_error(error: str) -> str:
|
||||
"""Turn a raw provider error into a friendly, actionable sentence.
|
||||
|
||||
The big one is moderation: image models refuse trademarked characters and
|
||||
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
|
||||
"""
|
||||
low = error.lower()
|
||||
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
|
||||
return (
|
||||
"The image provider blocked this prompt — its safety filter rejects "
|
||||
"trademarked characters and real people. Try an original description."
|
||||
)
|
||||
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
|
||||
return "The image provider rejected the request — check your API key in Settings → Providers."
|
||||
if "rate limit" in low or "429" in low:
|
||||
return "The image provider is rate-limiting — wait a moment and try again."
|
||||
# Otherwise the first line, trimmed of the noisy provider envelope.
|
||||
return error.splitlines()[0].strip()[:200]
|
||||
|
||||
|
||||
def hatch_pet(
|
||||
*,
|
||||
base_image: str | Path,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
concept: str = "",
|
||||
style: str = "auto",
|
||||
on_progress: ProgressFn | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> HatchResult:
|
||||
"""Turn an approved base image into a full, installed Hermes pet.
|
||||
|
||||
Generates a grounded row strip per state, extracts frames, composes +
|
||||
validates the atlas, and registers it. The idle row falls back to the base
|
||||
look so the pet always renders. Raises :class:`GenerationError` on failure.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
|
||||
started are skipped, queued rows are cancelled, and once every row is done we
|
||||
abort (raising :class:`GenerationError`) before composing/saving so a stopped
|
||||
hatch never writes a half-built pet.
|
||||
"""
|
||||
base = Path(base_image)
|
||||
if not base.is_file():
|
||||
raise GenerationError(f"base image not found: {base}")
|
||||
|
||||
sprite = provider or imagegen.resolve_provider(require_references=True)
|
||||
progress = on_progress or (lambda *_: None)
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
label = concept or display_name or slug
|
||||
|
||||
frames_by_state: dict[str, list] = {}
|
||||
total_rows = len(atlas.ROW_SPECS)
|
||||
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
|
||||
|
||||
# Generate every state's row strip concurrently — they're independent
|
||||
# grounded calls, so the hatch waits for the slowest row, not their sum. A
|
||||
# single row failing is tolerated (idle is guaranteed below).
|
||||
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
|
||||
state, _row, count = spec
|
||||
if cancelled():
|
||||
return state, None
|
||||
t0 = time.monotonic()
|
||||
last_exc: Exception | None = None
|
||||
# Self-healing: a model occasionally returns a row whose poses are touching
|
||||
# (no clean gutters), which slices badly. We retry such rolls; only the
|
||||
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
|
||||
# still yields *something* rather than dropping the whole row.
|
||||
for attempt in range(_ROW_GEN_ATTEMPTS):
|
||||
if cancelled():
|
||||
return state, None
|
||||
strict = attempt < _ROW_GEN_ATTEMPTS - 1
|
||||
try:
|
||||
strips = imagegen.generate(
|
||||
prompts.build_row_prompt(state, count, label, style=style),
|
||||
n=1,
|
||||
reference_images=[base],
|
||||
provider=sprite,
|
||||
prefix=f"pet_row_{state}",
|
||||
# Wider canvas → each frame gets real horizontal room, so winged
|
||||
# poses keep a full, healthy size and still leave clean gutters.
|
||||
aspect_ratio="landscape",
|
||||
)
|
||||
# ``components`` requires clean per-pose gutters (raises otherwise),
|
||||
# so a touching roll is rejected and regenerated; the last attempt
|
||||
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
|
||||
# so normalize_cells registers the whole pet at once.
|
||||
method = "components" if strict else "auto"
|
||||
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
|
||||
logger.info(
|
||||
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
|
||||
slug, state, time.monotonic() - t0, attempt + 1,
|
||||
)
|
||||
return state, frames
|
||||
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r attempt %d/%d failed: %s",
|
||||
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
|
||||
)
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r gave up after %.1fs: %s",
|
||||
slug, state, time.monotonic() - t0, last_exc,
|
||||
)
|
||||
return state, None
|
||||
|
||||
# running-left is derived by mirroring running-right (guaranteed-consistent
|
||||
# and one fewer generation), so we don't generate it directly.
|
||||
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
|
||||
|
||||
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
|
||||
done = 0
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
|
||||
# as_completed runs on the caller (request) thread, so progress events
|
||||
# emitted here inherit the request transport — unlike the worker threads.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
state, frames = fut.result()
|
||||
done += 1
|
||||
progress("row", f"{state}:{done}:{total_rows}")
|
||||
if frames:
|
||||
frames_by_state[state] = frames
|
||||
|
||||
if cancelled():
|
||||
raise GenerationError("hatch cancelled")
|
||||
|
||||
# Derive running-left from the approved running-right row (per-frame mirror,
|
||||
# preserving order/timing). Missing running-right is rejected below; a pet
|
||||
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
|
||||
right = frames_by_state.get("running-right")
|
||||
if right:
|
||||
done += 1
|
||||
progress("row", f"running-left:{done}:{total_rows}")
|
||||
frames_by_state["running-left"] = atlas.mirror_frames(right)
|
||||
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
|
||||
else:
|
||||
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
|
||||
|
||||
# Idle is the resting state the renderer falls back to — guarantee it.
|
||||
if not frames_by_state.get("idle"):
|
||||
progress("row", "idle-fallback")
|
||||
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
|
||||
|
||||
progress("compose", "")
|
||||
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
|
||||
# One shared scale + baseline across every state so the pet never slides or
|
||||
# pulses size between frames; compose just packs the normalized cells.
|
||||
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
|
||||
validation = atlas.validate_atlas(sheet)
|
||||
if not validation["ok"]:
|
||||
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
|
||||
filled_states = set(validation["filled_states"])
|
||||
missing_required = sorted(_REQUIRED_STATES - filled_states)
|
||||
if missing_required:
|
||||
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
|
||||
if len(filled_states) < _MIN_FILLED_STATES:
|
||||
raise GenerationError(
|
||||
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
|
||||
)
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
progress("save", slug)
|
||||
logger.info("pet hatch %r: saving pet", slug)
|
||||
pet = store.register_local_pet(
|
||||
sheet,
|
||||
slug=slug,
|
||||
display_name=display_name or slug,
|
||||
description=description,
|
||||
)
|
||||
return HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=validation["filled_states"],
|
||||
validation=validation,
|
||||
)
|
||||
176
agent/pet/generate/prompts.py
Normal file
176
agent/pet/generate/prompts.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Prompt builders for pet generation.
|
||||
|
||||
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
|
||||
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
|
||||
produce one horizontal strip of N poses). Prompts stay concise and
|
||||
sprite-production oriented; the identity lock and "one transparent row" framing
|
||||
matter more than flowery description.
|
||||
|
||||
We generate the full petdex/Codex nine-state set (see
|
||||
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
|
||||
``petdex submit`` spritesheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# What each petdex/Codex state should depict (kept short — these go straight into
|
||||
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
|
||||
# effects, motion lines, shadows). Critical distinction: ``running`` is the
|
||||
# *working* state (in place), while ``running-right`` / ``running-left`` are the
|
||||
# actual directional walk/run cycles.
|
||||
STATE_ACTIONS: dict[str, str] = {
|
||||
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
|
||||
"running-right": (
|
||||
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
|
||||
"faces and travels right with clear directional steps, a smooth gait loop"
|
||||
),
|
||||
"running-left": (
|
||||
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
|
||||
"faces and travels left with clear directional steps (the mirror of the "
|
||||
"right-facing run)"
|
||||
),
|
||||
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
|
||||
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
|
||||
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
|
||||
"waiting": (
|
||||
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
|
||||
"or approval — distinct from idle and review"
|
||||
),
|
||||
"running": (
|
||||
"focused active work, staying IN PLACE (NOT walking or foot-running): "
|
||||
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
|
||||
),
|
||||
"review": "careful inspection: a focused lean, head tilt, studying something intently",
|
||||
}
|
||||
|
||||
_STYLE_HINTS: dict[str, str] = {
|
||||
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
|
||||
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
|
||||
"auto": (
|
||||
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
|
||||
"limited palette, clean dark outline, flat cel shading, chunky chibi "
|
||||
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
|
||||
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
|
||||
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
|
||||
),
|
||||
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
|
||||
"plush": " Render as a soft plush toy.",
|
||||
"clay": " Render as a claymation / soft 3D clay figure.",
|
||||
"sticker": " Render as a glossy die-cut sticker.",
|
||||
"flat-vector": " Render in flat vector mascot style.",
|
||||
"3d-toy": " Render as a glossy 3D toy.",
|
||||
"painterly": " Render in a soft painterly style.",
|
||||
}
|
||||
|
||||
_BACKGROUND = (
|
||||
"Center one full-body character on a flat, uniform, high-contrast chroma-key "
|
||||
"background (prefer pure hot magenta #FF00FF unless that color appears on "
|
||||
"the character). The background must completely surround the character: one "
|
||||
"even color with NO gradient, vignette, texture, pattern, scenery, shadow, "
|
||||
"ground line, frame, or border, so it keys out cleanly. The background color "
|
||||
"must not appear anywhere on the character itself. No text, no labels."
|
||||
)
|
||||
|
||||
|
||||
def style_hint(style: str | None) -> str:
|
||||
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
|
||||
|
||||
|
||||
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
|
||||
# orchestrate). The extra width is what lets each pose stay a healthy size AND
|
||||
# leave a real gutter — used here only to cite concrete pixel numbers.
|
||||
_ASSUMED_STRIP_WIDTH = 1536
|
||||
|
||||
|
||||
def _spacing_spec(frame_count: int) -> tuple[int, int]:
|
||||
"""(per-pose width px, gap px) for a row of *frame_count* poses.
|
||||
|
||||
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
|
||||
the full wingspan, so neighbors touch even when bodies are spaced. The lever
|
||||
that works is proportional containment on a wide canvas: give each pose its
|
||||
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
|
||||
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
|
||||
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
|
||||
"""
|
||||
slots = max(1, frame_count)
|
||||
slot_w = _ASSUMED_STRIP_WIDTH / slots
|
||||
pose_px = round(slot_w * 0.7)
|
||||
gap_px = max(48, round(slot_w * 0.3))
|
||||
return pose_px, gap_px
|
||||
|
||||
|
||||
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
|
||||
# near-duplicates for a single prompt. We vary the *look* (palette, build,
|
||||
# expression, accents), NOT the pose, so the chosen base still grounds clean,
|
||||
# consistent animation rows.
|
||||
BASE_VARIATIONS: tuple[str, ...] = (
|
||||
"",
|
||||
"a distinctly different colour palette and markings",
|
||||
"a heavier, broader silhouette with sturdier proportions",
|
||||
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
|
||||
"a leaner, taller build and an alternate colour scheme",
|
||||
"bolder, more saturated colours and a stronger expression matching the concept tone",
|
||||
)
|
||||
|
||||
|
||||
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
|
||||
"""The base look: a single, clean, centered full-body mascot.
|
||||
|
||||
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
|
||||
"""
|
||||
concept = (concept or "a distinctive mascot creature").strip()
|
||||
nudge = f" Make this design distinct: {variation}." if variation else ""
|
||||
return (
|
||||
f"A stylized mascot pet character: {concept}. "
|
||||
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
|
||||
"while staying non-graphic. "
|
||||
"Compact, whole-body silhouette that reads clearly at small size, "
|
||||
"clear readable facial features, simple consistent palette. "
|
||||
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
|
||||
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
|
||||
"relaxed at the sides, feet together on the ground, any cape/accessories "
|
||||
"hanging straight and still."
|
||||
f"{nudge} "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
|
||||
|
||||
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
|
||||
"""A row strip: *frame_count* poses of the SAME character, left→right.
|
||||
|
||||
The attached base image is the identity source of truth; the prompt locks
|
||||
species, palette, face, and props to it.
|
||||
"""
|
||||
action = STATE_ACTIONS.get(state, "a simple idle pose")
|
||||
concept = (concept or "the mascot").strip()
|
||||
pose_px, gap_px = _spacing_spec(frame_count)
|
||||
return (
|
||||
f"Using the attached reference image as the exact same character "
|
||||
f"(same species, face, colors, markings, proportions, and props), "
|
||||
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
|
||||
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
|
||||
f"LAYOUT: split the wide strip into {frame_count} equal vertical cells, one "
|
||||
"pose centered in each cell. "
|
||||
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
|
||||
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
|
||||
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
|
||||
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
|
||||
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
|
||||
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
|
||||
f"the same empty margin before the first pose and after the last. If a wing, "
|
||||
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
|
||||
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
|
||||
f"share a shadow, share a ground line, share motion trails, or merge into "
|
||||
f"one connected shape. "
|
||||
# Registration: a clean sprite sheet keeps the character locked in place
|
||||
# so only the action moves — this is what stops the loop sliding/pulsing.
|
||||
"REGISTRATION (critical): the character is the SAME height and SAME width "
|
||||
"in every frame, drawn at the SAME scale, centered over the SAME point, "
|
||||
"with all feet resting on ONE shared horizontal ground line across the "
|
||||
"whole strip. Keep the body's center, size, and stance fixed frame to "
|
||||
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
|
||||
"bags, and scarves stay in the SAME place and shape every frame (no "
|
||||
"swinging, flowing, or drifting) unless the action itself requires it. No "
|
||||
"pose is cropped at the strip edges. "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -41,11 +42,16 @@ class InstalledPet:
|
|||
description: str
|
||||
directory: Path
|
||||
spritesheet: Path
|
||||
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.spritesheet.is_file()
|
||||
|
||||
@property
|
||||
def generated(self) -> bool:
|
||||
return self.created_by == "generator"
|
||||
|
||||
|
||||
def pets_dir() -> Path:
|
||||
"""Return the profile-scoped pets directory (created on demand)."""
|
||||
|
|
@ -113,6 +119,7 @@ def load_pet(slug: str) -> InstalledPet | None:
|
|||
description=str(meta.get("description", "") or ""),
|
||||
directory=directory,
|
||||
spritesheet=_resolve_spritesheet(directory, meta),
|
||||
created_by=str(meta.get("createdBy", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -197,6 +204,101 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
|
|||
return pet
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return slug or "pet"
|
||||
|
||||
|
||||
def unique_slug(name: str) -> str:
|
||||
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
|
||||
base = slugify(name)
|
||||
slug = base
|
||||
counter = 2
|
||||
while (pets_dir() / slug).exists():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def _write_spritesheet(source, dest: Path) -> None:
|
||||
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
dest.write_bytes(bytes(source))
|
||||
return
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(source, (str, Path)):
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
else:
|
||||
image = source.convert("RGBA")
|
||||
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
|
||||
|
||||
def register_local_pet(
|
||||
spritesheet,
|
||||
*,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
) -> InstalledPet:
|
||||
"""Write a locally-generated pet into the store and return it.
|
||||
|
||||
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
|
||||
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
|
||||
returns an already-on-disk pet before consulting the manifest, it can be
|
||||
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
|
||||
"""
|
||||
slug = slugify(slug)
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
sprite_path = directory / "spritesheet.webp"
|
||||
try:
|
||||
_write_spritesheet(spritesheet, sprite_path)
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
|
||||
|
||||
meta = {
|
||||
"id": slug,
|
||||
"displayName": display_name or slug,
|
||||
"description": description or "",
|
||||
"spritesheetPath": sprite_path.name,
|
||||
"createdBy": "generator",
|
||||
}
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def export_pet(slug: str) -> tuple[str, bytes]:
|
||||
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
|
||||
|
||||
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
|
||||
re-importable pet package. Raises :class:`PetStoreError` if not installed.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
root = pets_dir()
|
||||
directory = root / slug.strip()
|
||||
# Guard against traversal: the target must be a direct child of pets_dir.
|
||||
if directory.resolve().parent != root.resolve() or not directory.is_dir():
|
||||
raise PetStoreError(f"pet '{slug}' is not installed")
|
||||
|
||||
name = directory.name
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and not path.name.startswith("."):
|
||||
archive.write(path, f"{name}/{path.name}")
|
||||
return f"{name}.zip", buf.getvalue()
|
||||
|
||||
|
||||
_THUMB_FRAME_W = 192
|
||||
_THUMB_FRAME_H = 208
|
||||
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
|
||||
|
|
@ -301,6 +403,15 @@ def remove_pet(slug: str) -> bool:
|
|||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return False
|
||||
|
||||
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
|
||||
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
|
||||
# slug renders this one's stale thumbnail.
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
|
|
@ -308,6 +419,55 @@ def remove_pet(slug: str) -> bool:
|
|||
return not directory.exists()
|
||||
|
||||
|
||||
def rename_pet(slug: str, display_name: str) -> str | None:
|
||||
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
|
||||
|
||||
Generated pets are hatched under a provisional, prompt-derived slug; when
|
||||
the user names the pet on the reveal screen we make that name the real
|
||||
identity so lists/subtitles show what they typed, not the prompt. The dir is
|
||||
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
|
||||
whenever that yields a free, different slug — otherwise the slug is left as
|
||||
is. Returns the resulting slug on success, or ``None`` on failure.
|
||||
"""
|
||||
slug = _safe_slug(slug)
|
||||
display_name = (display_name or "").strip()
|
||||
if not slug or not display_name:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta["displayName"] = display_name
|
||||
|
||||
new_slug = slug
|
||||
desired = slugify(display_name)
|
||||
if desired and desired != slug and not (pets_dir() / desired).exists():
|
||||
try:
|
||||
directory.rename(pets_dir() / desired)
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
|
||||
except OSError:
|
||||
pass
|
||||
directory = pets_dir() / desired
|
||||
pet_json = directory / "pet.json"
|
||||
new_slug = desired
|
||||
meta["id"] = new_slug
|
||||
except OSError:
|
||||
new_slug = slug # keep the provisional slug if the move fails
|
||||
|
||||
try:
|
||||
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
return new_slug
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: float) -> None:
|
||||
import httpx
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
Clock,
|
||||
Cpu,
|
||||
Download,
|
||||
Egg,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
|
|
@ -43,6 +44,7 @@ import {
|
|||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
|
|
@ -409,6 +411,13 @@ export function CommandPalette() {
|
|||
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
|
||||
label: cc.pets.title,
|
||||
to: 'pets'
|
||||
},
|
||||
{
|
||||
icon: Egg,
|
||||
id: 'appearance-generate-pet',
|
||||
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
|
||||
label: cc.generatePet.title,
|
||||
run: () => openPetGenerate()
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -653,6 +662,8 @@ export function CommandPalette() {
|
|||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
|
|
@ -663,7 +674,7 @@ export function CommandPalette() {
|
|||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage search={search} />
|
||||
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
|||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Loader2, PawPrint } from '@/lib/icons'
|
||||
import { Check, Egg, Loader2, PawPrint } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petBusy,
|
||||
|
|
@ -31,9 +31,11 @@ import {
|
|||
|
||||
interface PetPalettePageProps {
|
||||
search: string
|
||||
/** Navigate to the "generate a pet" page (rendered as a header action). */
|
||||
onGenerate?: () => void
|
||||
}
|
||||
|
||||
export function PetPalettePage({ search }: PetPalettePageProps) {
|
||||
export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
|
@ -72,6 +74,24 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
|
|||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{onGenerate && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT
|
||||
)}
|
||||
onClick={onGenerate}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
|
||||
<Egg className="size-4" />
|
||||
</span>
|
||||
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
{shown.length === 0 ? (
|
||||
|
|
@ -104,7 +124,14 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
|
|||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{pet.displayName}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{pet.displayName}</span>
|
||||
{pet.generated && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
|
||||
{copy.generatedTag}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||
{pet.slug}
|
||||
{pet.installed ? ` · ${copy.installed}` : ''}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ import { useKeybinds } from './hooks/use-keybinds'
|
|||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
|
|
@ -1028,6 +1029,7 @@ export function DesktopController() {
|
|||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
|
||||
{settingsOpen && (
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function useGatewayRequest() {
|
|||
}, [])
|
||||
|
||||
const requestGateway = useCallback(
|
||||
async <T>(method: string, params: Record<string, unknown> = {}) => {
|
||||
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
|
||||
const gateway = gatewayRef.current
|
||||
|
||||
if (!gateway) {
|
||||
|
|
@ -102,7 +102,7 @@ export function useGatewayRequest() {
|
|||
}
|
||||
|
||||
try {
|
||||
return await gateway.request<T>(method, params)
|
||||
return await gateway.request<T>(method, params, timeoutMs, signal)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ export function useGatewayRequest() {
|
|||
throw error
|
||||
}
|
||||
|
||||
return recovered.request<T>(method, params)
|
||||
return recovered.request<T>(method, params, timeoutMs, signal)
|
||||
}
|
||||
},
|
||||
[ensureGatewayOpen]
|
||||
|
|
|
|||
19
apps/desktop/src/app/hooks/use-route-overlay-active.ts
Normal file
19
apps/desktop/src/app/hooks/use-route-overlay-active.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { appViewForPath, isOverlayView } from '@/app/routes'
|
||||
|
||||
/**
|
||||
* True while a full-screen route overlay (settings, agents, command-center, …)
|
||||
* is showing.
|
||||
*
|
||||
* A portaled Radix modal sits above the app shell, so it would cover such a
|
||||
* route. Any modal that sends the user to one (e.g. "set up image generation" →
|
||||
* `/settings`) can `if (useRouteOverlayActive()) return null` to *yield* the
|
||||
* screen — its open state lives in a store, so it stays open — and reappear,
|
||||
* re-running its mount effects (a free refresh), when the route overlay closes.
|
||||
*/
|
||||
export function useRouteOverlayActive(): boolean {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
return isOverlayView(appViewForPath(pathname))
|
||||
}
|
||||
89
apps/desktop/src/app/pet-generate/components/draft-grid.tsx
Normal file
89
apps/desktop/src/app/pet-generate/components/draft-grid.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PawPrint } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const VARIANT_COUNT = 4
|
||||
|
||||
interface DraftGridProps {
|
||||
drafts: { index: number; dataUri: string }[]
|
||||
generating: boolean
|
||||
hasDrafts: boolean
|
||||
onCancel: () => void
|
||||
onHatch: () => void
|
||||
onSelect: (index: number) => void
|
||||
selected: number | null
|
||||
}
|
||||
|
||||
export function DraftGrid({ drafts, generating, hasDrafts, onCancel, onHatch, onSelect, selected }: DraftGridProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const slots = generating
|
||||
? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null)
|
||||
: drafts
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span className={cn(generating && 'shimmer shimmer-color-primary opacity-40', !generating && 'invisible')}>
|
||||
{copy.generating}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{Math.min(drafts.length, VARIANT_COUNT)}/{VARIANT_COUNT}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{slots.map((draft, i) => {
|
||||
// A streamed draft is selectable immediately — even mid-generation —
|
||||
// so the user can commit to one without waiting for the rest.
|
||||
const isSelected = draft != null && selected === draft.index
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex aspect-[192/208] items-center justify-center overflow-hidden',
|
||||
selectableCardClass({ active: isSelected, prominent: true })
|
||||
)}
|
||||
disabled={draft == null}
|
||||
key={draft ? `draft-${draft.index}` : `slot-${i}`}
|
||||
onClick={() => draft != null && onSelect(draft.index)}
|
||||
type="button"
|
||||
>
|
||||
{draft != null ? (
|
||||
// Hatches into place as each draft streams back.
|
||||
<img
|
||||
alt=""
|
||||
className="pet-reveal size-full object-contain p-1.5"
|
||||
draggable={false}
|
||||
src={draft.dataUri}
|
||||
/>
|
||||
) : (
|
||||
// Incubating: a creme egg bouncing on its contact shadow.
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<PixelEggSprite index={i} mode="bounce" size={48} />
|
||||
<span className="pet-egg-shadow pet-egg-shadow--sm" style={{ marginTop: '-0.3rem' }} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Same abort/go-back text link in both states (sits right under the grid);
|
||||
once drafts land, the full-width Hatch drops in below it. */}
|
||||
<Button className="self-center" onClick={onCancel} size="xs" variant="text">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
{hasDrafts && (
|
||||
<Button className="w-full" disabled={selected === null} onClick={onHatch}>
|
||||
<PawPrint />
|
||||
{copy.hatch}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
apps/desktop/src/app/pet-generate/components/empty-hint.tsx
Normal file
27
apps/desktop/src/app/pet-generate/components/empty-hint.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface EmptyHintProps {
|
||||
onExample: (prompt: string) => void
|
||||
}
|
||||
|
||||
// Creative seed prompts — specifics make better pets (petdex's own advice).
|
||||
// Short chips that wrap into a tight, centered cluster (capped width → 2 rows).
|
||||
const EXAMPLE_PROMPTS = ['bubble-tea otter', 'sock elf', 'pixel dragon', 'office cat', 'neon axolotl', 'moss golem']
|
||||
|
||||
export function EmptyHint({ onExample }: EmptyHintProps) {
|
||||
return (
|
||||
<div className="flex max-w-[300px] flex-wrap place-content-center place-items-center gap-2">
|
||||
{EXAMPLE_PROMPTS.map(example => (
|
||||
<Button
|
||||
className="h-auto w-fit rounded-full font-normal"
|
||||
key={example}
|
||||
onClick={() => onExample(`a ${example}`)}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
{example}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { PawPrint, Settings2 } from '@/lib/icons'
|
||||
|
||||
interface GenerateUnavailableProps {
|
||||
onSetup: () => void
|
||||
}
|
||||
|
||||
// Shown when no reference-capable image backend is configured: generation is
|
||||
// impossible, so we replace the prompt entirely with a friendly path to set one
|
||||
// up (in-app) plus where to grab a key.
|
||||
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
|
||||
<PawPrint className="size-5" />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">Add an image backend to generate</p>
|
||||
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
|
||||
Hatching a custom pet needs a provider that can ground on a reference image.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onSetup} size="sm">
|
||||
<Settings2 className="size-4" />
|
||||
Set up image generation
|
||||
</Button>
|
||||
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<span>Grab a key from</span>
|
||||
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
|
||||
Nous Portal
|
||||
</ExternalLink>
|
||||
<span>·</span>
|
||||
<ExternalLink
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
href="https://openrouter.ai/keys"
|
||||
showExternalIcon={false}
|
||||
>
|
||||
OpenRouter
|
||||
</ExternalLink>
|
||||
<span>·</span>
|
||||
<ExternalLink
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
href="https://platform.openai.com/api-keys"
|
||||
showExternalIcon={false}
|
||||
>
|
||||
OpenAI
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
Normal file
137
apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { PetStarShower } from '@/components/pet/pet-star-shower'
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, RefreshCw } from '@/lib/icons'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
|
||||
import { frameCountForRow } from '../lib/frame-count'
|
||||
|
||||
const PREVIEW_SCALE = 0.7
|
||||
const PREVIEW_STATE_MS = 1400
|
||||
|
||||
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
|
||||
|
||||
interface HatchPreviewProps {
|
||||
pet: PetInfo
|
||||
adopting: boolean
|
||||
error: string | null
|
||||
onAdopt: (name: string) => void
|
||||
onDiscard: () => void
|
||||
}
|
||||
|
||||
export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
// Empty so the "Name your pet" placeholder shows; blank adopt keeps the
|
||||
// provisional name from the prompt.
|
||||
const [name, setName] = useState('')
|
||||
// Play the egg's crack/hatch frames once before swapping in the live pet.
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
// Right after the egg cracks the pet plays its "yay" jump a couple times, then
|
||||
// hands off to the normal state-cycling preview.
|
||||
const [celebrating, setCelebrating] = useState(false)
|
||||
const [stateIndex, setStateIndex] = useState(0)
|
||||
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
|
||||
const rows = previewRows.length > 0 ? previewRows : ['idle']
|
||||
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
|
||||
const canJump = frameCountForRow(pet, 'jumping') > 0
|
||||
const rowOverride = celebrating && canJump ? 'jumping' : activeRow
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [rows.length])
|
||||
|
||||
// On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview.
|
||||
useEffect(() => {
|
||||
if (!revealed) {
|
||||
return
|
||||
}
|
||||
|
||||
setCelebrating(true)
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setCelebrating(false)
|
||||
setStateIndex(0)
|
||||
}, 2 * (pet.loopMs ?? 1100))
|
||||
|
||||
return () => clearTimeout(id)
|
||||
}, [revealed, pet.loopMs])
|
||||
|
||||
useEffect(() => {
|
||||
setStateIndex(0)
|
||||
setName('')
|
||||
setRevealed(false)
|
||||
setCelebrating(false)
|
||||
}, [pet.slug])
|
||||
|
||||
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Fills the (now narrow) dialog so the pet frame is the screen width. */}
|
||||
<div className="relative flex aspect-[192/208] w-full items-center justify-center overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)">
|
||||
{revealed ? (
|
||||
<>
|
||||
<div className="relative inline-block">
|
||||
<span aria-hidden className="pet-contact-shadow" />
|
||||
<div className="pet-reveal relative z-10">
|
||||
<PetSprite info={previewInfo} rowOverride={rowOverride} />
|
||||
</div>
|
||||
</div>
|
||||
<PetStarShower />
|
||||
</>
|
||||
) : (
|
||||
// The egg cracks open, then we swap in the live pet.
|
||||
<PixelEggSprite
|
||||
mode="hatch"
|
||||
onDone={() => {
|
||||
setRevealed(true)
|
||||
triggerHaptic('crisp')
|
||||
}}
|
||||
size={150}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
className="w-full"
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onAdopt(name)
|
||||
}
|
||||
}}
|
||||
placeholder={copy.namePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<Button disabled={adopting} onClick={onDiscard} variant="ghost">
|
||||
<RefreshCw />
|
||||
{copy.startOver}
|
||||
</Button>
|
||||
<Button className="flex-1" disabled={adopting} onClick={() => onAdopt(name)}>
|
||||
{adopting ? <Loader2 className="animate-spin" /> : <PawPrint />}
|
||||
{copy.adopt}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cancelHatch, type PetHatchStage } from '@/store/pet-generate'
|
||||
|
||||
interface HatchingViewProps {
|
||||
stage: PetHatchStage | null
|
||||
}
|
||||
|
||||
// The hatch progress screen — a beating egg with a phase-tracking subtitle
|
||||
// (per-row → composing → saving).
|
||||
export function HatchingView({ stage }: HatchingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const subtitle = stage
|
||||
? stage.phase === 'row'
|
||||
? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0)
|
||||
: stage.phase === 'compose'
|
||||
? copy.hatchComposing
|
||||
: copy.hatchSaving
|
||||
: copy.hatchingSub
|
||||
|
||||
return <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Check, ChevronDown } from '@/lib/icons'
|
||||
import { $petGenProvider, $petGenProviders, setPetGenProvider } from '@/store/pet-generate'
|
||||
|
||||
// Image-backend picker for pet generation — the composer's model-pill pattern:
|
||||
// a quiet trigger + a dropdown of options, each with a one-line speed/quality
|
||||
// note. Hidden unless there are 2+ reference-capable backends (nothing to pick).
|
||||
export function ProviderPicker() {
|
||||
const providers = useStore($petGenProviders)
|
||||
const picked = useStore($petGenProvider)
|
||||
|
||||
if (providers.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallback = providers.find(p => p.default) ?? providers[0]
|
||||
const current = providers.find(p => p.name === picked) ?? fallback
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* Plain text affordance (matches "Add a reference"), not a padded pill. */}
|
||||
<button
|
||||
className="flex h-6 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
{current?.label}
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* The picker lives inside the pet-gen Dialog (z-130) and portals to body,
|
||||
so lift its menu above the dialog or it opens behind it. */}
|
||||
<DropdownMenuContent align="start" className="z-[140] w-56">
|
||||
{providers.map(provider => (
|
||||
<DropdownMenuItem
|
||||
className="flex-col items-start gap-0.5"
|
||||
key={provider.name}
|
||||
// Picking the default clears the override (no need to pin it).
|
||||
onSelect={() => setPetGenProvider(provider.default ? '' : provider.name)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground">{provider.label}</span>
|
||||
{provider.name === current?.name && <Check className="size-3.5 text-primary" />}
|
||||
</span>
|
||||
{provider.note && <span className="text-[0.6875rem] text-(--ui-text-tertiary)">{provider.note}</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { ImageLightbox } from '@/components/chat/zoomable-image'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { X } from '@/lib/icons'
|
||||
|
||||
interface ReferenceChipProps {
|
||||
name: string
|
||||
onRemove: () => void
|
||||
src: string
|
||||
}
|
||||
|
||||
// The reference photo as an attachment chip: filename + thumbnail that opens
|
||||
// the shared image viewer (lightbox), with a remove affordance.
|
||||
export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
|
||||
const { t } = useI18n()
|
||||
const { download, saving } = useImageDownload(src)
|
||||
const [viewing, setViewing] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
|
||||
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
|
||||
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
|
||||
</button>
|
||||
|
||||
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
|
||||
<button
|
||||
aria-label="Remove reference"
|
||||
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
<ImageLightbox
|
||||
alt={name}
|
||||
copy={t.desktop}
|
||||
onClick={download}
|
||||
onOpenChange={setViewing}
|
||||
open={viewing}
|
||||
saving={saving}
|
||||
src={src}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
apps/desktop/src/app/pet-generate/lib/frame-count.ts
Normal file
26
apps/desktop/src/app/pet-generate/lib/frame-count.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { type PetInfo } from '@/store/pet'
|
||||
|
||||
// Sprite row → the PetInfo frame-count key it resolves to (directional walks and
|
||||
// aliases collapse onto their base state).
|
||||
const ROW_TO_FRAME_KEY: Record<string, string> = {
|
||||
idle: 'idle',
|
||||
wave: 'wave',
|
||||
waving: 'wave',
|
||||
jump: 'jump',
|
||||
jumping: 'jump',
|
||||
run: 'run',
|
||||
running: 'run',
|
||||
'running-right': 'run',
|
||||
'running-left': 'run',
|
||||
failed: 'failed',
|
||||
review: 'review',
|
||||
waiting: 'waiting'
|
||||
}
|
||||
|
||||
// Real frame count for a row, preferring the concrete per-row count, then the
|
||||
// per-state count, then the mapped base state, then the sheet-wide default.
|
||||
export function frameCountForRow(pet: PetInfo, row: string): number {
|
||||
const mapped = ROW_TO_FRAME_KEY[row]
|
||||
|
||||
return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
const img = new Image()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('unreadable image'))
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// Read an image file as a downscaled PNG data URL. We decode from an object URL
|
||||
// (not readAsDataURL) so large files don't inflate into giant base64 strings
|
||||
// before we scale them down for generation.
|
||||
export async function readReferenceImage(
|
||||
file: File,
|
||||
max = 1024,
|
||||
maxInputBytes = DEFAULT_MAX_INPUT_BYTES
|
||||
): Promise<string> {
|
||||
if (file.size > maxInputBytes) {
|
||||
throw new Error('reference image too large')
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
|
||||
try {
|
||||
const img = await loadImage(objectUrl)
|
||||
const scale = Math.min(1, max / Math.max(img.width, img.height))
|
||||
const width = Math.max(1, Math.round(img.width * scale))
|
||||
const height = Math.max(1, Math.round(img.height * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('could not create canvas context')
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
return canvas.toDataURL('image/png')
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
293
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
293
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Egg, ImageIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petGenAvailable,
|
||||
$petGenDrafts,
|
||||
$petGenError,
|
||||
$petGenInput,
|
||||
$petGenPreview,
|
||||
$petGenRefImage,
|
||||
$petGenRefName,
|
||||
$petGenSelected,
|
||||
$petGenStage,
|
||||
$petGenStatus,
|
||||
adoptHatched,
|
||||
cancelGenerate,
|
||||
checkPetGenAvailable,
|
||||
cleanPetName,
|
||||
closePetGenerate,
|
||||
discardDrafts,
|
||||
discardHatched,
|
||||
generateDrafts,
|
||||
hatchSelected
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
import { DraftGrid } from './components/draft-grid'
|
||||
import { EmptyHint } from './components/empty-hint'
|
||||
import { GenerateUnavailable } from './components/generate-unavailable'
|
||||
import { HatchPreview } from './components/hatch-preview'
|
||||
import { HatchingView } from './components/hatching-view'
|
||||
import { ProviderPicker } from './components/provider-picker'
|
||||
import { ReferenceChip } from './components/reference-chip'
|
||||
import { readReferenceImage } from './lib/read-reference-image'
|
||||
|
||||
// The generate → hatch → adopt controller. A thin view over the `pet-generate`
|
||||
// store; the store owns the steps and persists inputs across close/reopen.
|
||||
export function PetGenerateContent() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const available = useStore($petGenAvailable)
|
||||
// `null` = not yet probed → stay optimistic (show the prompt); only the
|
||||
// confirmed-no-backend case swaps in the setup card.
|
||||
const unavailable = available === false
|
||||
const drafts = useStore($petGenDrafts)
|
||||
const selected = useStore($petGenSelected)
|
||||
const preview = useStore($petGenPreview)
|
||||
const stage = useStore($petGenStage)
|
||||
|
||||
// Inputs live in atoms so they survive a close/reopen (and background runs).
|
||||
const prompt = useStore($petGenInput)
|
||||
const refImage = useStore($petGenRefImage)
|
||||
const refName = useStore($petGenRefName)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Probe backend availability on open — and again whenever the content
|
||||
// remounts (e.g. after returning from the providers settings), so adding a
|
||||
// key flips the setup card to the prompt with no manual refresh.
|
||||
useEffect(() => {
|
||||
void checkPetGenAvailable(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const busy = status === 'generating' || status === 'hatching'
|
||||
const hasDrafts = drafts.length > 0
|
||||
const generating = status === 'generating'
|
||||
|
||||
// The idle "describe a pet" state — egg + suggestions get generous, equidistant
|
||||
// breathing room (gap-4) from the prompt; the working states stay compact.
|
||||
const isEmptyState =
|
||||
!hasDrafts &&
|
||||
!generating &&
|
||||
status !== 'hatching' &&
|
||||
status !== 'preview' &&
|
||||
status !== 'adopting' &&
|
||||
status !== 'stale'
|
||||
|
||||
const generate = () => {
|
||||
if ((prompt.trim() || refImage) && !busy) {
|
||||
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: refImage ?? undefined })
|
||||
}
|
||||
}
|
||||
|
||||
const clearReference = () => {
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
}
|
||||
|
||||
const pickReference = (file: File | undefined) => {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const mapReferenceError = (reason: unknown): string => {
|
||||
const message = reason instanceof Error ? reason.message.toLowerCase() : ''
|
||||
|
||||
return message.includes('too large') ? copy.referenceImageTooLarge : copy.referenceImageInvalid
|
||||
}
|
||||
|
||||
void readReferenceImage(file)
|
||||
.then(dataUrl => {
|
||||
$petGenRefImage.set(dataUrl)
|
||||
$petGenRefName.set(file.name)
|
||||
// Clear picker-only errors once the reference is valid again.
|
||||
|
||||
if ($petGenStatus.get() === 'error' && $petGenDrafts.get().length === 0) {
|
||||
$petGenStatus.set('idle')
|
||||
$petGenError.set(null)
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
$petGenError.set(mapReferenceError(reason))
|
||||
|
||||
if (!busy) {
|
||||
$petGenStatus.set('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// One-click an example prompt straight into a draft round.
|
||||
const runExample = (example: string) => {
|
||||
$petGenInput.set(example)
|
||||
void generateDrafts(requestGateway, { prompt: example })
|
||||
}
|
||||
|
||||
// Hatch the selected draft. The user can pick one before the rest stream in —
|
||||
// if so, abort the remaining generations first (keeping the drafts we have).
|
||||
// The prompt is grounding text, not a label; the user names it on reveal.
|
||||
const hatch = () => {
|
||||
if (selected === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (generating) {
|
||||
cancelGenerate()
|
||||
}
|
||||
|
||||
void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() })
|
||||
}
|
||||
|
||||
const adopt = (finalName: string) => {
|
||||
void adoptHatched(requestGateway, finalName).then(out => {
|
||||
if (out.ok) {
|
||||
triggerHaptic('crisp')
|
||||
closePetGenerate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The header title tracks the phase instead of sticking on "Generate a pet".
|
||||
const headerTitle =
|
||||
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
|
||||
|
||||
// Send the user to set up a key without closing — the overlay yields to the
|
||||
// settings route (useRouteOverlayActive) and reappears + re-checks on return.
|
||||
const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
|
||||
// Prompt input only belongs on the describe/draft screens (and never when
|
||||
// there's no backend to generate with).
|
||||
const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting'
|
||||
|
||||
return (
|
||||
<>
|
||||
{unavailable ? (
|
||||
<DialogTitle className="sr-only">{copy.title}</DialogTitle>
|
||||
) : (
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', isEmptyState ? 'gap-4' : 'gap-2.5')}>
|
||||
{/* Concept prompt with the inline sparkle generate/stop affordance (the
|
||||
same primitive as the commit-message + project-idea fields). */}
|
||||
{showPrompt && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Input
|
||||
autoFocus
|
||||
className="pr-9"
|
||||
onChange={event => $petGenInput.set(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
generate()
|
||||
}
|
||||
}}
|
||||
placeholder={copy.placeholder}
|
||||
value={prompt}
|
||||
/>
|
||||
<GenerateButton
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
disabled={!prompt.trim() && !refImage}
|
||||
generating={generating}
|
||||
generatingLabel={t.common.cancel}
|
||||
label={copy.generate}
|
||||
// Inline cancel should match step-2 cancel semantics: abort and
|
||||
// return to step 1 (prompt retained for quick tweaks).
|
||||
onCancel={discardDrafts}
|
||||
onGenerate={generate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderPicker />
|
||||
{refImage ? (
|
||||
<ReferenceChip name={refName} onRemove={clearReference} src={refImage} />
|
||||
) : (
|
||||
<button
|
||||
className="ml-auto flex h-6 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="size-3" />
|
||||
Add a reference
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional reference photo — make a pet from the user's own image.
|
||||
Styled like the chat composer's attachment pill. */}
|
||||
<Input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={event => {
|
||||
pickReference(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
}}
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hatch failed but the drafts are still here — show why above the grid so
|
||||
the user can re-pick and retry without losing their options. */}
|
||||
{status === 'error' && hasDrafts && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error || copy.genericError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unavailable ? (
|
||||
<GenerateUnavailable onSetup={setupImageGen} />
|
||||
) : status === 'stale' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{copy.staleBackend}</AlertDescription>
|
||||
</Alert>
|
||||
) : status === 'hatching' ? (
|
||||
<HatchingView stage={stage} />
|
||||
) : (status === 'preview' || status === 'adopting') && preview ? (
|
||||
<HatchPreview
|
||||
adopting={status === 'adopting'}
|
||||
error={error}
|
||||
onAdopt={adopt}
|
||||
onDiscard={() => void discardHatched(requestGateway)}
|
||||
pet={preview}
|
||||
/>
|
||||
) : !hasDrafts && !generating ? (
|
||||
// Doubles as the error-empty state — the failure reason rides the
|
||||
// dialog's footer banner, so here we just offer the retry sparks.
|
||||
<EmptyHint onExample={runExample} />
|
||||
) : (
|
||||
<DraftGrid
|
||||
drafts={drafts}
|
||||
generating={generating}
|
||||
hasDrafts={hasDrafts}
|
||||
onCancel={discardDrafts}
|
||||
onHatch={hatch}
|
||||
onSelect={index => $petGenSelected.set(index)}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
85
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal file
85
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* "Hatch a Pet" — a dedicated, Pokédex-style overlay for pet generation.
|
||||
*
|
||||
* Previously generation lived as a cramped nested page inside the Cmd-K command
|
||||
* palette (~34rem popover). This is its own full Radix dialog with room to
|
||||
* breathe: a device-framed header, its own concept prompt, a roomy draft grid
|
||||
* that streams in live, and the egg-hatch + reveal flow. It's a thin view over
|
||||
* the `pet-generate` store; the store owns the generate → hatch → adopt steps.
|
||||
*
|
||||
* This file is just the dialog shell + sizing; the flow lives in
|
||||
* `PetGenerateContent`, and each screen is its own atomic component under
|
||||
* `./components`.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petGenDrafts,
|
||||
$petGenerateOpen,
|
||||
$petGenError,
|
||||
$petGenStatus,
|
||||
cleanupPetGenOnClose,
|
||||
closePetGenerate
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
import { PetGenerateContent } from './pet-generate-content'
|
||||
|
||||
export function PetGenerateOverlay() {
|
||||
const { t } = useI18n()
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const open = useStore($petGenerateOpen)
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const drafts = useStore($petGenDrafts)
|
||||
|
||||
// Yield the screen to a full-screen route overlay (e.g. /settings while the
|
||||
// user adds an image-gen key) without tearing down — the store keeps us open,
|
||||
// and we reappear + re-check on return.
|
||||
if (useRouteOverlayActive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
cleanupPetGenOnClose(requestGateway)
|
||||
// Never interrupt in-flight work. Generating/hatching continues in the
|
||||
// background; only an unadopted finished preview is discarded on close.
|
||||
closePetGenerate()
|
||||
}
|
||||
}
|
||||
|
||||
// The draft screen needs room for the 2×2 grid; the single-pet screens
|
||||
// (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box.
|
||||
// `fitContent` lets the dialog size to content; the `min-w` floors each phase.
|
||||
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
// The footer banner narrates the dialog's async state: the failure reason on a
|
||||
// dead-end error, else the "you can close this, we'll notify you" reassurance
|
||||
// while a generate/hatch runs in the background.
|
||||
const working = status === 'generating' || status === 'hatching'
|
||||
const errored = status === 'error' && drafts.length === 0
|
||||
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : undefined
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
banner={banner}
|
||||
bannerTone={errored ? 'error' : 'info'}
|
||||
// Cap the width so a long banner (e.g. a provider refusal) wraps instead
|
||||
// of stretching the dialog out; the min-w floors each phase.
|
||||
className={cn('gap-4 text-center', single ? 'min-w-[17rem] max-w-[20rem]' : 'min-w-[19rem] max-w-[22rem]')}
|
||||
fitContent
|
||||
>
|
||||
{open && <PetGenerateContent />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
|
||||
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $petInfo } from '@/store/pet'
|
||||
|
|
@ -16,13 +20,16 @@ import {
|
|||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
exportPet as exportPetAction,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
PET_SCALE_DEFAULT,
|
||||
PET_SCALE_MAX,
|
||||
PET_SCALE_MIN,
|
||||
type GalleryPet,
|
||||
rankedGalleryPets,
|
||||
removePet as removePetAction,
|
||||
renamePet as renamePetAction,
|
||||
setPetEnabled,
|
||||
setPetScale
|
||||
} from '@/store/pet-gallery'
|
||||
|
|
@ -48,6 +55,9 @@ export function PetSettings() {
|
|||
const busySlug = useStore($petBusy)
|
||||
const petInfo = useStore($petInfo)
|
||||
const [query, setQuery] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -71,6 +81,23 @@ export function PetSettings() {
|
|||
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const exportPet = (slug: string) => {
|
||||
void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const saveRename = () => {
|
||||
if (!renameTarget || !renameValue.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic: the rename paints instantly, so close now and let the RPC
|
||||
// settle in the background (it rolls back + surfaces an error on failure).
|
||||
const { slug } = renameTarget
|
||||
setRenameTarget(null)
|
||||
triggerHaptic('crisp')
|
||||
void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug))
|
||||
}
|
||||
|
||||
const toggle = (on: boolean) => {
|
||||
void setPetEnabled(requestGateway, on, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
|
|
@ -142,8 +169,15 @@ export function PetSettings() {
|
|||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{pet.displayName}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{pet.displayName}
|
||||
</span>
|
||||
{pet.generated && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
|
||||
{copy.generatedTag}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{pet.slug}
|
||||
|
|
@ -152,16 +186,36 @@ export function PetSettings() {
|
|||
</span>
|
||||
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
|
||||
</button>
|
||||
{pet.installed && !isBusy && (
|
||||
<button
|
||||
aria-label={copy.uninstall(pet.displayName)}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => void removePet(pet.slug)}
|
||||
title={copy.uninstall(pet.displayName)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
{!isBusy && (pet.installed || pet.generated) && (
|
||||
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100">
|
||||
{pet.generated && (
|
||||
<PetAction
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
label={copy.rename(pet.displayName)}
|
||||
onClick={() => {
|
||||
setRenameValue(pet.displayName)
|
||||
setRenameTarget(pet)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pet.generated && (
|
||||
<PetAction
|
||||
icon={<Download className="size-3.5" />}
|
||||
label={copy.exportPet(pet.displayName)}
|
||||
onClick={() => exportPet(pet.slug)}
|
||||
/>
|
||||
)}
|
||||
{pet.installed && (
|
||||
// Generated pets have no remote source — deletion is
|
||||
// permanent, so confirm; petdex pets just uninstall.
|
||||
<PetAction
|
||||
danger
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)}
|
||||
onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -226,6 +280,80 @@ export function PetSettings() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.deleteConfirm}
|
||||
description={copy.deleteBody}
|
||||
destructive
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
onConfirm={async () => {
|
||||
if (confirmDelete) {
|
||||
const ok = await removePetAction(requestGateway, confirmDelete.slug, copy.uninstallFailed(confirmDelete.slug))
|
||||
if (!ok) {
|
||||
throw new Error(copy.uninstallFailed(confirmDelete.slug))
|
||||
}
|
||||
triggerHaptic('crisp')
|
||||
}
|
||||
}}
|
||||
open={confirmDelete !== null}
|
||||
title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && setRenameTarget(null)} open={renameTarget !== null}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.renameTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={event => setRenameValue(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRename()
|
||||
}
|
||||
}}
|
||||
placeholder={copy.renamePlaceholder}
|
||||
value={renameValue}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRenameTarget(null)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={!renameValue.trim()} onClick={saveRename}>
|
||||
{copy.renameSave}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single hover-revealed icon action on a pet card (rename / export / delete). */
|
||||
function PetAction({
|
||||
danger,
|
||||
icon,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
danger?: boolean
|
||||
icon: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
|
||||
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,25 @@ interface Point {
|
|||
y: number
|
||||
}
|
||||
|
||||
interface PetInfoMeta {
|
||||
enabled: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
scale?: number
|
||||
spritesheetRevision?: string
|
||||
}
|
||||
|
||||
function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean {
|
||||
return (
|
||||
info.enabled &&
|
||||
Boolean(info.spritesheetBase64) &&
|
||||
info.slug === meta.slug &&
|
||||
info.displayName === meta.displayName &&
|
||||
info.scale === meta.scale &&
|
||||
info.spritesheetRevision === meta.spritesheetRevision
|
||||
)
|
||||
}
|
||||
|
||||
function clampToViewport({ x, y }: Point): Point {
|
||||
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
|
||||
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
|
||||
|
|
@ -63,12 +82,15 @@ function loadPosition(): Point {
|
|||
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
|
||||
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
|
||||
* while no pet is active and the mascot pops in within a few seconds — no
|
||||
* reload, no CLI. Once a pet is live we stop polling.
|
||||
* reload, no CLI. Once a pet is live we still refresh more slowly so generated
|
||||
* pets rewritten on disk (or renamed/rebuilt by the hatch flow) repaint without
|
||||
* restarting the app.
|
||||
*
|
||||
* Promotion to a separate frameless OS-level window is a follow-up — the
|
||||
* sprite + state logic here is reused as-is, only the host changes.
|
||||
*/
|
||||
const PET_POLL_MS = 3000
|
||||
const PET_ACTIVE_REFRESH_MS = 15000
|
||||
|
||||
export function FloatingPet() {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
|
@ -93,11 +115,12 @@ export function FloatingPet() {
|
|||
// state is only committed on release.
|
||||
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
|
||||
|
||||
// Fetch pet.info on connect, then keep polling while no pet is active so an
|
||||
// in-app `/pet <slug>` shows up live. Stops polling once a pet is enabled.
|
||||
// Fetch pet.info on connect. Poll quickly while inactive so an in-app
|
||||
// `/pet <slug>` appears, then slowly while active so regenerated spritesheets
|
||||
// and row-count metadata replace the cached base64 payload.
|
||||
const active = info.enabled && Boolean(info.spritesheetBase64)
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || active) {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -105,9 +128,39 @@ export function FloatingPet() {
|
|||
|
||||
const pull = async () => {
|
||||
try {
|
||||
if (active) {
|
||||
try {
|
||||
const meta = await requestGateway<PetInfoMeta>('pet.info.meta', { profile: petProfile() })
|
||||
if (cancelled || !meta) {
|
||||
return
|
||||
}
|
||||
if (!meta.enabled) {
|
||||
setPetInfo({ enabled: false })
|
||||
return
|
||||
}
|
||||
if (samePetRevision($petInfo.get(), meta)) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Older gateways may not have pet.info.meta yet; fall back to pet.info.
|
||||
}
|
||||
}
|
||||
|
||||
const next = await requestGateway<PetInfo>('pet.info', { profile: petProfile() })
|
||||
|
||||
if (!cancelled && next) {
|
||||
const current = $petInfo.get()
|
||||
if (
|
||||
next.enabled &&
|
||||
current.enabled &&
|
||||
current.slug === next.slug &&
|
||||
current.displayName === next.displayName &&
|
||||
current.scale === next.scale &&
|
||||
current.spritesheetRevision &&
|
||||
current.spritesheetRevision === next.spritesheetRevision
|
||||
) {
|
||||
return
|
||||
}
|
||||
setPetInfo(next)
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -116,10 +169,12 @@ export function FloatingPet() {
|
|||
}
|
||||
|
||||
void pull()
|
||||
const timer = window.setInterval(() => void pull(), PET_POLL_MS)
|
||||
const timer = window.setInterval(() => void pull(), active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS)
|
||||
window.addEventListener('focus', pull)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener('focus', pull)
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [gatewayState, active, requestGateway])
|
||||
|
|
|
|||
68
apps/desktop/src/components/pet/pet-egg-hatch.tsx
Normal file
68
apps/desktop/src/components/pet/pet-egg-hatch.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Egg-hatch visuals for the pet generation flow (Cmd-K → Pets → Generate).
|
||||
*
|
||||
* `PetEggHatch` is the incubation beat shown while `pet.hatch` runs: a wobbling
|
||||
* egg that reads as "something is about to hatch" instead of a bare spinner. The
|
||||
* reveal celebration is the canvas `PetStarShower`. Motion is disabled under
|
||||
* `prefers-reduced-motion`.
|
||||
*/
|
||||
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface PetEggHatchProps {
|
||||
subtitle?: string
|
||||
onCancel?: () => void
|
||||
cancelLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin progress bar. Determinate when given done/total (hatch rows stream one by
|
||||
* one, so a real percentage is meaningful); indeterminate otherwise (drafts
|
||||
* return together, so a count would just snap 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 (
|
||||
<div
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={determinate ? pct : undefined}
|
||||
className="pet-progress"
|
||||
role="progressbar"
|
||||
>
|
||||
{determinate ? (
|
||||
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
|
||||
) : (
|
||||
<div className="pet-progress__indeterminate" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<PixelEggSprite mode="bounce" size={88} />
|
||||
{/* The egg sprite has transparent canvas below the art, so pull the
|
||||
shadow up ~a fifth of its size to sit at the egg's base. */}
|
||||
<span className="pet-egg-shadow" style={{ marginTop: '-0.55rem' }} />
|
||||
</div>
|
||||
|
||||
{subtitle && (
|
||||
<p className="shimmer shimmer-color-primary whitespace-nowrap text-center text-[length:var(--conversation-caption-font-size)] leading-snug text-(--ui-text-tertiary)">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} size="xs" variant="text">
|
||||
{cancelLabel ?? 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
apps/desktop/src/components/pet/pet-egg-sheet.png
Normal file
BIN
apps/desktop/src/components/pet/pet-egg-sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -32,10 +32,33 @@ const STATE_ALIASES: Record<PetState, string[]> = {
|
|||
waiting: ['waiting']
|
||||
}
|
||||
|
||||
const ROW_TO_STATE: Record<string, PetState> = {
|
||||
idle: 'idle',
|
||||
wave: 'wave',
|
||||
waving: 'wave',
|
||||
jump: 'jump',
|
||||
jumping: 'jump',
|
||||
run: 'run',
|
||||
running: 'run',
|
||||
'running-right': 'run',
|
||||
'running-left': 'run',
|
||||
failed: 'failed',
|
||||
review: 'review',
|
||||
waiting: 'waiting'
|
||||
}
|
||||
|
||||
interface PetSpriteProps {
|
||||
info: PetInfo
|
||||
/** On-screen scale multiplier applied on top of the pet's native scale. */
|
||||
zoom?: number
|
||||
/**
|
||||
* Force a specific animation state instead of reading the live `$petState`.
|
||||
* Used by the generate-flow preview to showcase every row without driving (or
|
||||
* being driven by) the real agent activity that moves the floating mascot.
|
||||
*/
|
||||
stateOverride?: PetState
|
||||
/** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */
|
||||
rowOverride?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,14 +72,26 @@ interface PetSpriteProps {
|
|||
* with `memo`, this component effectively never re-renders after mount until
|
||||
* the pet itself changes.
|
||||
*/
|
||||
function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
const overrideRef = useRef<PetState | undefined>(stateOverride)
|
||||
const rowOverrideRef = useRef<string | undefined>(rowOverride)
|
||||
|
||||
// Keep the override current without re-running the RAF setup effect.
|
||||
useEffect(() => {
|
||||
overrideRef.current = stateOverride
|
||||
}, [stateOverride])
|
||||
|
||||
useEffect(() => {
|
||||
rowOverrideRef.current = rowOverride
|
||||
}, [rowOverride])
|
||||
|
||||
const frameW = info.frameW ?? DEFAULT_FRAME_W
|
||||
const frameH = info.frameH ?? DEFAULT_FRAME_H
|
||||
const frames = info.framesPerState ?? DEFAULT_FRAMES
|
||||
const framesByState = info.framesByState
|
||||
const framesByRow = info.framesByRow
|
||||
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
|
||||
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
|
||||
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
|
||||
|
|
@ -100,6 +135,8 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
|||
let lastStep = performance.now()
|
||||
let drawnFrame = -1
|
||||
let drawnRow = -1
|
||||
let activeRow = -1
|
||||
let activeCount = -1
|
||||
|
||||
const rowIndexForState = (s: PetState): number => {
|
||||
for (const key of STATE_ALIASES[s] ?? [s]) {
|
||||
|
|
@ -116,6 +153,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
|||
// than flashing blank padding.
|
||||
const resolve = (s: PetState): { row: number; count: number } => {
|
||||
const real = framesByState?.[s] ?? frames
|
||||
|
||||
if (real > 0) {
|
||||
return { row: rowIndexForState(s), count: real }
|
||||
}
|
||||
|
|
@ -123,8 +161,28 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
|||
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
|
||||
}
|
||||
|
||||
const resolveRow = (rowName: string): { row: number; count: number } => {
|
||||
const row = rows.indexOf(rowName)
|
||||
const state = ROW_TO_STATE[rowName]
|
||||
const count = Math.max(
|
||||
1,
|
||||
framesByRow?.[rowName] ?? framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames
|
||||
)
|
||||
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
|
||||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
const { row, count } = resolve(stateRef.current)
|
||||
const forcedRow = rowOverrideRef.current
|
||||
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
|
||||
|
||||
if (row !== activeRow || count !== activeCount) {
|
||||
activeRow = row
|
||||
activeCount = count
|
||||
frame = 0
|
||||
lastStep = now
|
||||
drawnFrame = -1
|
||||
}
|
||||
|
||||
// Per-state step keeps every state's loop ~loopMs even when frame counts
|
||||
// differ; counts vary per row so derive the cadence here, not once.
|
||||
const stepMs = loopMs / count
|
||||
|
|
@ -158,7 +216,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
|||
cancelAnimationFrame(raf)
|
||||
unsubState()
|
||||
}
|
||||
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
|
|
|||
204
apps/desktop/src/components/pet/pet-star-shower.tsx
Normal file
204
apps/desktop/src/components/pet/pet-star-shower.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Canvas hatch celebration layered over a freshly revealed pet: a one-shot
|
||||
* sunburst of rotating god-rays, a fast radial star burst (confetti physics —
|
||||
* velocity + decay + gravity + spin), and a light trickle of rising twinkle
|
||||
* motes. Additive (`lighter`) so the sparkles bloom. No glow-halo flash.
|
||||
*
|
||||
* Sized to its container (absolute inset-0, pointer-events: none) and disabled
|
||||
* under `prefers-reduced-motion`.
|
||||
*/
|
||||
|
||||
const GOLD = '#ffd76a'
|
||||
const BURST = 15
|
||||
const VELOCITY = 500
|
||||
const DECAY = 0.9
|
||||
const GRAVITY = 90
|
||||
const RAY_COUNT = 24
|
||||
const GOLD_MIX = 0.6
|
||||
const MOTE_MS = 333 // ~3 / sec
|
||||
|
||||
interface Star {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
rot: number
|
||||
vrot: number
|
||||
phase: number
|
||||
twinkle: number
|
||||
life: number
|
||||
ttl: number
|
||||
color: string
|
||||
rise: boolean
|
||||
}
|
||||
|
||||
function readAccent(el: HTMLElement): string {
|
||||
return getComputedStyle(el).getPropertyValue('--ui-accent').trim() || '#9aa0ff'
|
||||
}
|
||||
|
||||
function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color: string): void {
|
||||
ctx.rotate(rot)
|
||||
ctx.fillStyle = color
|
||||
for (const [rx, ry] of [
|
||||
[size, size * 0.26],
|
||||
[size * 0.26, size]
|
||||
]) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -ry)
|
||||
ctx.lineTo(rx, 0)
|
||||
ctx.lineTo(0, ry)
|
||||
ctx.lineTo(-rx, 0)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
const core = Math.max(1, Math.round(size * 0.4))
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(-core / 2, -core / 2, core, core)
|
||||
}
|
||||
|
||||
export function PetStarShower() {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
const parent = canvas?.parentElement
|
||||
if (!canvas || !ctx || !parent) {
|
||||
return
|
||||
}
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
return
|
||||
}
|
||||
|
||||
const accent = readAccent(canvas)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3)
|
||||
let w = 0
|
||||
let h = 0
|
||||
let cx = 0
|
||||
let cy = 0
|
||||
const resize = () => {
|
||||
const r = parent.getBoundingClientRect()
|
||||
w = r.width
|
||||
h = r.height
|
||||
cx = w / 2
|
||||
cy = h * 0.54
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(parent)
|
||||
|
||||
const pick = () => (Math.random() < GOLD_MIX ? GOLD : Math.random() < 0.5 ? accent : '#ffffff')
|
||||
const stars: Star[] = []
|
||||
for (let i = 0; i < BURST; i++) {
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const sp = VELOCITY * (0.4 + Math.random() * 0.7)
|
||||
stars.push({
|
||||
x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
|
||||
size: 3.5 + Math.random() * 5.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 8,
|
||||
phase: 0, twinkle: 0, life: 0, ttl: 0.8 + Math.random() * 0.7, color: pick(), rise: false
|
||||
})
|
||||
}
|
||||
const rays = { life: 0, ttl: 0.9, rot: Math.random() * 6.28 }
|
||||
|
||||
let raf = 0
|
||||
let last = performance.now()
|
||||
let acc = 0
|
||||
let raysAlive = true
|
||||
|
||||
const tick = (now: number) => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
const ms = now - last
|
||||
last = now
|
||||
const dt = Math.min(0.05, ms / 1000)
|
||||
const decay = Math.pow(DECAY, dt * 60)
|
||||
acc += ms
|
||||
if (acc >= MOTE_MS && stars.length < 40) {
|
||||
acc = 0
|
||||
stars.push({
|
||||
x: cx + (Math.random() - 0.5) * w * 0.85, y: cy + Math.random() * h * 0.25,
|
||||
vx: (Math.random() - 0.5) * 14, vy: -(14 + Math.random() * 26),
|
||||
size: 2.5 + Math.random() * 3.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 2,
|
||||
phase: Math.random() * 6.28, twinkle: 5 + Math.random() * 4, life: 0, ttl: 1.2 + Math.random(),
|
||||
color: pick(), rise: true
|
||||
})
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
|
||||
// Sunburst god-rays — one-shot bloom + slow spin.
|
||||
if (raysAlive) {
|
||||
rays.life += dt
|
||||
rays.rot += dt * 0.6
|
||||
const t = rays.life / rays.ttl
|
||||
if (t >= 1) {
|
||||
raysAlive = false
|
||||
} else {
|
||||
const len = Math.max(w, h) * 0.62 * (1 - (1 - t) ** 2)
|
||||
ctx.save()
|
||||
ctx.translate(cx, cy)
|
||||
ctx.rotate(rays.rot)
|
||||
for (let i = 0; i < RAY_COUNT; i++) {
|
||||
ctx.rotate((Math.PI * 2) / RAY_COUNT)
|
||||
const a = (1 - t) * 0.3 * (i % 2 ? 0.65 : 1)
|
||||
const wd = len * 0.05
|
||||
const g = ctx.createLinearGradient(0, 0, 0, -len)
|
||||
g.addColorStop(0, `rgba(255,255,255,${a})`)
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
ctx.fillStyle = g
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-wd, 0)
|
||||
ctx.lineTo(wd, 0)
|
||||
ctx.lineTo(0, -len)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = stars.length - 1; i >= 0; i--) {
|
||||
const s = stars[i]
|
||||
s.life += dt
|
||||
if (s.rise) {
|
||||
s.vy += 7 * dt
|
||||
s.phase += s.twinkle * dt
|
||||
} else {
|
||||
s.vx *= decay
|
||||
s.vy = s.vy * decay + GRAVITY * dt
|
||||
}
|
||||
s.x += s.vx * dt
|
||||
s.y += s.vy * dt
|
||||
s.rot += s.vrot * dt
|
||||
if (s.life >= s.ttl || s.y < -12) {
|
||||
stars.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const fade = s.rise
|
||||
? Math.min(1, s.life * 5, (s.ttl - s.life) * 3) * (0.45 + 0.55 * Math.abs(Math.sin(s.phase)))
|
||||
: Math.min(1, (s.ttl - s.life) * 3)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = fade
|
||||
ctx.translate(Math.round(s.x), Math.round(s.y))
|
||||
sparkle(ctx, s.size, s.rot, s.color)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas className="pointer-events-none absolute inset-0 z-10 h-full w-full" ref={canvasRef} />
|
||||
}
|
||||
234
apps/desktop/src/components/pet/pixel-egg-sprite.tsx
Normal file
234
apps/desktop/src/components/pet/pixel-egg-sprite.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { type CSSProperties, useEffect, useRef } from 'react'
|
||||
|
||||
import eggSheetUrl from './pet-egg-sheet.png'
|
||||
|
||||
/**
|
||||
* Animated pixel egg — the iamcrog "bouncing hatching egg" 12-frame sheet
|
||||
* (32×32 cells, stacked vertically), drawn to a canvas and recolored to a warm
|
||||
* white/creme shell.
|
||||
*
|
||||
* The sheet's shell is mid-gray, so a plain multiply only darkens it (still
|
||||
* gray). Instead we remap each pixel's luminance through a creme ramp via a 256-
|
||||
* entry LUT: near-black stays a warm dark outline, midtones become creme shadow,
|
||||
* highlights go near-white. Done on a 32×32 offscreen then nearest-neighbor
|
||||
* scaled up so it stays crisp.
|
||||
*
|
||||
* Frames 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<HTMLImageElement> | null = null
|
||||
|
||||
function loadSheet(): Promise<HTMLImageElement> {
|
||||
if (_sheet?.complete) {
|
||||
return Promise.resolve(_sheet)
|
||||
}
|
||||
if (!_sheetLoading) {
|
||||
_sheetLoading = new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
_sheet = img
|
||||
resolve(img)
|
||||
}
|
||||
img.onerror = reject
|
||||
img.src = eggSheetUrl
|
||||
})
|
||||
}
|
||||
return _sheetLoading
|
||||
}
|
||||
|
||||
interface PixelEggSpriteProps {
|
||||
mode: 'bounce' | 'hatch'
|
||||
/** On-screen size (px, square). */
|
||||
size: number
|
||||
/**
|
||||
* Slot position in a grid of eggs. Used to deterministically spread each egg's
|
||||
* first bounce across the rest window so neighbours never stir together (random
|
||||
* jitter alone can collide with only a handful of eggs).
|
||||
*/
|
||||
index?: number
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
/** Fired once when a `hatch` run reaches the final frame. */
|
||||
onDone?: () => void
|
||||
}
|
||||
|
||||
export function PixelEggSprite({ mode, size, index = 0, className, style, onDone }: PixelEggSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const onDoneRef = useRef(onDone)
|
||||
onDoneRef.current = onDone
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3)
|
||||
const dim = Math.round(size * dpr)
|
||||
canvas.width = dim
|
||||
canvas.height = dim
|
||||
|
||||
const lastFrame = TOTAL_FRAMES - 1
|
||||
// Mild per-egg speed jitter so bounces don't feel mechanical.
|
||||
const frameMs = (mode === 'bounce' ? BOUNCE_MS : HATCH_MS) * (0.85 + Math.random() * 0.3)
|
||||
const restMs = () => REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS)
|
||||
// First bounce: a deterministic per-slot slice of the rest window (so two
|
||||
// eggs never start together) plus a little random jitter on top.
|
||||
const firstDelay = ((index % 4) + 1) * (REST_MIN_MS / 4) + Math.random() * REST_MIN_MS
|
||||
|
||||
// 32×32 offscreen we recolor per frame, then scale up nearest-neighbor.
|
||||
const off = document.createElement('canvas')
|
||||
off.width = FRAME
|
||||
off.height = FRAME
|
||||
const offCtx = off.getContext('2d', { willReadFrequently: true })
|
||||
|
||||
let sheet: HTMLImageElement | null = null
|
||||
void loadSheet().then(img => {
|
||||
sheet = img
|
||||
})
|
||||
|
||||
const render = (frame: number) => {
|
||||
if (!sheet || !offCtx) {
|
||||
return
|
||||
}
|
||||
offCtx.clearRect(0, 0, FRAME, FRAME)
|
||||
offCtx.imageSmoothingEnabled = false
|
||||
offCtx.drawImage(sheet, 0, frame * FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME)
|
||||
const img = offCtx.getImageData(0, 0, FRAME, FRAME)
|
||||
const d = img.data
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
if (d[i + 3] === 0) {
|
||||
continue
|
||||
}
|
||||
const g = d[i] * 3
|
||||
d[i] = CREME_LUT[g]
|
||||
d[i + 1] = CREME_LUT[g + 1]
|
||||
d[i + 2] = CREME_LUT[g + 2]
|
||||
}
|
||||
offCtx.putImageData(img, 0, 0)
|
||||
|
||||
ctx.clearRect(0, 0, dim, dim)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim)
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
let step = 0
|
||||
let finished = false
|
||||
// bounce: `nextAt` is when the next thing happens — the next bounce frame, or
|
||||
// the start of a new bounce after a rest. hatch: `lastHatch` time-gates frames.
|
||||
let resting = mode === 'bounce'
|
||||
let nextAt = 0
|
||||
let lastHatch = 0
|
||||
|
||||
const tick = (now: number) => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
if (!sheet) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'hatch') {
|
||||
if (!lastHatch) {
|
||||
lastHatch = now
|
||||
render(HATCH_START)
|
||||
return
|
||||
}
|
||||
if (now - lastHatch < frameMs) {
|
||||
return
|
||||
}
|
||||
lastHatch = now
|
||||
const frame = Math.min(HATCH_START + step, lastFrame)
|
||||
render(frame)
|
||||
if (frame >= lastFrame) {
|
||||
if (!finished) {
|
||||
finished = true
|
||||
onDoneRef.current?.()
|
||||
}
|
||||
return // hold the cracked-open last frame
|
||||
}
|
||||
step += 1
|
||||
return
|
||||
}
|
||||
|
||||
// bounce: rest on frame 0, play 0..5, then rest again.
|
||||
if (!nextAt) {
|
||||
render(0)
|
||||
nextAt = now + firstDelay // staggered first bounce, per slot
|
||||
return
|
||||
}
|
||||
if (now < nextAt) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resting) {
|
||||
resting = false
|
||||
step = 0
|
||||
render(0)
|
||||
nextAt = now + frameMs
|
||||
return
|
||||
}
|
||||
|
||||
step += 1
|
||||
if (step >= BOUNCE_FRAMES) {
|
||||
resting = true
|
||||
render(0)
|
||||
nextAt = now + restMs()
|
||||
return
|
||||
}
|
||||
render(step)
|
||||
nextAt = now + frameMs
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [mode, size, index])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
className={className}
|
||||
ref={canvasRef}
|
||||
style={{ width: size, height: size, imageRendering: 'pixelated', ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -35,16 +35,98 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
|
|||
)
|
||||
}
|
||||
|
||||
type DialogBannerTone = 'error' | 'warn' | 'info'
|
||||
|
||||
// Tinted, edge-to-edge bottom banner per tone. Error/warn keep their semantic
|
||||
// destructive/primary tokens; info derives from the dialog's own bubble
|
||||
// background so it reads as part of the themed dialog — lifted 30% toward white
|
||||
// in light mode, deepened 20% toward black in dark mode.
|
||||
const DIALOG_BANNER_TONES: Record<DialogBannerTone, string> = {
|
||||
error: 'bg-destructive/12 text-destructive',
|
||||
warn: 'bg-primary/12 text-primary',
|
||||
info: 'bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_30%)] text-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_60%)] dark:bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_20%)] dark:text-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_60%)]'
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
fitContent = false,
|
||||
banner,
|
||||
bannerTone = 'error',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
// Size the dialog to its content (capped at the viewport) instead of the
|
||||
// default fixed `max-w-lg`. For content that has no intrinsic width (grids,
|
||||
// full-width inputs) pair it with a `min-w-*` in `className`.
|
||||
fitContent?: boolean
|
||||
// A dialog-level notice rendered as a banner flush to the bottom edge (tinted,
|
||||
// inherited bottom radius) so it reads as part of the dialog, not a floating
|
||||
// alert. Falsy → no banner. Tone picks the colour.
|
||||
banner?: React.ReactNode
|
||||
bannerTone?: DialogBannerTone
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const widthClass = fitContent ? 'w-auto max-w-[92vw]' : 'w-full max-w-lg'
|
||||
|
||||
const closeButton = showCloseButton ? (
|
||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t.common.close}</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
) : null
|
||||
|
||||
// With a banner, the border can't live on the scroll/clip box (it would draw a
|
||||
// line around the banner too). The white body keeps its own bottom radius and
|
||||
// sits over the tinted footer; the outer shell only clips the banner to the
|
||||
// dialog's rounded bottom edge.
|
||||
if (banner) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
widthClass,
|
||||
className,
|
||||
// Callers often pass `gap-*` for the no-banner grid layout — suppress
|
||||
// it here so the banner can tuck under the body's rounded bottom edge.
|
||||
'gap-0'
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
|
||||
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
|
||||
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
// Overlap by one corner radius so the white bottom lobes read clearly
|
||||
// over the tint instead of meeting it on a straight seam.
|
||||
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
|
||||
DIALOG_BANNER_TONES[bannerTone]
|
||||
)}
|
||||
data-slot="dialog-banner"
|
||||
role={bannerTone === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{banner}
|
||||
</div>
|
||||
{closeButton}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
|
|
@ -53,26 +135,15 @@ function DialogContent({
|
|||
// Cap height at 85vh and let long content scroll inside the dialog
|
||||
// instead of overflowing off-screen (long cron titles, tool detail
|
||||
// dumps, etc.). Individual dialogs can still override via className.
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
widthClass,
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t.common.close}</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
{closeButton}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
|
|
|
|||
62
apps/desktop/src/components/ui/generate-button.tsx
Normal file
62
apps/desktop/src/components/ui/generate-button.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface GenerateButtonProps extends Omit<React.ComponentProps<typeof Button>, 'children' | 'onClick'> {
|
||||
/** True while a generation is in flight. */
|
||||
generating: boolean
|
||||
/** Start a generation. */
|
||||
onGenerate: () => void
|
||||
/** Cancel an in-flight generation. When omitted, the button just spins while
|
||||
* generating (for one-shots that can't be cancelled). */
|
||||
onCancel?: () => void
|
||||
/** Tooltip + aria label at rest (and while generating if no `generatingLabel`). */
|
||||
label: string
|
||||
/** Tooltip while generating (e.g. "Stop" with cancel, "Generating…" without). */
|
||||
generatingLabel?: string
|
||||
iconSize?: number | string
|
||||
}
|
||||
|
||||
/** The sparkle "generate with AI" affordance — icon + tooltip, shared by the
|
||||
* commit-message box and the new-project idea field so they stay one pattern.
|
||||
* Sparkle → click generates; with `onCancel`, a Stop square appears mid-run;
|
||||
* without it, the sparkle spins until the one-shot resolves. */
|
||||
export function GenerateButton({
|
||||
generating,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
label,
|
||||
generatingLabel,
|
||||
disabled,
|
||||
iconSize = 12,
|
||||
className,
|
||||
...rest
|
||||
}: GenerateButtonProps) {
|
||||
const tip = generating ? (generatingLabel ?? label) : label
|
||||
const cancellable = generating && !!onCancel
|
||||
|
||||
return (
|
||||
<Tip label={tip}>
|
||||
<Button
|
||||
aria-label={tip}
|
||||
className={cn('text-muted-foreground/80 hover:text-foreground', className)}
|
||||
disabled={generating ? !onCancel : disabled}
|
||||
onClick={cancellable ? onCancel : onGenerate}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
>
|
||||
{cancellable ? (
|
||||
<Square className="fill-current" size={11} />
|
||||
) : (
|
||||
<Codicon name="sparkle" size={iconSize} spinning={generating} />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
|
@ -391,11 +391,23 @@ export const en: Translations = {
|
|||
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
|
||||
noMatch: query => `No pets match "${query}".`,
|
||||
installedTag: 'installed',
|
||||
generatedTag: 'Generated',
|
||||
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
|
||||
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
|
||||
uninstall: name => `Uninstall ${name}`,
|
||||
delete: name => `Delete ${name}`,
|
||||
deleteTitle: name => `Delete ${name}?`,
|
||||
deleteBody: "This permanently deletes the pet — it can't be reinstalled.",
|
||||
deleteConfirm: 'Delete',
|
||||
rename: name => `Rename ${name}`,
|
||||
renameTitle: 'Rename pet',
|
||||
renamePlaceholder: 'Name your pet',
|
||||
renameSave: 'Save',
|
||||
exportPet: name => `Export ${name}`,
|
||||
adoptFailed: slug => `Could not adopt ${slug}`,
|
||||
uninstallFailed: slug => `Could not uninstall ${slug}`,
|
||||
renameFailed: slug => `Could not rename ${slug}`,
|
||||
exportFailed: slug => `Could not export ${slug}`,
|
||||
noneAvailable: 'No pets available to turn on right now.',
|
||||
turnOnFailed: 'Could not turn the pet on.',
|
||||
turnOffFailed: 'Could not turn the pet off.'
|
||||
|
|
@ -762,10 +774,36 @@ export const en: Translations = {
|
|||
turnOff: 'Turn off',
|
||||
turnOn: 'Turn on',
|
||||
installed: 'Installed',
|
||||
generatedTag: 'Generated',
|
||||
adoptFailed: 'Could not adopt that pet.',
|
||||
toggleFailed: 'Could not toggle the pet.',
|
||||
noneAvailable: 'No pets available — pick one below to install.'
|
||||
},
|
||||
generatePet: {
|
||||
title: 'Generate a pet',
|
||||
placeholder: 'Describe a pet to generate…',
|
||||
promptHint: 'Type a description, then press Enter to draft four looks.',
|
||||
readyHint: 'Press Enter to draft four looks from your description.',
|
||||
generate: 'Generate',
|
||||
generating: 'Generating…',
|
||||
retry: 'Retry',
|
||||
hatch: 'Hatch',
|
||||
spawning: 'Spawning…',
|
||||
hatching: 'Hatching your pet…',
|
||||
hatchingSub: 'Bringing it to life…',
|
||||
hatched: 'It hatched!',
|
||||
hatchRow: (_state, done, total) => `Sketching frame ${done} of ${total}…`,
|
||||
hatchComposing: 'Piecing it together…',
|
||||
hatchSaving: 'Almost there…',
|
||||
namePlaceholder: 'Name your pet',
|
||||
staleBackend: 'Update Hermes to generate pets.',
|
||||
backgroundHint: 'You can close this — Hermes will notify you when 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'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'Install theme...',
|
||||
placeholder: 'Search the VS Code Marketplace...',
|
||||
|
|
|
|||
|
|
@ -305,11 +305,23 @@ export const ja = defineLocale({
|
|||
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
|
||||
noMatch: query => `「${query}」に一致するペットがありません。`,
|
||||
installedTag: 'インストール済み',
|
||||
generatedTag: '生成',
|
||||
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
|
||||
count: n => `${n} 件のペット。`,
|
||||
uninstall: name => `${name} をアンインストール`,
|
||||
delete: name => `${name} を削除`,
|
||||
deleteTitle: name => `${name} を削除しますか?`,
|
||||
deleteBody: 'ペットを完全に削除します。再インストールはできません。',
|
||||
deleteConfirm: '削除',
|
||||
rename: name => `${name} の名前を変更`,
|
||||
renameTitle: 'ペットの名前を変更',
|
||||
renamePlaceholder: 'ペットに名前を付ける',
|
||||
renameSave: '保存',
|
||||
exportPet: name => `${name} をエクスポート`,
|
||||
adoptFailed: slug => `${slug} を採用できませんでした`,
|
||||
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
|
||||
renameFailed: slug => `${slug} の名前を変更できませんでした`,
|
||||
exportFailed: slug => `${slug} をエクスポートできませんでした`,
|
||||
noneAvailable: 'オンにできるペットがありません。',
|
||||
turnOnFailed: 'ペットをオンにできませんでした。',
|
||||
turnOffFailed: 'ペットをオフにできませんでした。'
|
||||
|
|
@ -881,10 +893,36 @@ export const ja = defineLocale({
|
|||
turnOff: 'オフ',
|
||||
turnOn: 'オン',
|
||||
installed: 'インストール済み',
|
||||
generatedTag: '生成',
|
||||
adoptFailed: 'ペットを採用できませんでした。',
|
||||
toggleFailed: 'ペットを切り替えできませんでした。',
|
||||
noneAvailable: '利用可能なペットがありません。'
|
||||
},
|
||||
generatePet: {
|
||||
title: 'ペットを生成',
|
||||
placeholder: '生成するペットを説明…',
|
||||
promptHint: '説明を入力して Enter を押すと、4 つの見た目を生成します。',
|
||||
readyHint: 'Enter を押すと、説明から 4 つの見た目を生成します。',
|
||||
generate: '生成',
|
||||
generating: '生成中…',
|
||||
retry: '再試行',
|
||||
hatch: '孵化',
|
||||
spawning: 'スポーン中…',
|
||||
hatching: 'ペットを孵化しています…',
|
||||
hatchingSub: '命を吹き込んでいます…',
|
||||
hatched: '孵化しました!',
|
||||
hatchRow: (_state, done, total) => `フレームを描画中… ${done}/${total}`,
|
||||
hatchComposing: 'まとめています…',
|
||||
hatchSaving: 'もうすぐです…',
|
||||
namePlaceholder: 'ペットに名前を付ける',
|
||||
staleBackend: 'ペットを生成するには Hermes を更新してください。',
|
||||
backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。',
|
||||
genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。',
|
||||
referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。',
|
||||
referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。',
|
||||
adopt: '迎え入れる',
|
||||
startOver: 'やり直す'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'テーマをインストール...',
|
||||
placeholder: 'VS Code Marketplace を検索...',
|
||||
|
|
|
|||
|
|
@ -285,11 +285,23 @@ export interface Translations {
|
|||
unreachable: string
|
||||
noMatch: (query: string) => string
|
||||
installedTag: string
|
||||
generatedTag: string
|
||||
countCapped: (cap: number, total: number) => string
|
||||
count: (n: number) => string
|
||||
uninstall: (name: string) => string
|
||||
delete: (name: string) => string
|
||||
deleteTitle: (name: string) => string
|
||||
deleteBody: string
|
||||
deleteConfirm: string
|
||||
rename: (name: string) => string
|
||||
renameTitle: string
|
||||
renamePlaceholder: string
|
||||
renameSave: string
|
||||
exportPet: (name: string) => string
|
||||
adoptFailed: (slug: string) => string
|
||||
uninstallFailed: (slug: string) => string
|
||||
renameFailed: (slug: string) => string
|
||||
exportFailed: (slug: string) => string
|
||||
noneAvailable: string
|
||||
turnOnFailed: string
|
||||
turnOffFailed: string
|
||||
|
|
@ -636,10 +648,36 @@ export interface Translations {
|
|||
turnOff: string
|
||||
turnOn: string
|
||||
installed: string
|
||||
generatedTag: string
|
||||
adoptFailed: string
|
||||
toggleFailed: string
|
||||
noneAvailable: string
|
||||
}
|
||||
generatePet: {
|
||||
title: string
|
||||
placeholder: string
|
||||
promptHint: string
|
||||
readyHint: string
|
||||
generate: string
|
||||
generating: string
|
||||
retry: string
|
||||
hatch: string
|
||||
spawning: string
|
||||
hatching: string
|
||||
hatchingSub: string
|
||||
hatched: string
|
||||
hatchRow: (state: string, done: number, total: number) => string
|
||||
hatchComposing: string
|
||||
hatchSaving: string
|
||||
namePlaceholder: string
|
||||
staleBackend: string
|
||||
backgroundHint: string
|
||||
genericError: string
|
||||
referenceImageTooLarge: string
|
||||
referenceImageInvalid: string
|
||||
adopt: string
|
||||
startOver: string
|
||||
}
|
||||
installTheme: {
|
||||
title: string
|
||||
placeholder: string
|
||||
|
|
|
|||
|
|
@ -292,11 +292,23 @@ export const zhHant = defineLocale({
|
|||
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
|
||||
noMatch: query => `沒有符合「${query}」的寵物。`,
|
||||
installedTag: '已安裝',
|
||||
generatedTag: '生成',
|
||||
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
|
||||
count: n => `${n} 個寵物。`,
|
||||
uninstall: name => `解除安裝 ${name}`,
|
||||
delete: name => `刪除 ${name}`,
|
||||
deleteTitle: name => `刪除 ${name}?`,
|
||||
deleteBody: '此操作會永久刪除寵物,且無法重新安裝。',
|
||||
deleteConfirm: '刪除',
|
||||
rename: name => `重新命名 ${name}`,
|
||||
renameTitle: '重新命名寵物',
|
||||
renamePlaceholder: '為寵物取個名字',
|
||||
renameSave: '儲存',
|
||||
exportPet: name => `匯出 ${name}`,
|
||||
adoptFailed: slug => `無法領養 ${slug}`,
|
||||
uninstallFailed: slug => `無法解除安裝 ${slug}`,
|
||||
renameFailed: slug => `無法重新命名 ${slug}`,
|
||||
exportFailed: slug => `無法匯出 ${slug}`,
|
||||
noneAvailable: '目前沒有可開啟的寵物。',
|
||||
turnOnFailed: '無法開啟寵物。',
|
||||
turnOffFailed: '無法關閉寵物。'
|
||||
|
|
@ -851,10 +863,36 @@ export const zhHant = defineLocale({
|
|||
turnOff: '關閉',
|
||||
turnOn: '開啟',
|
||||
installed: '已安裝',
|
||||
generatedTag: '生成',
|
||||
adoptFailed: '無法領養該寵物。',
|
||||
toggleFailed: '無法切換寵物顯示。',
|
||||
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
|
||||
},
|
||||
generatePet: {
|
||||
title: '生成寵物',
|
||||
placeholder: '描述要生成的寵物……',
|
||||
promptHint: '輸入描述,然後按 Enter 生成四種造型。',
|
||||
readyHint: '按 Enter 依描述生成四種造型。',
|
||||
generate: '生成',
|
||||
generating: '生成中……',
|
||||
retry: '重試',
|
||||
hatch: '孵化',
|
||||
spawning: '召喚中……',
|
||||
hatching: '正在孵化你的寵物……',
|
||||
hatchingSub: '正在注入生命……',
|
||||
hatched: '孵化成功!',
|
||||
hatchRow: (_state, done, total) => `正在繪製畫面…… ${done}/${total}`,
|
||||
hatchComposing: '正在拼合……',
|
||||
hatchSaving: '快好了……',
|
||||
namePlaceholder: '為寵物命名',
|
||||
staleBackend: '請更新 Hermes 以生成寵物。',
|
||||
backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。',
|
||||
genericError: '生成失敗——請重試或選一個建議。',
|
||||
referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。',
|
||||
referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。',
|
||||
adopt: '領養',
|
||||
startOver: '重新開始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安裝主題...',
|
||||
placeholder: '搜尋 VS Code Marketplace...',
|
||||
|
|
|
|||
|
|
@ -381,11 +381,23 @@ export const zh: Translations = {
|
|||
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
|
||||
noMatch: query => `没有匹配「${query}」的宠物。`,
|
||||
installedTag: '已安装',
|
||||
generatedTag: '生成',
|
||||
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
|
||||
count: n => `${n} 个宠物。`,
|
||||
uninstall: name => `卸载 ${name}`,
|
||||
delete: name => `删除 ${name}`,
|
||||
deleteTitle: name => `删除 ${name}?`,
|
||||
deleteBody: '此操作会永久删除宠物,且无法重新安装。',
|
||||
deleteConfirm: '删除',
|
||||
rename: name => `重命名 ${name}`,
|
||||
renameTitle: '重命名宠物',
|
||||
renamePlaceholder: '给宠物起个名字',
|
||||
renameSave: '保存',
|
||||
exportPet: name => `导出 ${name}`,
|
||||
adoptFailed: slug => `无法领养 ${slug}`,
|
||||
uninstallFailed: slug => `无法卸载 ${slug}`,
|
||||
renameFailed: slug => `无法重命名 ${slug}`,
|
||||
exportFailed: slug => `无法导出 ${slug}`,
|
||||
noneAvailable: '当前没有可开启的宠物。',
|
||||
turnOnFailed: '无法开启宠物。',
|
||||
turnOffFailed: '无法关闭宠物。'
|
||||
|
|
@ -949,10 +961,36 @@ export const zh: Translations = {
|
|||
turnOff: '关闭',
|
||||
turnOn: '开启',
|
||||
installed: '已安装',
|
||||
generatedTag: '生成',
|
||||
adoptFailed: '无法领养该宠物。',
|
||||
toggleFailed: '无法切换宠物显示。',
|
||||
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
|
||||
},
|
||||
generatePet: {
|
||||
title: '生成宠物',
|
||||
placeholder: '描述要生成的宠物……',
|
||||
promptHint: '输入描述,然后按 Enter 生成四种造型。',
|
||||
readyHint: '按 Enter 根据描述生成四种造型。',
|
||||
generate: '生成',
|
||||
generating: '生成中……',
|
||||
retry: '重试',
|
||||
hatch: '孵化',
|
||||
spawning: '召唤中……',
|
||||
hatching: '正在孵化你的宠物……',
|
||||
hatchingSub: '正在注入生命……',
|
||||
hatched: '孵化成功!',
|
||||
hatchRow: (_state, done, total) => `正在绘制画面…… ${done}/${total}`,
|
||||
hatchComposing: '正在拼合……',
|
||||
hatchSaving: '马上就好……',
|
||||
namePlaceholder: '给宠物起个名字',
|
||||
staleBackend: '请更新 Hermes 以生成宠物。',
|
||||
backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。',
|
||||
genericError: '生成失败——请重试或选择一个建议。',
|
||||
referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。',
|
||||
referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。',
|
||||
adopt: '领养',
|
||||
startOver: '重新开始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安装主题...',
|
||||
placeholder: '搜索 VS Code Marketplace...',
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export interface GalleryPet {
|
|||
spritesheetUrl?: string
|
||||
/** petdex's hand-picked set — used only to rank "popular" pets first. */
|
||||
curated?: boolean
|
||||
/** Hatched locally by the user (createdBy=generator) — badged + ranked first. */
|
||||
generated?: boolean
|
||||
}
|
||||
|
||||
export interface PetGallery {
|
||||
|
|
@ -39,7 +41,12 @@ export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error'
|
|||
|
||||
/** The recovering `requestGateway` from `useGatewayRequest` — passed in so the
|
||||
* store reuses the hook's reconnect/reauth handling instead of duplicating it. */
|
||||
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
export type GatewayRequest = <T>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
) => Promise<T>
|
||||
|
||||
/** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active
|
||||
* profile (the gateway no-ops it for the launch profile). One chokepoint so no
|
||||
|
|
@ -115,16 +122,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
|
|||
$petGalleryStatus.set('loading')
|
||||
}
|
||||
|
||||
let localOk = false
|
||||
|
||||
try {
|
||||
const [next, info] = await Promise.all([
|
||||
petRpc<PetGallery>(request, 'pet.gallery'),
|
||||
// Phase 1: local pets only — instant, never blocks on the remote petdex
|
||||
// manifest. The user's own/generated pets render right away.
|
||||
const [local, info] = await Promise.all([
|
||||
petRpc<PetGallery>(request, 'pet.gallery', { localOnly: true }),
|
||||
petRpc<PetInfo>(request, 'pet.info')
|
||||
])
|
||||
|
||||
if (next) {
|
||||
$petGallery.set(next)
|
||||
if (local) {
|
||||
$petGallery.set(local)
|
||||
$petGalleryStatus.set('ready')
|
||||
$petGalleryError.set(null)
|
||||
localOk = true
|
||||
}
|
||||
|
||||
if (info) {
|
||||
|
|
@ -142,6 +154,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
|
|||
} finally {
|
||||
galleryLoad = null
|
||||
}
|
||||
|
||||
// Phase 2: merge in the full petdex catalog in the background. A slow/failed
|
||||
// manifest fetch never hides the local pets shown in phase 1.
|
||||
if (localOk) {
|
||||
try {
|
||||
const full = await petRpc<PetGallery>(request, 'pet.gallery')
|
||||
|
||||
if (full) {
|
||||
$petGallery.set(full)
|
||||
$petGalleryStatus.set('ready')
|
||||
}
|
||||
} catch {
|
||||
// Keep the local-only gallery; the petdex catalog just stays unmerged.
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return galleryLoad
|
||||
|
|
@ -161,6 +188,24 @@ async function syncInfo(request: GatewayRequest): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect a just-adopted *local* pet without any network: optimistically mark it
|
||||
* active/installed in the cached gallery and repaint the live mascot via the
|
||||
* local `pet.info`. Adopting a generated pet is a disk+config op — it must never
|
||||
* wait on `pet.gallery`'s remote petdex manifest fetch.
|
||||
*/
|
||||
export async function applyAdoptedPet(request: GatewayRequest, slug: string, displayName: string): Promise<void> {
|
||||
patchGallery(gallery => ({
|
||||
...gallery,
|
||||
enabled: true,
|
||||
active: slug,
|
||||
pets: gallery.pets.some(p => p.slug === slug)
|
||||
? gallery.pets.map(p => (p.slug === slug ? { ...p, installed: true, displayName } : p))
|
||||
: [...gallery.pets, { slug, displayName, installed: true, spritesheetUrl: '' }]
|
||||
}))
|
||||
await syncInfo(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter (drop the internal `clawd*` pets + apply a search query) and rank the
|
||||
* gallery for a picker. Ranking has no popularity data, so it leans on the
|
||||
|
|
@ -175,8 +220,15 @@ export function rankedGalleryPets(gallery: PetGallery | null, query = ''): Galle
|
|||
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
// User-generated pets first, then the active pet, then installed, then curated.
|
||||
// Guard every term with a boolean — local-only pets omit curated/generated, and
|
||||
// `Number(undefined)` is NaN, which poisons the sort (it would sink those pets
|
||||
// below the render cap and hide them entirely).
|
||||
const rank = (p: GalleryPet) =>
|
||||
Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated)
|
||||
(p.generated ? 8 : 0) +
|
||||
(gallery.enabled && p.slug === gallery.active ? 4 : 0) +
|
||||
(p.installed ? 2 : 0) +
|
||||
(p.curated ? 1 : 0)
|
||||
|
||||
return gallery.pets
|
||||
.filter(
|
||||
|
|
@ -309,14 +361,111 @@ export function setPetScale(request: GatewayRequest, scale: number): void {
|
|||
}, 200)
|
||||
}
|
||||
|
||||
/** Export a pet as a `.zip` (pet.json + spritesheet) and save it via the browser. */
|
||||
export async function exportPet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
|
||||
$petBusy.set(slug)
|
||||
$petGalleryError.set(null)
|
||||
try {
|
||||
const res = await petRpc<{ ok: boolean; filename: string; zipBase64: string }>(request, 'pet.export', { slug })
|
||||
if (!res?.ok || !res.zipBase64) {
|
||||
throw new Error(fallback)
|
||||
}
|
||||
const bytes = Uint8Array.from(atob(res.zipBase64), c => c.charCodeAt(0))
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = res.filename || `${slug}.zip`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
return true
|
||||
} catch (e) {
|
||||
$petGalleryError.set(e instanceof Error ? e.message : fallback)
|
||||
return false
|
||||
} finally {
|
||||
$petBusy.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a pet — optimistic. The new name shows instantly (so the dialog can
|
||||
* close immediately); the RPC runs in the background and the backend also
|
||||
* realigns the slug/dir, so we reconcile the slug + thumb cache when it returns,
|
||||
* and roll the name back if it fails.
|
||||
*/
|
||||
export function renamePet(request: GatewayRequest, slug: string, name: string, fallback: string): Promise<boolean> {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
const prev = $petGallery.get()?.pets.find(p => p.slug === slug)?.displayName ?? ''
|
||||
|
||||
// Optimistic: paint the new name now (slug reconciles when the RPC returns).
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: trimmed } : p))
|
||||
}))
|
||||
$petGalleryError.set(null)
|
||||
|
||||
return (async () => {
|
||||
try {
|
||||
const res = await petRpc<{ ok: boolean; slug: string; displayName: string }>(request, 'pet.rename', {
|
||||
slug,
|
||||
name: trimmed
|
||||
})
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(fallback)
|
||||
}
|
||||
|
||||
const newSlug = res.slug || slug
|
||||
|
||||
if (newSlug !== slug) {
|
||||
thumbCache.delete(slug)
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
active: g.active === slug ? newSlug : g.active,
|
||||
pets: g.pets
|
||||
.filter(p => p.slug !== newSlug || p.slug === slug)
|
||||
.map(p => (p.slug === slug ? { ...p, slug: newSlug, displayName: res.displayName || trimmed } : p))
|
||||
}))
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
// Roll the optimistic name back so the list reflects on-disk truth.
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: prev } : p))
|
||||
}))
|
||||
$petGalleryError.set(e instanceof Error ? e.message : fallback)
|
||||
|
||||
return false
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/** Uninstall a pet; turns the mascot off if it was the active one. */
|
||||
export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
|
||||
return mutate(slug, fallback, request, async () => {
|
||||
await petRpc(request, 'pet.remove', { slug })
|
||||
// Evict the by-slug thumb cache so a reused slug doesn't render this pet's
|
||||
// stale thumbnail (the backend drops its disk thumb in parallel).
|
||||
thumbCache.delete(slug)
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
enabled: g.active === slug ? false : g.enabled,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p))
|
||||
active: g.active === slug ? '' : g.active,
|
||||
// Petdex pets can be reinstalled from the manifest, so we just mark them
|
||||
// uninstalled. Generated / local-only pets have no remote source — once
|
||||
// deleted they're gone, so drop them from the list entirely.
|
||||
pets: g.pets.flatMap(p => {
|
||||
if (p.slug !== slug) {
|
||||
return [p]
|
||||
}
|
||||
return p.generated || !p.spritesheetUrl ? [] : [{ ...p, installed: false }]
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
652
apps/desktop/src/store/pet-generate.ts
Normal file
652
apps/desktop/src/store/pet-generate.ts
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
import { applyAdoptedPet, type GatewayRequest } from '@/store/pet-gallery'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
/**
|
||||
* Feature store for the "generate a pet" flow (Cmd-K → Pets → Generate).
|
||||
*
|
||||
* Three backend steps, mirrored as state here:
|
||||
* - `pet.generate` produces N cheap base-look *drafts* keyed by a `token`.
|
||||
* - `pet.hatch` turns the chosen draft into a full animated pet — installed but
|
||||
* NOT active — and returns its renderer payload so we can preview all frames.
|
||||
* - the user then *adopts* (`pet.select`) or *discards* (`pet.remove`) it.
|
||||
*
|
||||
* The store owns the draft set, the selected variant, the hatched preview, and
|
||||
* the busy/error status so the page is a thin view. Retry == regenerate (new
|
||||
* token). Kept separate from `pet-gallery` because its lifecycle (ephemeral
|
||||
* drafts + an unadopted preview) is unrelated to the long-lived gallery cache.
|
||||
*/
|
||||
|
||||
// Generation is many grounded image calls — far longer than the default 30s RPC
|
||||
// timeout. Drafts fan out 4 base looks; hatch fans out ~8 animation rows. Even
|
||||
// parallelized, a cold provider call is slow, so we give these calls real
|
||||
// headroom (the bug was "request timed out: pet.generate" on the 30s default).
|
||||
const GENERATE_TIMEOUT_MS = 240_000
|
||||
const HATCH_TIMEOUT_MS = 420_000
|
||||
|
||||
// Filler words to drop when deriving a default name from a free-text prompt.
|
||||
const NAME_STOPWORDS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'and',
|
||||
'at',
|
||||
'by',
|
||||
'cute',
|
||||
'for',
|
||||
'from',
|
||||
'in',
|
||||
'of',
|
||||
'on',
|
||||
'style',
|
||||
'the',
|
||||
'to',
|
||||
'with'
|
||||
])
|
||||
|
||||
/**
|
||||
* Derive a short, friendly default name from a generation prompt. The prompt
|
||||
* (e.g. "2d dragon in the style of ragnarok online") is grounding text, not a
|
||||
* name — using it verbatim makes a terrible label + slug. We keep the first few
|
||||
* meaningful words, title-cased and capped, so a blank adopt still reads well.
|
||||
* The user can always override on the reveal screen or rename later.
|
||||
*/
|
||||
export function cleanPetName(prompt: string): string {
|
||||
const words = prompt
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
|
||||
const meaningful = words.filter(w => !NAME_STOPWORDS.has(w.toLowerCase()))
|
||||
const picked = (meaningful.length ? meaningful : words).slice(0, 3)
|
||||
|
||||
const name = picked
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
.slice(0, 28)
|
||||
.trim()
|
||||
|
||||
return name || 'Pet'
|
||||
}
|
||||
|
||||
export interface PetDraft {
|
||||
index: number
|
||||
/** Downscaled PNG data URI preview from the gateway. */
|
||||
dataUri: string
|
||||
}
|
||||
|
||||
export type PetGenStatus =
|
||||
| 'idle'
|
||||
| 'generating'
|
||||
| 'ready'
|
||||
| 'hatching'
|
||||
| 'preview'
|
||||
| 'adopting'
|
||||
| 'error'
|
||||
| 'stale'
|
||||
|
||||
/** Live hatch step for the egg screen — which row is being drawn, then compose/save. */
|
||||
export interface PetHatchStage {
|
||||
phase: 'row' | 'compose' | 'save'
|
||||
state?: string
|
||||
done?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export const $petGenStatus = atom<PetGenStatus>('idle')
|
||||
export const $petGenStage = atom<PetHatchStage | null>(null)
|
||||
export const $petGenError = atom<string | null>(null)
|
||||
|
||||
// Whether a reference-capable image backend is configured. `null` = not yet
|
||||
// probed (treat as available so the prompt shows optimistically); the overlay
|
||||
// re-probes on open and on return from settings.
|
||||
export const $petGenAvailable = atom<boolean | null>(null)
|
||||
|
||||
/** A reference-capable image backend the user can pick for generation. */
|
||||
export interface PetGenProvider {
|
||||
name: string
|
||||
label: string
|
||||
/** One-line speed/quality tradeoff note. */
|
||||
note: string
|
||||
/** Whether this is the backend's default pick (no override needed). */
|
||||
default: boolean
|
||||
}
|
||||
|
||||
const PROVIDER_KEY = 'hermes.desktop.petgen.provider'
|
||||
|
||||
/** Reference-capable providers available to pick (from `pet.generate.status`). */
|
||||
export const $petGenProviders = atom<PetGenProvider[]>([])
|
||||
/** The picked provider name; `''` means "use the backend default". Persisted. */
|
||||
export const $petGenProvider = atom(storedString(PROVIDER_KEY) ?? '')
|
||||
|
||||
/** Set (and persist) the pet-gen provider override. `''` clears it. */
|
||||
export function setPetGenProvider(name: string): void {
|
||||
$petGenProvider.set(name)
|
||||
persistString(PROVIDER_KEY, name || null)
|
||||
}
|
||||
|
||||
/** Probe whether generation is possible (a reference-capable backend exists). */
|
||||
export async function checkPetGenAvailable(request: GatewayRequest): Promise<void> {
|
||||
try {
|
||||
const res = await request<{ available: boolean; providers?: PetGenProvider[] }>('pet.generate.status')
|
||||
$petGenAvailable.set(Boolean(res?.available))
|
||||
const providers = res?.providers ?? []
|
||||
$petGenProviders.set(providers)
|
||||
// Drop a stale pick if that backend is no longer configured.
|
||||
const picked = $petGenProvider.get()
|
||||
|
||||
if (picked && !providers.some(p => p.name === picked)) {
|
||||
setPetGenProvider('')
|
||||
}
|
||||
} catch {
|
||||
// Unknown (old backend / transient) — don't gate the UI on a failed probe.
|
||||
$petGenAvailable.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the dedicated "Generate a pet" Pokédex overlay is open. */
|
||||
export const $petGenerateOpen = atom(false)
|
||||
|
||||
export function openPetGenerate(): void {
|
||||
// Resume an in-flight or finished-but-unadopted run (so a Stop-free close, or
|
||||
// a "done" notification click, lands back on the right step); only start on a
|
||||
// clean slate when nothing is going on.
|
||||
if ($petGenStatus.get() === 'idle') {
|
||||
resetPetGen()
|
||||
}
|
||||
|
||||
$petGenerateOpen.set(true)
|
||||
}
|
||||
|
||||
export function closePetGenerate(): void {
|
||||
$petGenerateOpen.set(false)
|
||||
}
|
||||
|
||||
export const $petGenToken = atom<string | null>(null)
|
||||
/** Prompt that produced the current draft token; hatch uses this for consistency. */
|
||||
export const $petGenPrompt = atom<string>('')
|
||||
export const $petGenDrafts = atom<PetDraft[]>([])
|
||||
export const $petGenSelected = atom<number | null>(null)
|
||||
/** The hatched-but-unadopted pet: its renderer payload, played in the preview. */
|
||||
export const $petGenPreview = atom<PetInfo | null>(null)
|
||||
|
||||
// Live composer inputs live in atoms (not component state) so closing the
|
||||
// overlay mid-flow — or letting it run in the background — and reopening (or
|
||||
// clicking the "done" notification) restores exactly what you had.
|
||||
export const $petGenInput = atom('')
|
||||
export const $petGenRefImage = atom<string | null>(null)
|
||||
export const $petGenRefName = atom('')
|
||||
|
||||
function isMissingMethod(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /method not found|-32601|unknown method|no such method/i.test(message)
|
||||
}
|
||||
|
||||
/** Clear all generation state (before a fresh run). */
|
||||
export function resetPetGen(): void {
|
||||
$petGenStatus.set('idle')
|
||||
$petGenStage.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenToken.set(null)
|
||||
$petGenPrompt.set('')
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenPreview.set(null)
|
||||
$petGenInput.set('')
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Close-time cleanup: if a pet is already hatched but not adopted, discard it so
|
||||
* abandoned previews do not accumulate on disk. In-flight generate/hatch runs
|
||||
* are intentionally left alone (background-resumable).
|
||||
*/
|
||||
export function cleanupPetGenOnClose(request: GatewayRequest): void {
|
||||
const status = $petGenStatus.get()
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if ((status === 'preview' || status === 'adopting') && preview?.slug) {
|
||||
void request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
resetPetGen()
|
||||
}
|
||||
}
|
||||
|
||||
// A finished background run (overlay closed) nudges the user back: an in-app
|
||||
// toast with a View action always, plus an OS notification when enabled and the
|
||||
// app is in the background. Clicking either reopens the overlay to its state.
|
||||
function notifyPetGenDone(title: string, message: string, kind: 'error' | 'success'): void {
|
||||
if ($petGenerateOpen.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind, title, message, action: { label: 'View', onClick: openPetGenerate } })
|
||||
dispatchNativeNotification({ kind: 'backgroundDone', title, body: message, sessionId: $activeSessionId.get() })
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
prompt: string
|
||||
style?: string
|
||||
count?: number
|
||||
/** Optional data-URL reference image — every draft is grounded on it. */
|
||||
referenceImage?: string
|
||||
}
|
||||
|
||||
// A Stop (or a fresh round) must invalidate the in-flight call. This primitive
|
||||
// pairs a monotonic run id with the current run's cancel fn; `begin` opens a
|
||||
// run, `isCurrent` gates stale callbacks/events, `arm` registers the aborter,
|
||||
// `stop` supersedes + fires it. Drives both the draft and hatch flows.
|
||||
interface Run {
|
||||
begin: () => number
|
||||
isCurrent: (id: number) => boolean
|
||||
arm: (cancel: () => void) => void
|
||||
stop: () => void
|
||||
disarmIf: (id: number) => void
|
||||
}
|
||||
|
||||
function cancelableRun(): Run {
|
||||
let id = 0
|
||||
let cancel: (() => void) | null = null
|
||||
|
||||
return {
|
||||
begin: () => (id += 1),
|
||||
isCurrent: n => n === id,
|
||||
arm: fn => {
|
||||
cancel = fn
|
||||
},
|
||||
stop: () => {
|
||||
id += 1
|
||||
cancel?.()
|
||||
cancel = null
|
||||
},
|
||||
disarmIf: n => {
|
||||
if (n === id) {
|
||||
cancel = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gen = cancelableRun()
|
||||
|
||||
/**
|
||||
* Stop the in-flight draft generation (real abort). If any drafts have already
|
||||
* streamed in, keep them and drop into the ready/picker state (no reason to wait
|
||||
* for all 4) — otherwise reset to idle.
|
||||
*/
|
||||
export function cancelGenerate(): void {
|
||||
gen.stop()
|
||||
$petGenError.set(null)
|
||||
|
||||
const drafts = $petGenDrafts.get()
|
||||
|
||||
if (drafts.length > 0) {
|
||||
if ($petGenSelected.get() === null) {
|
||||
$petGenSelected.set(drafts[0]?.index ?? 0)
|
||||
}
|
||||
|
||||
$petGenStatus.set('ready')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$petGenStatus.set('idle')
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenToken.set(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandon the current drafts and return to the prompt (step 1). Stops any
|
||||
* in-flight generation; keeps the prompt text so the user can tweak + retry.
|
||||
*/
|
||||
export function discardDrafts(): void {
|
||||
gen.stop()
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenToken.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set('idle')
|
||||
}
|
||||
|
||||
const hatch = cancelableRun()
|
||||
|
||||
// A Stop invalidates the in-flight hatch and drops back to the draft picker (the
|
||||
// server still finishes, so we delete the pet it created).
|
||||
/** Stop the in-flight hatch and return to the draft picker. */
|
||||
export function cancelHatch(): void {
|
||||
hatch.stop()
|
||||
$petGenStage.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
|
||||
}
|
||||
|
||||
/** Generate (or retry) a fresh set of base-look drafts for `prompt`. */
|
||||
export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise<boolean> {
|
||||
const prompt = options.prompt.trim()
|
||||
const referenceImage = options.referenceImage
|
||||
|
||||
// Need *something* to ground on: a description or a reference image.
|
||||
if (!prompt && !referenceImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
const runId = gen.begin()
|
||||
const controller = new AbortController()
|
||||
gen.arm(() => {
|
||||
controller.abort()
|
||||
const token = $petGenToken.get()
|
||||
|
||||
if (token) {
|
||||
void request('pet.cancel', { token }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// Starting a fresh generation round supersedes any unadopted preview pet.
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (preview?.slug) {
|
||||
await request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
$petGenStatus.set('generating')
|
||||
$petGenError.set(null)
|
||||
$petGenPreview.set(null)
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
|
||||
// Stream drafts in as the backend finishes each one (pet.generate.progress),
|
||||
// so the grid fills live instead of sitting on placeholders until all N land.
|
||||
const off =
|
||||
$gateway.get()?.on<PetDraft & { token: string; count: number }>('pet.generate.progress', event => {
|
||||
const draft = event.payload
|
||||
|
||||
// Token-only init event (no draft yet): learn the token immediately so an
|
||||
// early Stop can still tell the backend to cancel this run.
|
||||
if (draft?.token && !draft.dataUri) {
|
||||
if (gen.isCurrent(runId) && $petGenStatus.get() === 'generating') {
|
||||
$petGenToken.set(draft.token)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!draft?.dataUri || typeof draft.index !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore events from a superseded/stopped run, and only stream while live.
|
||||
if (!gen.isCurrent(runId) || $petGenStatus.get() !== 'generating') {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the token from the stream so a Stop can still hatch the partial set.
|
||||
if (draft.token) {
|
||||
$petGenToken.set(draft.token)
|
||||
}
|
||||
|
||||
const current = $petGenDrafts.get()
|
||||
|
||||
if (current.some(d => d.index === draft.index)) {
|
||||
return
|
||||
}
|
||||
|
||||
$petGenDrafts.set(
|
||||
[...current, { index: draft.index, dataUri: draft.dataUri }].sort((a, b) => a.index - b.index)
|
||||
)
|
||||
}) ?? (() => {})
|
||||
|
||||
try {
|
||||
const result = await request<{ ok: boolean; token: string; drafts: PetDraft[] }>(
|
||||
'pet.generate',
|
||||
{
|
||||
prompt,
|
||||
style: options.style ?? 'auto',
|
||||
count: options.count ?? 4,
|
||||
...(referenceImage ? { referenceImage } : {}),
|
||||
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
|
||||
},
|
||||
GENERATE_TIMEOUT_MS,
|
||||
controller.signal
|
||||
)
|
||||
|
||||
// Stopped (or superseded by a newer round) while the RPC was in flight.
|
||||
if (!gen.isCurrent(runId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!result?.ok || !result.drafts?.length) {
|
||||
throw new Error('generation produced no drafts')
|
||||
}
|
||||
|
||||
$petGenToken.set(result.token)
|
||||
// Keep a concept for the hatch row prompts even on an image-only generate.
|
||||
$petGenPrompt.set(prompt || 'a custom pet')
|
||||
$petGenDrafts.set(result.drafts)
|
||||
$petGenSelected.set(result.drafts[0]?.index ?? 0)
|
||||
$petGenStatus.set('ready')
|
||||
notifyPetGenDone('Pet drafts ready', 'Your pet looks finished — pick one to hatch.', 'success')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (!gen.isCurrent(runId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isMissingMethod(e)) {
|
||||
$petGenStatus.set('stale')
|
||||
} else {
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not generate pet drafts.')
|
||||
notifyPetGenDone('Pet generation failed', 'Reopen to try again.', 'error')
|
||||
}
|
||||
|
||||
return false
|
||||
} finally {
|
||||
off()
|
||||
gen.disarmIf(runId)
|
||||
}
|
||||
}
|
||||
|
||||
interface HatchOptions {
|
||||
name: string
|
||||
description?: string
|
||||
prompt?: string
|
||||
style?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hatch the selected draft into a full pet (installed but NOT yet active) and
|
||||
* load its renderer payload into the preview. Adoption is a separate, explicit
|
||||
* step (`adoptHatched`) so the user sees every frame play before committing.
|
||||
* Returns true when the preview is ready.
|
||||
*/
|
||||
export async function hatchSelected(request: GatewayRequest, options: HatchOptions): Promise<boolean> {
|
||||
const token = $petGenToken.get()
|
||||
const index = $petGenSelected.get()
|
||||
const name = options.name.trim()
|
||||
const concept = ($petGenPrompt.get() || options.prompt || name).trim()
|
||||
|
||||
if (token === null || index === null || !name) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hatch cancellation rides its own token (not the draft token): hatching
|
||||
// mid-generation leaves pet.generate releasing that token, which would race
|
||||
// the arm. The draft token still locates the staged image server-side.
|
||||
const cancelToken = crypto.randomUUID()
|
||||
const hatchRunId = hatch.begin()
|
||||
const controller = new AbortController()
|
||||
hatch.arm(() => {
|
||||
controller.abort()
|
||||
void request('pet.cancel', { token: cancelToken }).catch(() => {})
|
||||
})
|
||||
|
||||
$petGenStatus.set('hatching')
|
||||
$petGenStage.set(null)
|
||||
$petGenError.set(null)
|
||||
|
||||
// Stream the hatch steps (which row is drawing, then compose/save) to the egg
|
||||
// screen so a multi-minute hatch shows live progress instead of a black box.
|
||||
const offProgress =
|
||||
$gateway
|
||||
.get()
|
||||
?.on<{ event: string; state?: string; done?: string; total?: string }>('pet.hatch.progress', event => {
|
||||
const p = event.payload
|
||||
|
||||
if (!p || !hatch.isCurrent(hatchRunId) || $petGenStatus.get() !== 'hatching') {
|
||||
return
|
||||
}
|
||||
|
||||
if (p.event === 'row' && p.state) {
|
||||
$petGenStage.set({
|
||||
phase: 'row',
|
||||
state: p.state,
|
||||
done: Number(p.done) || undefined,
|
||||
total: Number(p.total) || undefined
|
||||
})
|
||||
} else if (p.event === 'compose') {
|
||||
$petGenStage.set({ phase: 'compose' })
|
||||
} else if (p.event === 'save') {
|
||||
$petGenStage.set({ phase: 'save' })
|
||||
}
|
||||
}) ?? (() => {})
|
||||
|
||||
try {
|
||||
const result = await request<{ ok: boolean; slug: string; displayName: string; pet?: PetInfo }>(
|
||||
'pet.hatch',
|
||||
{
|
||||
token,
|
||||
cancelToken,
|
||||
index,
|
||||
name,
|
||||
description: options.description ?? '',
|
||||
prompt: concept,
|
||||
style: options.style ?? 'auto',
|
||||
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
|
||||
},
|
||||
HATCH_TIMEOUT_MS,
|
||||
controller.signal
|
||||
)
|
||||
|
||||
// Stopped mid-hatch: the server created the pet anyway, so delete it.
|
||||
if (!hatch.isCurrent(hatchRunId)) {
|
||||
if (result?.slug) {
|
||||
void request('pet.remove', { slug: result.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (!result?.ok || !result.pet?.spritesheetBase64) {
|
||||
throw new Error('hatch produced no preview')
|
||||
}
|
||||
|
||||
$petGenPreview.set({ ...result.pet, enabled: true })
|
||||
$petGenStatus.set('preview')
|
||||
notifyPetGenDone('Your pet hatched', 'Reopen to name and adopt it.', 'success')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (!hatch.isCurrent(hatchRunId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not hatch the pet.')
|
||||
notifyPetGenDone('Hatching failed', 'Reopen to try again.', 'error')
|
||||
|
||||
return false
|
||||
} finally {
|
||||
offProgress()
|
||||
|
||||
if (hatch.isCurrent(hatchRunId)) {
|
||||
$petGenStage.set(null)
|
||||
hatch.disarmIf(hatchRunId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdoptOutcome {
|
||||
ok: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the previewed pet: optionally rename it to the user's chosen name (set
|
||||
* on the reveal screen), activate it (`pet.select`), refresh the gallery + live
|
||||
* mascot, and clear generation state. No-op unless a preview exists.
|
||||
*/
|
||||
export async function adoptHatched(request: GatewayRequest, name?: string): Promise<AdoptOutcome> {
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (!preview?.slug) {
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
$petGenStatus.set('adopting')
|
||||
$petGenError.set(null)
|
||||
|
||||
try {
|
||||
// Name is collected after hatch, so apply it before activating. The rename
|
||||
// also realigns the slug to the chosen name (so lists show what the user
|
||||
// typed, not the prompt), so adopt the *returned* slug. Best-effort: a
|
||||
// rename failure shouldn't block adopting under the provisional slug.
|
||||
const finalName = name?.trim()
|
||||
let adoptSlug = preview.slug
|
||||
|
||||
if (finalName && finalName !== preview.displayName) {
|
||||
const renamed = await request<{ ok: boolean; slug: string }>('pet.rename', {
|
||||
slug: preview.slug,
|
||||
name: finalName
|
||||
}).catch(() => null)
|
||||
|
||||
if (renamed?.slug) {
|
||||
adoptSlug = renamed.slug
|
||||
}
|
||||
}
|
||||
|
||||
const result = await request<{ ok: boolean; slug: string; displayName: string }>('pet.select', {
|
||||
slug: adoptSlug
|
||||
})
|
||||
|
||||
if (!result?.ok) {
|
||||
throw new Error('adopt failed')
|
||||
}
|
||||
|
||||
// pet.select already set the active mascot (disk + config). Reflect it
|
||||
// locally — no remote petdex manifest fetch — and close immediately.
|
||||
resetPetGen()
|
||||
void applyAdoptedPet(request, result.slug, result.displayName)
|
||||
|
||||
return { ok: true, slug: result.slug, displayName: result.displayName }
|
||||
} catch (e) {
|
||||
$petGenStatus.set('preview')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not adopt the pet.')
|
||||
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw away the previewed pet (`pet.remove`) and return to the draft picker so
|
||||
* the user can choose another base or regenerate. Best-effort on the delete.
|
||||
*/
|
||||
export async function discardHatched(request: GatewayRequest): Promise<void> {
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (preview?.slug) {
|
||||
await request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
$petGenPreview.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ export interface PetInfo {
|
|||
displayName?: string
|
||||
mime?: string
|
||||
spritesheetBase64?: string
|
||||
// Stable sheet revision (`mtime_ns:size`) from the gateway; lets the desktop
|
||||
// skip full sprite payload refreshes when the active pet hasn't changed.
|
||||
spritesheetRevision?: string
|
||||
frameW?: number
|
||||
frameH?: number
|
||||
framesPerState?: number
|
||||
|
|
@ -27,6 +30,9 @@ export interface PetInfo {
|
|||
// canvas step only frames that exist instead of a fixed framesPerState, which
|
||||
// would animate into the transparent padding of ragged sheets (blank flash).
|
||||
framesByState?: Record<string, number>
|
||||
// Concrete Codex row counts (e.g. running-right may have 8 frames even though
|
||||
// the Hermes "run" activity state uses the in-place running row).
|
||||
framesByRow?: Record<string, number>
|
||||
loopMs?: number
|
||||
scale?: number
|
||||
stateRows?: string[]
|
||||
|
|
|
|||
|
|
@ -1426,3 +1426,254 @@ canvas {
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Pet egg hatch (Cmd-K → Pets → Generate) */
|
||||
/* The incubation wobble + reveal flash/pop give the draft→pet step a */
|
||||
/* Pokémon-style "egg is hatching" beat instead of a bare spinner. */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.pet-egg {
|
||||
position: relative;
|
||||
width: 5.5rem;
|
||||
height: 7rem;
|
||||
border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
|
||||
background:
|
||||
radial-gradient(120% 90% at 32% 26%, color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%, #f4ecd8 46%, #e4d3ad 100%);
|
||||
box-shadow:
|
||||
inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent),
|
||||
inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent),
|
||||
0 0.4rem 0.9rem color-mix(in srgb, #000 22%, transparent);
|
||||
transform-origin: 50% 88%;
|
||||
animation: pet-egg-wobble 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Compact egg (empty-state hero). Children are %-based so they track the size;
|
||||
only the rem box-shadow needs scaling down to stay crisp. */
|
||||
.pet-egg--sm {
|
||||
width: 3.25rem;
|
||||
height: 4.1rem;
|
||||
box-shadow:
|
||||
inset -0.28rem -0.38rem 0.7rem color-mix(in srgb, #000 16%, transparent),
|
||||
inset 0.22rem 0.26rem 0.45rem color-mix(in srgb, #fff 70%, transparent),
|
||||
0 0.25rem 0.55rem color-mix(in srgb, #000 22%, transparent);
|
||||
}
|
||||
|
||||
.pet-egg__shine {
|
||||
position: absolute;
|
||||
top: 14%;
|
||||
left: 22%;
|
||||
width: 28%;
|
||||
height: 22%;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, #fff 85%, transparent);
|
||||
filter: blur(2px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.pet-egg__spot {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ui-accent) 70%, #b89b63);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.pet-egg__glow {
|
||||
position: absolute;
|
||||
inset: -35%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--ui-accent) 55%, transparent) 0%, transparent 62%);
|
||||
animation: pet-egg-glow 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pet-egg-shadow {
|
||||
width: 4.5rem;
|
||||
height: 0.8rem;
|
||||
border-radius: 50%;
|
||||
/* Lighter on light backgrounds (~20% less ink); dark mode keeps it grounded. */
|
||||
background: radial-gradient(circle, color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%, transparent 72%);
|
||||
animation: pet-egg-shadow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .pet-egg-shadow {
|
||||
--pet-egg-shadow-ink: 32%;
|
||||
}
|
||||
|
||||
/* Contact shadow sized for the compact incubator egg (roughly its footprint). */
|
||||
.pet-egg-shadow--sm {
|
||||
width: 3rem;
|
||||
height: 0.6rem;
|
||||
}
|
||||
|
||||
/* Contact shadow under the revealed pet — mirrors the floating mascot's in-app
|
||||
shadow: an ellipse at the feet, ~55% of the sprite width, sitting behind it. */
|
||||
.pet-contact-shadow {
|
||||
position: absolute;
|
||||
bottom: -0.15rem;
|
||||
left: 50%;
|
||||
width: 55%;
|
||||
aspect-ratio: 100 / 28;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(ellipse at center, color-mix(in srgb, #000 42%, transparent) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Hatch wiggle for the pixel egg (rocks around its base). */
|
||||
.pet-wobble {
|
||||
transform-origin: 50% 85%;
|
||||
animation: pet-egg-wobble 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-wobble {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pet-egg-wobble {
|
||||
0%,
|
||||
62%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
8% {
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
16% {
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
24% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
32% {
|
||||
transform: rotate(4deg);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
/* the "almost out" burst */
|
||||
70% {
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
76% {
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
82% {
|
||||
transform: rotate(-9deg);
|
||||
}
|
||||
88% {
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
94% {
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pet-egg-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
84% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pet-egg-shadow {
|
||||
0%,
|
||||
62%,
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
76% {
|
||||
transform: scaleX(0.8);
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.pet-reveal {
|
||||
animation: pet-reveal-pop 620ms cubic-bezier(0.22, 1.4, 0.4, 1) both;
|
||||
}
|
||||
|
||||
@keyframes pet-reveal-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.35) translateY(0.4rem);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.12) translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-egg,
|
||||
.pet-egg__glow,
|
||||
.pet-egg-shadow,
|
||||
.pet-reveal {
|
||||
animation: none;
|
||||
}
|
||||
.pet-reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pet generation progress bar — determinate (hatch rows: done/total) or */
|
||||
/* indeterminate (drafts, which return together so a % would just snap). */
|
||||
.pet-progress {
|
||||
position: relative;
|
||||
height: 0.25rem;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--ui-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pet-progress__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: var(--ui-accent);
|
||||
transition: width 320ms ease;
|
||||
}
|
||||
|
||||
.pet-progress__indeterminate {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 40%;
|
||||
border-radius: 9999px;
|
||||
background: var(--ui-accent);
|
||||
animation: pet-progress-slide 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pet-progress-slide {
|
||||
0% {
|
||||
left: -42%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-progress__indeterminate {
|
||||
animation: none;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,29 +217,67 @@ export class JsonRpcGatewayClient {
|
|||
return () => this.stateHandlers.delete(handler)
|
||||
}
|
||||
|
||||
request<T>(method: string, params: Record<string, unknown> = {}, timeoutMs = this.options.requestTimeoutMs): Promise<T> {
|
||||
request<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = this.options.requestTimeoutMs,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const socket = this.socket
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error(this.options.notConnectedErrorMessage))
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(new DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
|
||||
const id = this.options.createRequestId(++this.nextId)
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let onAbort: (() => void) | undefined
|
||||
const detach = () => {
|
||||
if (onAbort && signal) {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
const pending: PendingCall = {
|
||||
reject,
|
||||
resolve: value => resolve(value as T)
|
||||
resolve: value => {
|
||||
detach()
|
||||
resolve(value as T)
|
||||
},
|
||||
reject: error => {
|
||||
detach()
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
pending.timer = setTimeout(() => {
|
||||
if (this.pending.delete(id)) {
|
||||
detach()
|
||||
reject(new Error(`request timed out: ${method}`))
|
||||
}
|
||||
}, timeoutMs)
|
||||
}
|
||||
|
||||
// Abort drops the pending call immediately (no dangling resolver/timer);
|
||||
// server-side cancellation is a separate cooperative RPC where it matters.
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
const call = this.pending.get(id)
|
||||
if (call?.timer) {
|
||||
clearTimeout(call.timer)
|
||||
}
|
||||
this.pending.delete(id)
|
||||
detach()
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
this.pending.set(id, pending)
|
||||
|
||||
try {
|
||||
|
|
@ -253,6 +291,7 @@ export class JsonRpcGatewayClient {
|
|||
)
|
||||
} catch (error) {
|
||||
this.clearPending(id)
|
||||
detach()
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
3
cli.py
3
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'):
|
||||
|
|
|
|||
|
|
@ -1051,6 +1051,74 @@ class CLICommandsMixin:
|
|||
_set_active(arg)
|
||||
print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.")
|
||||
|
||||
def _handle_hatch_command(self, cmd: str):
|
||||
"""Generate ("hatch") a brand-new petdex pet from a description.
|
||||
|
||||
``/hatch <description>`` runs the full pet pipeline in-process: a base
|
||||
look, then one grounded animation row per state, sliced + normalized into
|
||||
a spritesheet, then adopted as the active mascot. Progress streams inline
|
||||
(it's ~a minute of image-model calls). In the desktop app this command
|
||||
opens the richer generate overlay instead; here we run it directly.
|
||||
"""
|
||||
from agent.pet import store
|
||||
from agent.pet.generate import orchestrate
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
from hermes_cli.pets import _set_active
|
||||
|
||||
parts = cmd.split(maxsplit=1)
|
||||
concept = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if not concept:
|
||||
try:
|
||||
concept = input("(o_o) Describe your pet: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return
|
||||
|
||||
if not concept:
|
||||
print("(o_o) Usage: /hatch <description> (e.g. /hatch a tiny cyber fox)")
|
||||
return
|
||||
|
||||
# A short, friendly display name from the first few words of the concept.
|
||||
display_name = " ".join(w.capitalize() for w in concept.split()[:3])[:28].strip() or "Pet"
|
||||
slug = store.slugify(display_name) or store.slugify(concept) or "pet"
|
||||
|
||||
print(f"(o_o) Designing '{concept}'… (a minute of image-model calls)")
|
||||
try:
|
||||
drafts = orchestrate.generate_base_drafts(concept, n=1)
|
||||
except GenerationError as exc:
|
||||
print(f"(x_x) Couldn't generate a base look: {exc}")
|
||||
return
|
||||
|
||||
if not drafts:
|
||||
print("(x_x) No base draft came back — try again.")
|
||||
return
|
||||
|
||||
def _progress(event: str, detail: str) -> None:
|
||||
if event == "row":
|
||||
# detail is "<state>:<done>:<total>"; show the state name.
|
||||
state = detail.split(":", 1)[0]
|
||||
print(f" ┊ drawing {state}…")
|
||||
elif event == "compose":
|
||||
print(" ┊ composing spritesheet…")
|
||||
elif event == "save":
|
||||
print(" ┊ saving…")
|
||||
|
||||
try:
|
||||
result = orchestrate.hatch_pet(
|
||||
base_image=drafts[0],
|
||||
slug=slug,
|
||||
display_name=display_name,
|
||||
concept=concept,
|
||||
on_progress=_progress,
|
||||
)
|
||||
except GenerationError as exc:
|
||||
print(f"(x_x) Hatch failed: {exc}")
|
||||
return
|
||||
|
||||
_set_active(result.slug)
|
||||
print(f"(^_^)b {result.display_name} hatched and adopted — it'll pop in shortly!")
|
||||
|
||||
def _handle_cron_command(self, cmd: str):
|
||||
"""Handle the /cron command to manage scheduled tasks."""
|
||||
from cli import get_job
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
"Tools & Skills"),
|
||||
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
|
||||
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
|
||||
CommandDef("hatch", "Generate a new petdex pet from a description",
|
||||
"Tools & Skills", cli_only=True, aliases=("generate-pet",), args_hint="[description]"),
|
||||
CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)",
|
||||
"Tools & Skills", args_hint="<what to learn from>"),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
|
|
|
|||
414
plugins/image_gen/openrouter/__init__.py
Normal file
414
plugins/image_gen/openrouter/__init__.py
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
"""OpenRouter-compatible image generation backend (OpenRouter + Nous Portal).
|
||||
|
||||
Both OpenRouter and the Nous Portal inference endpoint speak the same
|
||||
OpenAI-style ``/chat/completions`` image-generation protocol: send
|
||||
``modalities: ["image", "text"]`` with an image-output model (e.g.
|
||||
``google/gemini-2.5-flash-image``), pass reference images as ``image_url``
|
||||
content parts for grounding, and read the generated images back from
|
||||
``choices[0].message.images[].image_url.url`` (a ``data:image/...;base64`` URI).
|
||||
|
||||
Nous Portal proxies OpenRouter, so one implementation services both — we only
|
||||
swap the resolved ``(base_url, api_key)``. Credentials are resolved through the
|
||||
agent's existing :func:`~hermes_cli.runtime_provider.resolve_runtime_provider`,
|
||||
which already understands OpenRouter's key pool and the Nous OAuth device-code
|
||||
token, so this plugin never reinvents auth.
|
||||
|
||||
Reference grounding is the reason pet sprite generation cares about this
|
||||
backend: each animation row must stay the same character as the chosen base
|
||||
frame, which only works on models that accept image input. Gemini Flash Image
|
||||
("nano-banana") does, so both providers advertise image-to-image support.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default image-output model. Gemini 2.5 Flash Image ("nano-banana") is GA on
|
||||
# OpenRouter, accepts reference images for grounding, and honors
|
||||
# ``image_config.aspect_ratio``.
|
||||
DEFAULT_MODEL = "google/gemini-2.5-flash-image"
|
||||
|
||||
# Semantic aspect ratio (the image_gen contract) → OpenRouter's image_config
|
||||
# aspect_ratio strings.
|
||||
_ASPECT_RATIOS = {
|
||||
"square": "1:1",
|
||||
"landscape": "16:9",
|
||||
"portrait": "9:16",
|
||||
}
|
||||
|
||||
# Gemini Flash Image accepts up to 3 input images per prompt; clamp references
|
||||
# so we never overflow the model's limit.
|
||||
_MAX_REFERENCE_IMAGES = 3
|
||||
|
||||
_REQUEST_TIMEOUT = 180.0
|
||||
|
||||
|
||||
def _load_image_gen_config() -> Dict[str, Any]:
|
||||
"""Read the ``image_gen`` section from config.yaml (``{}`` on failure)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
except Exception as exc: # noqa: BLE001 - config is best-effort
|
||||
logger.debug("could not load image_gen config: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _to_image_url_part(ref: str) -> Optional[str]:
|
||||
"""Turn a reference (local path or http URL) into an ``image_url`` value.
|
||||
|
||||
Remote URLs pass through unchanged; local files are inlined as base64 data
|
||||
URIs so the request is self-contained (the provider endpoint can't reach a
|
||||
path on our disk). Returns ``None`` when the reference can't be read.
|
||||
"""
|
||||
ref = str(ref or "").strip()
|
||||
if not ref:
|
||||
return None
|
||||
if ref.startswith(("http://", "https://", "data:")):
|
||||
return ref
|
||||
path = Path(ref)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
logger.debug("could not read reference image %s: %s", ref, exc)
|
||||
return None
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/png"
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def _extract_images(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Pull generated image URLs from a chat-completions response.
|
||||
|
||||
OpenRouter returns generated images under
|
||||
``choices[0].message.images[].image_url.url`` (typically a base64 data URI).
|
||||
"""
|
||||
out: List[str] = []
|
||||
choices = payload.get("choices") if isinstance(payload, dict) else None
|
||||
if not isinstance(choices, list):
|
||||
return out
|
||||
for choice in choices:
|
||||
message = choice.get("message") if isinstance(choice, dict) else None
|
||||
images = message.get("images") if isinstance(message, dict) else None
|
||||
if not isinstance(images, list):
|
||||
continue
|
||||
for image in images:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
image_url = image.get("image_url")
|
||||
url = image_url.get("url") if isinstance(image_url, dict) else None
|
||||
if isinstance(url, str) and url.strip():
|
||||
out.append(url.strip())
|
||||
return out
|
||||
|
||||
|
||||
class OpenRouterCompatImageProvider(ImageGenProvider):
|
||||
"""Image generation over an OpenRouter-compatible chat-completions endpoint.
|
||||
|
||||
Instantiated once per backend (OpenRouter, Nous Portal). The two differ only
|
||||
in which runtime provider supplies ``(base_url, api_key)`` and in the config
|
||||
namespace used for the model override.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider_name: str,
|
||||
display_name: str,
|
||||
runtime_name: str,
|
||||
config_key: str,
|
||||
model_env_var: str,
|
||||
setup_schema: Dict[str, Any],
|
||||
) -> None:
|
||||
self._name = provider_name
|
||||
self._display = display_name
|
||||
self._runtime_name = runtime_name
|
||||
self._config_key = config_key
|
||||
self._model_env_var = model_env_var
|
||||
self._setup_schema = setup_schema
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self._display
|
||||
|
||||
def _resolve_runtime(self) -> Dict[str, Any]:
|
||||
"""Resolve ``(base_url, api_key)`` via the shared runtime resolver."""
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
return resolve_runtime_provider(requested=self._runtime_name)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
runtime = self._resolve_runtime()
|
||||
except Exception as exc: # noqa: BLE001 - treat resolution failure as unavailable
|
||||
logger.debug("%s runtime resolution failed: %s", self._name, exc)
|
||||
return False
|
||||
return bool(str(runtime.get("api_key") or "").strip())
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# Both text-to-image and image-to-image (reference grounding) — the
|
||||
# latter is what makes this backend usable for pet sprite rows.
|
||||
return {
|
||||
"modalities": ["text", "image"],
|
||||
"max_reference_images": _MAX_REFERENCE_IMAGES,
|
||||
}
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": DEFAULT_MODEL,
|
||||
"display": "Gemini 2.5 Flash Image (nano-banana)",
|
||||
"strengths": "Reference-grounded edits; aspect-ratio control",
|
||||
}
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
return self._resolve_model()
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return dict(self._setup_schema)
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
"""Pick the image model: env override → config → :data:`DEFAULT_MODEL`."""
|
||||
env_override = os.environ.get(self._model_env_var, "").strip()
|
||||
if env_override:
|
||||
return env_override
|
||||
cfg = _load_image_gen_config()
|
||||
scoped = cfg.get(self._config_key) if isinstance(cfg.get(self._config_key), dict) else {}
|
||||
if isinstance(scoped, dict):
|
||||
value = scoped.get("model")
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return DEFAULT_MODEL
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
import requests
|
||||
|
||||
try:
|
||||
runtime = self._resolve_runtime()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return error_response(
|
||||
error=f"Could not resolve {self._display} credentials: {exc}",
|
||||
error_type="missing_api_key",
|
||||
provider=self._name,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
api_key = str(runtime.get("api_key") or "").strip()
|
||||
base_url = str(runtime.get("base_url") or "").strip().rstrip("/")
|
||||
if not api_key or not base_url:
|
||||
return error_response(
|
||||
error=(
|
||||
f"No {self._display} credentials found. "
|
||||
f"Configure {self._display} in `hermes tools` → Image Generation."
|
||||
),
|
||||
error_type="missing_api_key",
|
||||
provider=self._name,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
model_id = self._resolve_model()
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
or_aspect = _ASPECT_RATIOS.get(aspect, "1:1")
|
||||
|
||||
# Collect every reference: the pet generator passes local paths via the
|
||||
# ``reference_images`` kwarg; the generic tool surface uses ``image_url``
|
||||
# / ``reference_image_urls``. Accept all three.
|
||||
references: List[str] = []
|
||||
for ref in kwargs.get("reference_images") or []:
|
||||
references.append(str(ref))
|
||||
if image_url:
|
||||
references.append(str(image_url))
|
||||
for ref in reference_image_urls or []:
|
||||
references.append(str(ref))
|
||||
|
||||
content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for ref in references[:_MAX_REFERENCE_IMAGES]:
|
||||
part = _to_image_url_part(ref)
|
||||
if part:
|
||||
content.append({"type": "image_url", "image_url": {"url": part}})
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model_id,
|
||||
"modalities": ["image", "text"],
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"image_config": {"aspect_ratio": or_aspect},
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
# OpenRouter attribution headers (harmless against Nous Portal).
|
||||
"HTTP-Referer": "https://github.com/NousResearch/hermes-agent",
|
||||
"X-Title": "Hermes Agent",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
resp = exc.response
|
||||
status = resp.status_code if resp is not None else 0
|
||||
try:
|
||||
err_msg = resp.json().get("error", {}).get("message", resp.text[:300])
|
||||
except Exception: # noqa: BLE001
|
||||
err_msg = resp.text[:300] if resp is not None else str(exc)
|
||||
logger.error("%s image gen failed (%d): %s", self._name, status, err_msg)
|
||||
return error_response(
|
||||
error=f"{self._display} image generation failed ({status}): {err_msg}",
|
||||
error_type="api_error",
|
||||
provider=self._name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
except requests.Timeout:
|
||||
return error_response(
|
||||
error=f"{self._display} image generation timed out "
|
||||
f"({int(_REQUEST_TIMEOUT)}s)",
|
||||
error_type="timeout",
|
||||
provider=self._name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
except requests.ConnectionError as exc:
|
||||
return error_response(
|
||||
error=f"{self._display} connection error: {exc}",
|
||||
error_type="connection_error",
|
||||
provider=self._name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return error_response(
|
||||
error=f"{self._display} returned invalid JSON: {exc}",
|
||||
error_type="invalid_response",
|
||||
provider=self._name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
images = _extract_images(result)
|
||||
if not images:
|
||||
# A response with text but no image usually means the model didn't
|
||||
# honor image output (wrong model or modalities); surface that.
|
||||
return error_response(
|
||||
error=(
|
||||
f"{self._display} returned no image. Ensure the model "
|
||||
f"'{model_id}' supports image output."
|
||||
),
|
||||
error_type="empty_response",
|
||||
provider=self._name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
first = images[0]
|
||||
try:
|
||||
if first.startswith("data:"):
|
||||
b64 = first.split(",", 1)[1] if "," in first else ""
|
||||
saved_path = save_b64_image(b64, prefix=f"{self._name}_gen")
|
||||
else:
|
||||
saved_path = save_url_image(first, prefix=f"{self._name}_gen")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return error_response(
|
||||
error=f"Could not save generated image: {exc}",
|
||||
error_type="io_error",
|
||||
provider=self._name,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
return success_response(
|
||||
image=str(saved_path),
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider=self._name,
|
||||
)
|
||||
|
||||
|
||||
def _build_providers() -> List[OpenRouterCompatImageProvider]:
|
||||
return [
|
||||
OpenRouterCompatImageProvider(
|
||||
provider_name="openrouter",
|
||||
display_name="OpenRouter",
|
||||
runtime_name="openrouter",
|
||||
config_key="openrouter",
|
||||
model_env_var="OPENROUTER_IMAGE_MODEL",
|
||||
setup_schema={
|
||||
"name": "OpenRouter (image)",
|
||||
"badge": "paid",
|
||||
"tag": "Gemini Flash Image & more via OpenRouter; uses OPENROUTER_API_KEY",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "OPENROUTER_API_KEY",
|
||||
"prompt": "OpenRouter API key",
|
||||
"url": "https://openrouter.ai/keys",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
OpenRouterCompatImageProvider(
|
||||
provider_name="nous",
|
||||
display_name="Nous Portal",
|
||||
runtime_name="nous",
|
||||
config_key="nous",
|
||||
model_env_var="NOUS_IMAGE_MODEL",
|
||||
setup_schema={
|
||||
"name": "Nous Portal (image)",
|
||||
"badge": "subscription",
|
||||
"tag": "Reference-grounded image generation via Nous Portal (OpenRouter-backed)",
|
||||
"env_vars": [],
|
||||
"requires_nous_auth": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register(ctx: Any) -> None:
|
||||
"""Register the OpenRouter + Nous Portal image gen providers."""
|
||||
for provider in _build_providers():
|
||||
ctx.register_image_gen_provider(provider)
|
||||
7
plugins/image_gen/openrouter/plugin.yaml
Normal file
7
plugins/image_gen/openrouter/plugin.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
name: openrouter
|
||||
version: 1.0.0
|
||||
description: "OpenRouter + Nous Portal image generation (chat-completions image output; reference-grounded). Text-to-image and image-to-image."
|
||||
author: Hermes Agent
|
||||
kind: backend
|
||||
requires_env:
|
||||
- OPENROUTER_API_KEY
|
||||
493
tests/agent/test_pet_generate.py
Normal file
493
tests/agent/test_pet_generate.py
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
"""Tests for pet generation: deterministic atlas ops, store register, orchestration.
|
||||
|
||||
No network/API calls — image generation is mocked with synthetic strips so the
|
||||
whole pipeline (segmentation → compose → validate → register → adopt) is
|
||||
exercised hermetically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.pet.generate import atlas
|
||||
|
||||
PIL = pytest.importorskip("PIL")
|
||||
from PIL import Image, ImageDraw # noqa: E402
|
||||
|
||||
|
||||
def _strip(n_blobs: int, *, transparent: bool = True, bg=(0, 255, 0, 255), size=(208, 208)) -> Image.Image:
|
||||
"""A horizontal strip with *n_blobs* clearly-separated colored ellipses."""
|
||||
w = size[0] * n_blobs
|
||||
h = size[1]
|
||||
base = (0, 0, 0, 0) if transparent else bg
|
||||
img = Image.new("RGBA", (w, h), base)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for i in range(n_blobs):
|
||||
cx = i * size[0] + size[0] // 2
|
||||
cy = h // 2
|
||||
r = size[0] // 3
|
||||
color = (40 + i * 30 % 200, 80, 200 - i * 20 % 180, 255)
|
||||
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
|
||||
return img
|
||||
|
||||
|
||||
# ───────────────────────── frame extraction ─────────────────────────
|
||||
|
||||
|
||||
def test_extract_strip_frames_transparent_returns_centered_cells():
|
||||
frames = atlas.extract_strip_frames(_strip(6), 6)
|
||||
assert len(frames) == 6
|
||||
for frame in frames:
|
||||
assert frame.size == (atlas.CELL_WIDTH, atlas.CELL_HEIGHT)
|
||||
# Background corners must be transparent.
|
||||
assert frame.getpixel((0, 0))[3] == 0
|
||||
# Something is drawn.
|
||||
assert frame.getchannel("A").getextrema()[1] > 0
|
||||
|
||||
|
||||
def test_extract_strip_frames_keys_out_solid_background():
|
||||
frames = atlas.extract_strip_frames(_strip(4, transparent=False), 4)
|
||||
assert len(frames) == 4
|
||||
# The green backdrop must be gone (corner transparent).
|
||||
assert frames[0].getpixel((0, 0))[3] == 0
|
||||
|
||||
|
||||
def test_remove_background_clears_trapped_chroma_pocket():
|
||||
# Green body enclosing a magenta pocket (the "pink between the arm" case):
|
||||
# the pocket isn't border-reachable, so it must be cleared by interior seeding.
|
||||
img = Image.new("RGBA", (200, 200), (255, 0, 255, 255)) # magenta backdrop
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse((40, 40, 160, 160), fill=(40, 200, 60, 255)) # body
|
||||
draw.ellipse((85, 85, 115, 115), fill=(255, 0, 255, 255)) # trapped pocket
|
||||
keyed = atlas.remove_background(img)
|
||||
assert keyed.getpixel((100, 100))[3] == 0 # pocket cleared
|
||||
assert keyed.getpixel((100, 50))[3] > 0 # body still opaque
|
||||
assert keyed.getpixel((2, 2))[3] == 0 # border cleared
|
||||
|
||||
|
||||
def test_extract_strip_frames_repairs_provider_alpha_holes():
|
||||
img = _strip(1)
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx = img.width // 2
|
||||
cy = img.height // 2
|
||||
draw.ellipse((cx - 16, cy - 16, cx + 16, cy + 16), fill=(0, 0, 0, 0))
|
||||
|
||||
frames = atlas.extract_strip_frames(img, 1, method="components")
|
||||
assert frames[0].getpixel((atlas.CELL_WIDTH // 2, atlas.CELL_HEIGHT // 2))[3] > 0
|
||||
|
||||
|
||||
def test_extract_strip_frames_severs_thin_bridges_between_frames():
|
||||
# AI strips often connect poses with a 1px shadow/glow bridge. Strict
|
||||
# component extraction must still find each frame instead of treating the row
|
||||
# as one merged subject.
|
||||
img = _strip(4)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.line((20, img.height // 2, img.width - 20, img.height // 2), fill=(255, 255, 255, 255), width=1)
|
||||
|
||||
frames = atlas.extract_strip_frames(img, 4, method="components")
|
||||
assert len(frames) == 4
|
||||
assert all(frame.getchannel("A").getextrema()[1] > 0 for frame in frames)
|
||||
|
||||
|
||||
def test_extract_strip_frames_drops_small_side_lobes_from_adjacent_frames():
|
||||
# Frogger regression: a real pose plus a small separated side lobe from a
|
||||
# neighbouring pose. The side lobe should not survive into the fitted cell.
|
||||
img = Image.new("RGBA", (atlas.CELL_WIDTH, atlas.CELL_HEIGHT), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse((52, 34, 150, 188), fill=(70, 190, 70, 255))
|
||||
draw.rectangle((4, 70, 24, 160), fill=(70, 190, 70, 255))
|
||||
draw.rectangle((168, 82, 186, 150), fill=(70, 190, 70, 255))
|
||||
|
||||
frame = atlas.extract_strip_frames(img, 1, method="components")[0]
|
||||
alpha = frame.getchannel("A")
|
||||
left_edge_mass = sum(1 for x in range(0, 36) for y in range(frame.height) if alpha.getpixel((x, y)) > 16)
|
||||
right_edge_mass = sum(1 for x in range(frame.width - 36, frame.width) for y in range(frame.height) if alpha.getpixel((x, y)) > 16)
|
||||
assert left_edge_mass == 0
|
||||
assert right_edge_mass == 0
|
||||
|
||||
|
||||
def test_extract_strip_frames_uses_real_gutters_when_spacing_is_uneven():
|
||||
# gpt-image often returns a square chroma strip whose poses are separated but
|
||||
# not laid out on exact equal-width slots. Equal slot slicing would include
|
||||
# the next pose's wing/cape in frame 0; gutter-derived crops keep it out.
|
||||
img = Image.new("RGBA", (600, 208), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle((40, 58, 140, 178), fill=(80, 120, 220, 255))
|
||||
draw.rectangle((182, 58, 282, 178), fill=(220, 120, 80, 255))
|
||||
draw.rectangle((430, 58, 530, 178), fill=(80, 220, 120, 255))
|
||||
|
||||
frames = atlas.extract_strip_frames(img, 3, method="auto", fit=False)
|
||||
|
||||
assert len(frames) == 3
|
||||
assert frames[0].getbbox()[2] <= 120
|
||||
assert frames[1].getbbox()[0] <= 16
|
||||
|
||||
|
||||
def test_extract_strip_frames_slot_fallback_when_unsegmentable():
|
||||
# A single connected smear can't be split into 5 components → slot fallback.
|
||||
img = Image.new("RGBA", (200 * 5, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(img).rectangle((0, 80, 200 * 5 - 1, 120), fill=(200, 50, 50, 255))
|
||||
frames = atlas.extract_strip_frames(img, 5, method="auto")
|
||||
assert len(frames) == 5
|
||||
|
||||
|
||||
def test_extract_components_method_raises_when_too_few():
|
||||
img = Image.new("RGBA", (400, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(img).ellipse((10, 10, 100, 100), fill=(255, 0, 0, 255))
|
||||
with pytest.raises(ValueError):
|
||||
atlas.extract_strip_frames(img, 6, method="components")
|
||||
|
||||
|
||||
# ───────────────────────── atlas compose / validate ─────────────────────────
|
||||
|
||||
|
||||
def _frames_for_all_states() -> dict[str, list]:
|
||||
out: dict[str, list] = {}
|
||||
for state, _row, count in atlas.ROW_SPECS:
|
||||
out[state] = atlas.extract_strip_frames(_strip(count), count)
|
||||
return out
|
||||
|
||||
|
||||
def test_compose_atlas_geometry_and_validation():
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
assert sheet.size == (atlas.ATLAS_WIDTH, atlas.ATLAS_HEIGHT)
|
||||
result = atlas.validate_atlas(sheet)
|
||||
assert result["ok"], result["errors"]
|
||||
assert set(result["filled_states"]) == {s for s, _, _ in atlas.ROW_SPECS}
|
||||
|
||||
|
||||
def test_compose_atlas_leaves_unused_tail_transparent():
|
||||
# waving has 4 frames; columns 4 and 5 of its row must be transparent.
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
wave_row = next(r for s, r, _ in atlas.ROW_SPECS if s == "waving")
|
||||
top = wave_row * atlas.CELL_HEIGHT
|
||||
for col in (4, 5):
|
||||
left = col * atlas.CELL_WIDTH
|
||||
cell = sheet.crop((left, top, left + atlas.CELL_WIDTH, top + atlas.CELL_HEIGHT))
|
||||
assert cell.getchannel("A").getextrema()[1] == 0
|
||||
|
||||
|
||||
def test_validate_atlas_rejects_wrong_size():
|
||||
bad = Image.new("RGBA", (100, 100), (0, 0, 0, 0))
|
||||
result = atlas.validate_atlas(bad)
|
||||
assert not result["ok"]
|
||||
assert any("expected" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_validate_atlas_rejects_rgb_residue():
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
# Poke a fully-transparent pixel with non-zero RGB.
|
||||
sheet.putpixel((0, 0), (120, 0, 0, 0))
|
||||
result = atlas.validate_atlas(sheet)
|
||||
assert not result["ok"]
|
||||
assert any("residue" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_validate_atlas_warns_on_empty_state():
|
||||
frames = _frames_for_all_states()
|
||||
frames["jumping"] = []
|
||||
sheet = atlas.compose_atlas(frames)
|
||||
result = atlas.validate_atlas(sheet)
|
||||
assert result["ok"] # one empty row is a warning, not an error
|
||||
assert any("jumping" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_single_frame_fits_cell():
|
||||
frame = atlas.single_frame(_strip(1))
|
||||
assert frame.size == (atlas.CELL_WIDTH, atlas.CELL_HEIGHT)
|
||||
assert frame.getchannel("A").getextrema()[1] > 0
|
||||
|
||||
|
||||
def test_normalize_cells_uses_consistent_pose_scale_for_motion_rows():
|
||||
# A jump row needs a taller union crop than idle, but the pet itself should
|
||||
# not shrink just because the motion envelope is taller.
|
||||
idle = Image.new("RGBA", (160, 180), (0, 0, 0, 0))
|
||||
jump_low = Image.new("RGBA", (160, 180), (0, 0, 0, 0))
|
||||
jump_high = Image.new("RGBA", (160, 180), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(idle).rectangle((50, 80, 110, 160), fill=(80, 120, 220, 255))
|
||||
ImageDraw.Draw(jump_low).rectangle((50, 80, 110, 160), fill=(220, 120, 80, 255))
|
||||
ImageDraw.Draw(jump_high).rectangle((50, 60, 110, 140), fill=(220, 120, 80, 255))
|
||||
|
||||
normalized = atlas.normalize_cells({"idle": [idle], "jumping": [jump_low, jump_high]})
|
||||
idle_box = normalized["idle"][0].getbbox()
|
||||
jump_box = normalized["jumping"][0].getbbox()
|
||||
|
||||
assert idle_box is not None
|
||||
assert jump_box is not None
|
||||
idle_h = idle_box[3] - idle_box[1]
|
||||
jump_h = jump_box[3] - jump_box[1]
|
||||
assert abs(idle_h - jump_h) <= 8
|
||||
|
||||
|
||||
# ───────────────────────── store register / adopt ─────────────────────────
|
||||
|
||||
|
||||
def test_slugify_and_unique_slug():
|
||||
from agent.pet import store
|
||||
|
||||
assert store.slugify("My Cool Pet!") == "my-cool-pet"
|
||||
assert store.slugify(" ") == "pet"
|
||||
first = store.unique_slug("Robo")
|
||||
(store.pets_dir() / first).mkdir(parents=True)
|
||||
assert store.unique_slug("Robo") == "robo-2"
|
||||
|
||||
|
||||
def test_register_local_pet_appears_and_is_adoptable():
|
||||
from agent.pet import store
|
||||
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
pet = store.register_local_pet(sheet, slug="Sparky", display_name="Sparky", description="zappy")
|
||||
assert pet.slug == "sparky"
|
||||
assert pet.exists
|
||||
assert any(p.slug == "sparky" for p in store.installed_pets())
|
||||
|
||||
# install_pet returns the on-disk pet without ever hitting the manifest.
|
||||
adopted = store.install_pet("sparky")
|
||||
assert adopted.slug == "sparky"
|
||||
assert adopted.display_name == "Sparky"
|
||||
|
||||
|
||||
def test_register_local_pet_is_generated_and_exports_zip():
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
store.register_local_pet(sheet, slug="zippy", display_name="Zippy")
|
||||
assert store.load_pet("zippy").generated is True # createdBy=generator
|
||||
|
||||
filename, data = store.export_pet("zippy")
|
||||
assert filename == "zippy.zip"
|
||||
names = zipfile.ZipFile(io.BytesIO(data)).namelist()
|
||||
assert "zippy/pet.json" in names
|
||||
assert any(n.startswith("zippy/spritesheet") for n in names)
|
||||
|
||||
|
||||
def test_export_pet_rejects_unknown_and_traversal():
|
||||
from agent.pet import store
|
||||
|
||||
with pytest.raises(store.PetStoreError):
|
||||
store.export_pet("does-not-exist")
|
||||
with pytest.raises(store.PetStoreError):
|
||||
store.export_pet("../secrets")
|
||||
|
||||
|
||||
def test_register_local_pet_accepts_bytes():
|
||||
from agent.pet import store
|
||||
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
data = atlas.atlas_to_webp_bytes(sheet)
|
||||
pet = store.register_local_pet(data, slug="bytey")
|
||||
assert pet.exists
|
||||
|
||||
|
||||
# ───────────────────────── orchestration (mocked imagegen) ─────────────────────────
|
||||
|
||||
|
||||
def test_generate_base_drafts_returns_n(monkeypatch, tmp_path):
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
|
||||
paths = []
|
||||
for i in range(n):
|
||||
calls["n"] += 1
|
||||
p = tmp_path / f"{prefix}_{calls['n']}.png"
|
||||
_strip(1).save(p)
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
drafts = orchestrate.generate_base_drafts("a fox", n=4)
|
||||
assert len(drafts) == 4
|
||||
|
||||
|
||||
def test_generate_base_drafts_hardens_opaque_background(monkeypatch, tmp_path):
|
||||
"""A provider that ignores background=transparent still yields a cutout."""
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
|
||||
# Solid-green backdrop with a blob — i.e. the provider painted a backdrop.
|
||||
p = tmp_path / f"{prefix}_opaque.png"
|
||||
_strip(1, transparent=False, bg=(0, 255, 0, 255)).save(p)
|
||||
return [p]
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
drafts = orchestrate.generate_base_drafts("a fox", n=1)
|
||||
assert len(drafts) == 1
|
||||
|
||||
with Image.open(drafts[0]) as out:
|
||||
rgba = out.convert("RGBA")
|
||||
# The keyed backdrop is now transparent (corner pixel fully see-through).
|
||||
assert rgba.getpixel((0, 0))[3] == 0
|
||||
# The pet blob in the center is still opaque.
|
||||
assert rgba.getpixel((rgba.width // 2, rgba.height // 2))[3] > 0
|
||||
|
||||
|
||||
def test_hatch_pet_end_to_end(monkeypatch, tmp_path):
|
||||
from agent.pet import store
|
||||
from agent.pet.generate import atlas as atlas_mod
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
|
||||
base = tmp_path / "base.png"
|
||||
_strip(1).save(base)
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
|
||||
# Return a synthetic row strip; frame count is inferable from the spec.
|
||||
state = prefix.replace("pet_row_", "")
|
||||
count = atlas_mod.FRAME_COUNTS.get(state, 6)
|
||||
p = tmp_path / f"{prefix}.png"
|
||||
_strip(count).save(p)
|
||||
return [p]
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
events: list[tuple[str, str]] = []
|
||||
result = orchestrate.hatch_pet(
|
||||
base_image=base,
|
||||
slug="mocky",
|
||||
display_name="Mocky",
|
||||
description="a test pet",
|
||||
concept="a fox",
|
||||
on_progress=lambda ev, detail: events.append((ev, detail)),
|
||||
)
|
||||
|
||||
assert result.slug == "mocky"
|
||||
assert result.validation["ok"]
|
||||
assert set(result.states) == {s for s, _, _ in atlas_mod.ROW_SPECS}
|
||||
assert ("compose", "") in events
|
||||
# The pet is on disk and adoptable.
|
||||
assert store.load_pet("mocky").exists
|
||||
|
||||
|
||||
def test_hatch_pet_idle_fallback_when_row_fails(monkeypatch, tmp_path):
|
||||
from agent.pet.generate import atlas as atlas_mod
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
|
||||
base = tmp_path / "base.png"
|
||||
_strip(1).save(base)
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
|
||||
if prefix == "pet_row_idle":
|
||||
raise GenerationError("boom")
|
||||
state = prefix.replace("pet_row_", "")
|
||||
count = atlas_mod.FRAME_COUNTS.get(state, 6)
|
||||
p = tmp_path / f"{prefix}.png"
|
||||
_strip(count).save(p)
|
||||
return [p]
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
result = orchestrate.hatch_pet(base_image=base, slug="fallbacky", concept="a fox")
|
||||
assert "idle" in result.states # filled by the base-image fallback
|
||||
|
||||
|
||||
def test_hatch_pet_rejects_missing_required_animation_rows(monkeypatch, tmp_path):
|
||||
from agent.pet.generate import atlas as atlas_mod
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
|
||||
base = tmp_path / "base.png"
|
||||
_strip(1).save(base)
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"):
|
||||
if prefix == "pet_row_running-right":
|
||||
raise GenerationError("bad row")
|
||||
state = prefix.replace("pet_row_", "")
|
||||
count = atlas_mod.FRAME_COUNTS.get(state, 6)
|
||||
p = tmp_path / f"{prefix}.png"
|
||||
_strip(count).save(p)
|
||||
return [p]
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
with pytest.raises(GenerationError, match="running-right"):
|
||||
orchestrate.hatch_pet(base_image=base, slug="broken", concept="a fox")
|
||||
|
||||
|
||||
def test_resolve_provider_errors_without_backend(monkeypatch):
|
||||
from agent.pet.generate import imagegen
|
||||
|
||||
monkeypatch.setattr(imagegen, "_discover", lambda: None)
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_active_provider", lambda: None)
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: None)
|
||||
|
||||
with pytest.raises(imagegen.GenerationError):
|
||||
imagegen.resolve_provider(require_references=True)
|
||||
|
||||
|
||||
class _FakeImgProvider:
|
||||
def __init__(self, name, available=True):
|
||||
self.name = name
|
||||
self._available = available
|
||||
|
||||
def is_available(self):
|
||||
return self._available
|
||||
|
||||
|
||||
def test_resolve_provider_honors_available_preference(monkeypatch):
|
||||
"""An explicit, configured, ref-capable preference wins over the active one."""
|
||||
from agent.pet.generate import imagegen
|
||||
|
||||
registry = {"openai": _FakeImgProvider("openai"), "openrouter": _FakeImgProvider("openrouter")}
|
||||
monkeypatch.setattr(imagegen, "_discover", lambda: None)
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_active_provider", lambda: registry["openai"])
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: registry.get(name))
|
||||
|
||||
assert imagegen.resolve_provider(prefer="openrouter").name == "openrouter"
|
||||
# An unavailable / unknown preference is ignored — fall back to the active one.
|
||||
registry["openrouter"]._available = False
|
||||
assert imagegen.resolve_provider(prefer="openrouter").name == "openai"
|
||||
assert imagegen.resolve_provider(prefer="not-a-provider").name == "openai"
|
||||
|
||||
|
||||
def test_list_sprite_providers_marks_default(monkeypatch):
|
||||
"""Lists only available ref-capable backends, flagging the default pick."""
|
||||
from agent.pet.generate import imagegen
|
||||
|
||||
registry = {"openai": _FakeImgProvider("openai"), "nous": _FakeImgProvider("nous")}
|
||||
monkeypatch.setattr(imagegen, "_discover", lambda: None)
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_active_provider", lambda: registry["openai"])
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: registry.get(name))
|
||||
|
||||
listed = imagegen.list_sprite_providers()
|
||||
names = {p["name"] for p in listed}
|
||||
assert names == {"openai", "nous"}
|
||||
# Every entry carries display metadata, and exactly one is the default.
|
||||
assert all(p["label"] and "note" in p for p in listed)
|
||||
assert [p["name"] for p in listed if p["default"]] == ["openai"]
|
||||
|
||||
|
||||
def test_generate_retries_without_transparent_background(monkeypatch, tmp_path):
|
||||
"""A model that rejects background=transparent still produces images."""
|
||||
from agent.pet.generate import imagegen
|
||||
|
||||
saved = tmp_path / "img.png"
|
||||
_strip(1).save(saved)
|
||||
calls: list[dict] = []
|
||||
|
||||
class FakeProvider:
|
||||
def generate(self, prompt, **kwargs):
|
||||
calls.append(kwargs)
|
||||
if kwargs.get("background") == "transparent":
|
||||
return {"success": False, "error": "Transparent background is not supported for this model."}
|
||||
return {"success": True, "image": str(saved)}
|
||||
|
||||
sprite = imagegen.SpriteProvider(name="openai", provider=FakeProvider(), supports_references=False)
|
||||
|
||||
out = imagegen.generate("a fox", n=2, provider=sprite)
|
||||
assert len(out) == 2
|
||||
# First variant probes transparent (rejected) then retries opaque; the second
|
||||
# variant skips the transparent probe entirely.
|
||||
backgrounds = [c.get("background") for c in calls]
|
||||
assert backgrounds == ["transparent", None, None]
|
||||
296
tests/plugins/image_gen/test_openrouter_compat_provider.py
Normal file
296
tests/plugins/image_gen/test_openrouter_compat_provider.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for the OpenRouter-compatible image gen provider (OpenRouter + Nous)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_RUNTIME = "hermes_cli.runtime_provider.resolve_runtime_provider"
|
||||
_PNG_DATA_URI = "data:image/png;base64,dGVzdC1pbWFnZS1kYXRh" # "test-image-data"
|
||||
|
||||
|
||||
def _runtime_ok(**over):
|
||||
base = {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "sk-or-test",
|
||||
"source": "env",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _mock_chat_response(images):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"images": [
|
||||
{"type": "image_url", "image_url": {"url": u}} for u in images
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _openrouter():
|
||||
from plugins.image_gen.openrouter import OpenRouterCompatImageProvider
|
||||
|
||||
return OpenRouterCompatImageProvider(
|
||||
provider_name="openrouter",
|
||||
display_name="OpenRouter",
|
||||
runtime_name="openrouter",
|
||||
config_key="openrouter",
|
||||
model_env_var="OPENROUTER_IMAGE_MODEL",
|
||||
setup_schema={"name": "OpenRouter (image)", "badge": "paid", "env_vars": []},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderClass:
|
||||
def test_names(self):
|
||||
from plugins.image_gen.openrouter import _build_providers
|
||||
|
||||
names = {p.name for p in _build_providers()}
|
||||
assert names == {"openrouter", "nous"}
|
||||
|
||||
def test_display_names(self):
|
||||
from plugins.image_gen.openrouter import _build_providers
|
||||
|
||||
by_name = {p.name: p for p in _build_providers()}
|
||||
assert by_name["openrouter"].display_name == "OpenRouter"
|
||||
assert by_name["nous"].display_name == "Nous Portal"
|
||||
|
||||
def test_capabilities_support_image_input(self):
|
||||
caps = _openrouter().capabilities()
|
||||
assert "image" in caps["modalities"]
|
||||
assert caps["max_reference_images"] >= 1
|
||||
|
||||
def test_is_available_with_key(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()):
|
||||
assert _openrouter().is_available() is True
|
||||
|
||||
def test_is_available_without_key(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
|
||||
assert _openrouter().is_available() is False
|
||||
|
||||
def test_is_available_on_resolution_error(self):
|
||||
with patch(_RUNTIME, side_effect=RuntimeError("boom")):
|
||||
assert _openrouter().is_available() is False
|
||||
|
||||
def test_default_model(self):
|
||||
from plugins.image_gen.openrouter import DEFAULT_MODEL
|
||||
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
|
||||
assert _openrouter().default_model() == DEFAULT_MODEL
|
||||
assert DEFAULT_MODEL == "google/gemini-2.5-flash-image"
|
||||
|
||||
def test_model_env_override(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "black-forest-labs/flux.2-pro")
|
||||
assert _openrouter()._resolve_model() == "black-forest-labs/flux.2-pro"
|
||||
|
||||
def test_model_config_override(self):
|
||||
cfg = {"openrouter": {"model": "google/gemini-3.1-flash-image-preview"}}
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
|
||||
assert _openrouter()._resolve_model() == "google/gemini-3.1-flash-image-preview"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_to_image_url_part_passthrough_url(self):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
assert _to_image_url_part("https://x/y.png") == "https://x/y.png"
|
||||
assert _to_image_url_part("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
|
||||
|
||||
def test_to_image_url_part_inlines_local_file(self, tmp_path):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
f = tmp_path / "base.png"
|
||||
f.write_bytes(b"\x89PNG\r\n")
|
||||
part = _to_image_url_part(str(f))
|
||||
assert part.startswith("data:image/png;base64,")
|
||||
decoded = base64.b64decode(part.split(",", 1)[1])
|
||||
assert decoded == b"\x89PNG\r\n"
|
||||
|
||||
def test_to_image_url_part_missing_file(self):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
assert _to_image_url_part("/no/such/file.png") is None
|
||||
|
||||
def test_extract_images(self):
|
||||
from plugins.image_gen.openrouter import _extract_images
|
||||
|
||||
payload = {
|
||||
"choices": [
|
||||
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,AA"}}]}}
|
||||
]
|
||||
}
|
||||
assert _extract_images(payload) == ["data:image/png;base64,AA"]
|
||||
|
||||
def test_extract_images_empty(self):
|
||||
from plugins.image_gen.openrouter import _extract_images
|
||||
|
||||
assert _extract_images({"choices": [{"message": {"content": "no image"}}]}) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_missing_credentials(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "missing_api_key"
|
||||
|
||||
def test_success_data_uri(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])), \
|
||||
patch(
|
||||
"plugins.image_gen.openrouter.save_b64_image",
|
||||
return_value=Path("/tmp/openrouter_gen.png"),
|
||||
) as mock_save:
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "/tmp/openrouter_gen.png"
|
||||
assert result["provider"] == "openrouter"
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_success_http_url(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response(["https://cdn/x.png"])), \
|
||||
patch(
|
||||
"plugins.image_gen.openrouter.save_url_image",
|
||||
return_value=Path("/tmp/openrouter_gen_url.png"),
|
||||
) as mock_save_url:
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "/tmp/openrouter_gen_url.png"
|
||||
mock_save_url.assert_called_once()
|
||||
|
||||
def test_empty_response(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([])):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_payload_shape_and_references(self, tmp_path):
|
||||
"""Wire payload must carry image modalities, aspect_ratio, and the
|
||||
reference image inlined as a data URI (this is what makes pet rows
|
||||
stay on-model)."""
|
||||
ref = tmp_path / "base.png"
|
||||
ref.write_bytes(b"\x89PNG\r\n")
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
|
||||
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
|
||||
_openrouter().generate(
|
||||
prompt="a pet", aspect_ratio="square", reference_images=[str(ref)]
|
||||
)
|
||||
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["modalities"] == ["image", "text"]
|
||||
assert payload["image_config"]["aspect_ratio"] == "1:1"
|
||||
content = payload["messages"][0]["content"]
|
||||
assert content[0] == {"type": "text", "text": "a pet"}
|
||||
image_parts = [c for c in content if c["type"] == "image_url"]
|
||||
assert len(image_parts) == 1
|
||||
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_auth_header(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
|
||||
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
|
||||
_openrouter().generate(prompt="a pet")
|
||||
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers["Authorization"] == "Bearer sk-or-test"
|
||||
|
||||
def test_posts_to_resolved_base_url(self):
|
||||
"""Nous routes to its own base URL — proves the same code serves both."""
|
||||
nous_runtime = _runtime_ok(
|
||||
provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok"
|
||||
)
|
||||
with patch(_RUNTIME, return_value=nous_runtime), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
|
||||
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
|
||||
from plugins.image_gen.openrouter import _build_providers
|
||||
|
||||
nous = {p.name: p for p in _build_providers()}["nous"]
|
||||
result = nous.generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "nous"
|
||||
url = mock_post.call_args[0][0]
|
||||
assert url == "https://inference.nousresearch.com/v1/chat/completions"
|
||||
|
||||
def test_api_error(self):
|
||||
import requests as req_lib
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 401
|
||||
resp.text = "Unauthorized"
|
||||
resp.json.return_value = {"error": {"message": "Invalid API key"}}
|
||||
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=resp):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
|
||||
def test_timeout(self):
|
||||
import requests as req_lib
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", side_effect=req_lib.Timeout()):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "timeout"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration + pet integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_register_both(self):
|
||||
from plugins.image_gen.openrouter import register
|
||||
|
||||
ctx = MagicMock()
|
||||
register(ctx)
|
||||
registered = [c.args[0].name for c in ctx.register_image_gen_provider.call_args_list]
|
||||
assert set(registered) == {"openrouter", "nous"}
|
||||
|
||||
def test_both_are_reference_capable_for_pets(self):
|
||||
from agent.pet.generate.imagegen import _REF_CAPABLE
|
||||
|
||||
assert "openrouter" in _REF_CAPABLE
|
||||
assert "nous" in _REF_CAPABLE
|
||||
|
|
@ -1932,12 +1932,14 @@ class TestDelegateHeartbeat(unittest.TestCase):
|
|||
|
||||
child.run_conversation.side_effect = slow_run
|
||||
|
||||
# Patch both the interval AND the idle ceiling so the test proves
|
||||
# the in-tool branch takes effect: with a 0.05s interval and the
|
||||
# default _HEARTBEAT_STALE_CYCLES_IDLE=5, the old behavior would
|
||||
# trip after 0.25s and stop firing. We should see heartbeats
|
||||
# continuing through the full 0.4s run.
|
||||
with patch("tools.delegate_tool._HEARTBEAT_INTERVAL", 0.05):
|
||||
# Use tiny thresholds so the assertion is scheduler-robust in CI:
|
||||
# if idle rules were used for in-tool work, heartbeat would stop after
|
||||
# ~2 cycles. The in-tool branch should keep touching well past that.
|
||||
with (
|
||||
patch("tools.delegate_tool._HEARTBEAT_INTERVAL", 0.05),
|
||||
patch("tools.delegate_tool._HEARTBEAT_STALE_CYCLES_IDLE", 2),
|
||||
patch("tools.delegate_tool._HEARTBEAT_STALE_CYCLES_IN_TOOL", 40),
|
||||
):
|
||||
_run_single_child(
|
||||
task_index=0,
|
||||
goal="Test long-running tool",
|
||||
|
|
@ -1945,11 +1947,10 @@ class TestDelegateHeartbeat(unittest.TestCase):
|
|||
parent_agent=parent,
|
||||
)
|
||||
|
||||
# With the old idle threshold (5 cycles = 0.25s), touch_calls
|
||||
# would cap at ~5. With the in-tool threshold (20 cycles = 1.0s),
|
||||
# we should see substantially more heartbeats over 0.4s.
|
||||
# If idle-threshold logic applied, we'd cap around 2 touches; prove we
|
||||
# continued beyond that while inside a long-running tool.
|
||||
self.assertGreater(
|
||||
len(touch_calls), 6,
|
||||
len(touch_calls), 2,
|
||||
f"Heartbeat stopped too early while child was inside a tool; "
|
||||
f"got {len(touch_calls)} touches over 0.4s at 0.05s interval",
|
||||
)
|
||||
|
|
|
|||
245
tests/tui_gateway/test_pet_generate_rpc.py
Normal file
245
tests/tui_gateway/test_pet_generate_rpc.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Gateway RPC tests for pet generation (pet.generate / pet.hatch).
|
||||
|
||||
Image generation is mocked, so these assert the RPC contract + staging behavior
|
||||
(draft tokens, data-URI previews, expiry, activation) without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PIL")
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from tui_gateway import server # noqa: E402
|
||||
|
||||
|
||||
def _png(path):
|
||||
Image.new("RGBA", (64, 64), (200, 80, 80, 255)).save(path)
|
||||
|
||||
|
||||
def test_pet_generate_requires_prompt():
|
||||
resp = server._methods["pet.generate"]("r1", {"prompt": " "})
|
||||
assert "error" in resp
|
||||
|
||||
|
||||
def test_pet_generate_rejects_invalid_reference_image():
|
||||
resp = server._methods["pet.generate"](
|
||||
"r_invalid_ref",
|
||||
{"referenceImage": "data:image/svg+xml;base64,PHN2Zy8+"},
|
||||
)
|
||||
assert "error" in resp
|
||||
assert "unsupported reference image type" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_pet_generate_rejects_oversized_reference_image(monkeypatch):
|
||||
import base64
|
||||
|
||||
monkeypatch.setattr(server, "_PET_REFERENCE_MAX_BYTES", 8)
|
||||
payload = base64.b64encode(b"0123456789").decode("ascii")
|
||||
resp = server._methods["pet.generate"](
|
||||
"r_big_ref",
|
||||
{"referenceImage": f"data:image/png;base64,{payload}"},
|
||||
)
|
||||
assert "error" in resp
|
||||
assert "too large" in resp["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_pet_generate_returns_token_and_previews(monkeypatch, tmp_path):
|
||||
import agent.pet.generate as gen
|
||||
|
||||
def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None):
|
||||
paths = []
|
||||
for i in range(n):
|
||||
p = tmp_path / f"d{i}.png"
|
||||
_png(p)
|
||||
paths.append(p)
|
||||
if on_draft is not None:
|
||||
on_draft(i, p)
|
||||
return paths
|
||||
|
||||
monkeypatch.setattr(gen, "generate_base_drafts", fake_drafts)
|
||||
|
||||
resp = server._methods["pet.generate"]("r2", {"prompt": "a robot fox", "count": 4})
|
||||
result = resp["result"]
|
||||
assert result["ok"]
|
||||
assert len(result["drafts"]) == 4
|
||||
assert all(d["dataUri"].startswith("data:image/png;base64,") for d in result["drafts"])
|
||||
|
||||
# Drafts are staged on disk under the returned token.
|
||||
staged = server._pet_gen_root() / result["token"] / "draft-0.png"
|
||||
assert staged.is_file()
|
||||
|
||||
|
||||
def test_pet_cancel_unknown_token_is_noop():
|
||||
resp = server._methods["pet.cancel"]("c0", {"token": "missing"})
|
||||
assert resp["result"]["ok"] is True
|
||||
|
||||
|
||||
def test_pet_generate_cancel_stops_run(monkeypatch, tmp_path):
|
||||
import agent.pet.generate as gen
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
def cap_emit(event, sid, payload=None):
|
||||
# Capture the token from the up-front init event so we can cancel it.
|
||||
if event == "pet.generate.progress" and payload and payload.get("token") and not payload.get("dataUri"):
|
||||
seen["token"] = payload["token"]
|
||||
|
||||
monkeypatch.setattr(server, "_emit", cap_emit)
|
||||
|
||||
def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None):
|
||||
# Simulate a Stop landing mid-run: the cooperative flag must read True.
|
||||
server._pet_cancel_request(seen["token"])
|
||||
assert is_cancelled() is True
|
||||
return [] # bailed before producing anything
|
||||
|
||||
monkeypatch.setattr(gen, "generate_base_drafts", fake_drafts)
|
||||
|
||||
resp = server._methods["pet.generate"]("rc", {"prompt": "x", "count": 4})
|
||||
assert "error" in resp
|
||||
assert "cancel" in resp["error"]["message"].lower()
|
||||
# The flag is released after the run so reusing the token isn't pre-cancelled.
|
||||
assert server._pet_is_cancelled(seen["token"]) is False
|
||||
|
||||
|
||||
def test_pet_hatch_validates_params():
|
||||
assert "error" in server._methods["pet.hatch"]("r1", {"name": "x"}) # missing token
|
||||
assert "error" in server._methods["pet.hatch"]("r2", {"token": "abc"}) # missing name
|
||||
|
||||
|
||||
def test_pet_hatch_expired_draft():
|
||||
resp = server._methods["pet.hatch"]("r3", {"token": "nope", "index": 0, "name": "Ghost"})
|
||||
assert "error" in resp
|
||||
assert "expired" in resp["error"]["message"]
|
||||
|
||||
|
||||
def _fake_drafts_factory(tmp_path):
|
||||
def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None):
|
||||
paths = []
|
||||
for i in range(n):
|
||||
p = tmp_path / f"d{i}.png"
|
||||
_png(p)
|
||||
paths.append(p)
|
||||
if on_draft is not None:
|
||||
on_draft(i, p)
|
||||
return paths
|
||||
|
||||
return fake_drafts
|
||||
|
||||
|
||||
def _fake_hatch_factory(captured):
|
||||
"""A hatch that registers a real local pet (so the preview payload populates)."""
|
||||
import agent.pet.generate as gen
|
||||
from agent.pet import store
|
||||
|
||||
def fake_hatch(*, base_image, slug, display_name="", description="", concept="", style="auto", on_progress=None, provider=None, is_cancelled=None):
|
||||
captured["base_image"] = str(base_image)
|
||||
captured["slug"] = slug
|
||||
pet = store.register_local_pet(
|
||||
Image.new("RGBA", (192, 208), (10, 20, 30, 255)),
|
||||
slug=slug,
|
||||
display_name=display_name,
|
||||
description=description,
|
||||
)
|
||||
return gen.HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=display_name or pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=["idle", "wave"],
|
||||
validation={"ok": True, "warnings": ["state 'jump' has no frames"]},
|
||||
)
|
||||
|
||||
return fake_hatch
|
||||
|
||||
|
||||
def test_pet_generate_then_hatch_previews_without_activating(monkeypatch, tmp_path):
|
||||
import agent.pet.generate as gen
|
||||
from agent.pet import store
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path))
|
||||
monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured))
|
||||
|
||||
token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"]
|
||||
|
||||
resp = server._methods["pet.hatch"](
|
||||
"r2",
|
||||
{"token": token, "index": 1, "name": "My Fox", "description": "vulpine"},
|
||||
)
|
||||
result = resp["result"]
|
||||
assert result["ok"]
|
||||
assert result["slug"] == "my-fox"
|
||||
assert result["displayName"] == "My Fox"
|
||||
assert result["warnings"] == ["state 'jump' has no frames"]
|
||||
# Hatched from the chosen draft index.
|
||||
assert captured["base_image"].endswith("draft-1.png")
|
||||
|
||||
# The pet is installed on disk and the preview payload carries the sheet,
|
||||
# but hatch must NOT activate it — adoption is a separate step.
|
||||
assert store.load_pet("my-fox") is not None
|
||||
assert result["pet"]["slug"] == "my-fox"
|
||||
assert result["pet"]["spritesheetBase64"]
|
||||
assert server._methods["pet.info"]("r3", {}).get("result", {}).get("enabled") in (False, None)
|
||||
|
||||
|
||||
def test_pet_hatch_then_adopt_activates(monkeypatch, tmp_path):
|
||||
import agent.pet.generate as gen
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path))
|
||||
monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured))
|
||||
|
||||
activated = {}
|
||||
monkeypatch.setattr("hermes_cli.pets._set_active", lambda slug: activated.setdefault("slug", slug))
|
||||
|
||||
token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"]
|
||||
hatched = server._methods["pet.hatch"]("r2", {"token": token, "index": 0, "name": "My Fox"})["result"]
|
||||
|
||||
# Adoption is the existing pet.select path, against the now-installed slug.
|
||||
adopt = server._methods["pet.select"]("r3", {"slug": hatched["slug"]})["result"]
|
||||
assert adopt["ok"]
|
||||
assert activated["slug"] == "my-fox"
|
||||
|
||||
|
||||
def test_pet_sprite_payload_includes_concrete_row_counts():
|
||||
from agent.pet import constants, store
|
||||
|
||||
cols, rows = 8, 9
|
||||
sheet = Image.new("RGBA", (constants.FRAME_W * cols, constants.FRAME_H * rows), (0, 0, 0, 0))
|
||||
# Current Codex rows can have more/fewer frames than Hermes' generic
|
||||
# FRAMES_PER_STATE. The desktop preview needs the concrete row count.
|
||||
real = {0: 6, 1: 8, 3: 4, 4: 5, 7: 6}
|
||||
for row, count in real.items():
|
||||
for col in range(count):
|
||||
block = Image.new("RGBA", (constants.FRAME_W, constants.FRAME_H), (80, 120, 220, 255))
|
||||
sheet.paste(block, (col * constants.FRAME_W, row * constants.FRAME_H))
|
||||
|
||||
pet = store.register_local_pet(sheet, slug="row-counts", display_name="Row Counts")
|
||||
payload = server._pet_sprite_payload(pet, scale=0.7)
|
||||
|
||||
assert payload["framesByRow"]["running-right"] == 8
|
||||
assert payload["framesByRow"]["waving"] == 4
|
||||
assert payload["framesByRow"]["jumping"] == 5
|
||||
assert payload["framesByState"]["run"] == 6
|
||||
|
||||
|
||||
def test_pet_info_meta_avoids_full_payload(monkeypatch):
|
||||
import hermes_cli.config as cli_config
|
||||
from agent.pet import constants, store
|
||||
|
||||
sheet = Image.new("RGBA", (constants.FRAME_W * 8, constants.FRAME_H * 9), (80, 120, 220, 255))
|
||||
pet = store.register_local_pet(sheet, slug="meta-pet", display_name="Meta Pet")
|
||||
monkeypatch.setattr(
|
||||
cli_config,
|
||||
"load_config",
|
||||
lambda: {"display": {"pet": {"enabled": True, "slug": pet.slug, "scale": 0.7}}},
|
||||
)
|
||||
|
||||
resp = server._methods["pet.info.meta"]("r_meta", {})
|
||||
result = resp["result"]
|
||||
assert result["enabled"] is True
|
||||
assert result["slug"] == pet.slug
|
||||
assert result["displayName"] == "Meta Pet"
|
||||
assert result["scale"] == 0.7
|
||||
assert ":" in result["spritesheetRevision"]
|
||||
|
|
@ -184,6 +184,10 @@ _LONG_HANDLERS = frozenset(
|
|||
# animation poll stutters. On the pool they run concurrently.
|
||||
"pet.cells",
|
||||
"pet.gallery",
|
||||
# Generation is the heaviest pet path by far — multiple image-model
|
||||
# round-trips per call — so it must never block the reader thread.
|
||||
"pet.generate",
|
||||
"pet.hatch",
|
||||
"pet.select",
|
||||
"pet.thumb",
|
||||
"plugins.manage",
|
||||
|
|
@ -5575,6 +5579,153 @@ def _pet_frame_counts(spritesheet) -> dict:
|
|||
return {}
|
||||
|
||||
|
||||
_pet_payload_cache_lock = threading.Lock()
|
||||
_pet_payload_cache: dict[tuple, dict] = {}
|
||||
|
||||
|
||||
def _pet_sheet_revision(spritesheet) -> str:
|
||||
"""Stable revision id for one spritesheet file."""
|
||||
try:
|
||||
stat = spritesheet.stat()
|
||||
return f"{stat.st_mtime_ns}:{stat.st_size}"
|
||||
except Exception: # noqa: BLE001 - cosmetic, never break the surface
|
||||
return "0:0"
|
||||
|
||||
|
||||
def _pet_payload_cache_key(pet, *, scale: float) -> tuple | None:
|
||||
"""Cache key for the expensive sprite payload build."""
|
||||
try:
|
||||
stat = pet.spritesheet.stat()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return (
|
||||
str(pet.spritesheet),
|
||||
stat.st_mtime_ns,
|
||||
stat.st_size,
|
||||
pet.slug,
|
||||
pet.display_name,
|
||||
round(scale, 4),
|
||||
)
|
||||
|
||||
|
||||
def _clone_pet_payload(payload: dict) -> dict:
|
||||
"""Shallow-clone cached payloads so callers can't mutate shared state."""
|
||||
out = dict(payload)
|
||||
if isinstance(payload.get("framesByState"), dict):
|
||||
out["framesByState"] = dict(payload["framesByState"])
|
||||
if isinstance(payload.get("framesByRow"), dict):
|
||||
out["framesByRow"] = dict(payload["framesByRow"])
|
||||
if isinstance(payload.get("stateRows"), list):
|
||||
out["stateRows"] = list(payload["stateRows"])
|
||||
return out
|
||||
|
||||
|
||||
def _pet_row_frame_counts(spritesheet) -> dict:
|
||||
"""Real frame count per concrete spritesheet row name."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
from agent.pet import constants, render
|
||||
|
||||
with Image.open(spritesheet) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
cols = max(1, image.width // constants.FRAME_W)
|
||||
row_count = max(1, image.height // constants.FRAME_H)
|
||||
rows = constants.state_rows_for_grid(row_count)
|
||||
out: dict[str, int] = {}
|
||||
for row_idx, name in enumerate(rows[:row_count]):
|
||||
top = row_idx * constants.FRAME_H
|
||||
count = 0
|
||||
for col in range(cols):
|
||||
left = col * constants.FRAME_W
|
||||
frame = image.crop((left, top, left + constants.FRAME_W, top + constants.FRAME_H))
|
||||
if render._frame_is_blank(frame):
|
||||
break
|
||||
count += 1
|
||||
out[name] = count
|
||||
return out
|
||||
except Exception: # noqa: BLE001 - cosmetic, never break the surface
|
||||
return {}
|
||||
|
||||
|
||||
def _pet_config_scale() -> float:
|
||||
"""Configured ``display.pet.scale`` (or the engine default), never raises."""
|
||||
from agent.pet import constants
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
||||
return float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
except Exception: # noqa: BLE001
|
||||
return constants.DEFAULT_SCALE
|
||||
|
||||
|
||||
def _pet_sprite_payload(pet, *, scale: float) -> dict:
|
||||
"""Build the renderer payload (spritesheet bytes + geometry) for *pet*.
|
||||
|
||||
Shared by ``pet.info`` (the active mascot) and ``pet.hatch`` (the unadopted
|
||||
preview) so both feed the desktop canvas / TUI from one shape.
|
||||
"""
|
||||
import base64
|
||||
|
||||
from agent.pet import constants
|
||||
|
||||
cache_key = _pet_payload_cache_key(pet, scale=scale)
|
||||
if cache_key is not None:
|
||||
with _pet_payload_cache_lock:
|
||||
cached = _pet_payload_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return _clone_pet_payload(cached)
|
||||
|
||||
raw = pet.spritesheet.read_bytes()
|
||||
suffix = pet.spritesheet.suffix.lower()
|
||||
mime = "image/png" if suffix == ".png" else "image/webp"
|
||||
payload = {
|
||||
"slug": pet.slug,
|
||||
"displayName": pet.display_name,
|
||||
"mime": mime,
|
||||
"spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"),
|
||||
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet),
|
||||
"frameW": constants.FRAME_W,
|
||||
"frameH": constants.FRAME_H,
|
||||
"framesPerState": constants.FRAMES_PER_STATE,
|
||||
"framesByState": _pet_frame_counts(pet.spritesheet),
|
||||
"framesByRow": _pet_row_frame_counts(pet.spritesheet),
|
||||
"loopMs": constants.LOOP_MS,
|
||||
"scale": scale,
|
||||
"stateRows": _pet_state_rows(pet.spritesheet),
|
||||
}
|
||||
if cache_key is not None:
|
||||
with _pet_payload_cache_lock:
|
||||
_pet_payload_cache[cache_key] = payload
|
||||
while len(_pet_payload_cache) > 8:
|
||||
_pet_payload_cache.pop(next(iter(_pet_payload_cache)))
|
||||
return _clone_pet_payload(payload)
|
||||
|
||||
|
||||
def _pet_active_selection():
|
||||
"""Resolve configured active pet + scale from config."""
|
||||
from agent.pet import constants, store
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
||||
except Exception:
|
||||
pet_cfg = {}
|
||||
|
||||
enabled = bool(pet_cfg.get("enabled"))
|
||||
configured_slug = str(pet_cfg.get("slug", "") or "")
|
||||
pet = store.resolve_active_pet(configured_slug) if enabled else None
|
||||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
return enabled, pet, scale
|
||||
|
||||
|
||||
def _pet_state_rows(spritesheet) -> list[str]:
|
||||
"""Row taxonomy for the concrete active pet sheet.
|
||||
|
||||
|
|
@ -5610,49 +5761,38 @@ def _(rid, params: dict) -> dict:
|
|||
before the agent finishes building. Fail-open: returns ``enabled=False``
|
||||
on any error rather than erroring the surface.
|
||||
"""
|
||||
import base64
|
||||
|
||||
try:
|
||||
from agent.pet import constants, store
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
||||
except Exception:
|
||||
pet_cfg = {}
|
||||
|
||||
enabled = bool(pet_cfg.get("enabled"))
|
||||
configured_slug = str(pet_cfg.get("slug", "") or "")
|
||||
pet = store.resolve_active_pet(configured_slug) if enabled else None
|
||||
enabled, pet, scale = _pet_active_selection()
|
||||
|
||||
if not enabled or pet is None or not pet.exists:
|
||||
return _ok(rid, {"enabled": False})
|
||||
|
||||
raw = pet.spritesheet.read_bytes()
|
||||
suffix = pet.spritesheet.suffix.lower()
|
||||
mime = "image/png" if suffix == ".png" else "image/webp"
|
||||
return _ok(rid, {"enabled": True, **_pet_sprite_payload(pet, scale=scale)})
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface
|
||||
logger.debug("pet.info failed: %s", exc)
|
||||
return _ok(rid, {"enabled": False})
|
||||
|
||||
|
||||
@method("pet.info.meta")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Cheap active-pet metadata used to avoid full payload refreshes."""
|
||||
try:
|
||||
enabled, pet, scale = _pet_active_selection()
|
||||
if not enabled or pet is None or not pet.exists:
|
||||
return _ok(rid, {"enabled": False})
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"enabled": True,
|
||||
"slug": pet.slug,
|
||||
"displayName": pet.display_name,
|
||||
"mime": mime,
|
||||
"spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"),
|
||||
"frameW": constants.FRAME_W,
|
||||
"frameH": constants.FRAME_H,
|
||||
"framesPerState": constants.FRAMES_PER_STATE,
|
||||
"framesByState": _pet_frame_counts(pet.spritesheet),
|
||||
"loopMs": constants.LOOP_MS,
|
||||
"scale": float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE),
|
||||
"stateRows": _pet_state_rows(pet.spritesheet),
|
||||
"scale": scale,
|
||||
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet),
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface
|
||||
logger.debug("pet.info failed: %s", exc)
|
||||
logger.debug("pet.info.meta failed: %s", exc)
|
||||
return _ok(rid, {"enabled": False})
|
||||
|
||||
|
||||
|
|
@ -5770,7 +5910,12 @@ def _(rid, params: dict) -> dict:
|
|||
current config (active slug + enabled). Agent-independent. Fail-open:
|
||||
returns whatever is installed locally if the gallery can't be reached, so
|
||||
the picker still works offline.
|
||||
|
||||
Param ``localOnly`` (bool): skip the remote petdex manifest fetch and return
|
||||
only locally-installed pets. The desktop loads this first so the user's own
|
||||
pets render instantly instead of waiting on the (possibly slow) manifest.
|
||||
"""
|
||||
local_only = bool(params.get("localOnly"))
|
||||
try:
|
||||
from agent.pet import store
|
||||
|
||||
|
|
@ -5788,9 +5933,14 @@ def _(rid, params: dict) -> dict:
|
|||
gallery: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
from agent.pet.manifest import fetch_manifest
|
||||
from agent.pet.manifest import fetch_manifest, prefetch
|
||||
|
||||
for entry in fetch_manifest():
|
||||
# Local-only: skip the network entirely, but kick off a background
|
||||
# warm so the follow-up full request usually hits a cached manifest.
|
||||
if local_only:
|
||||
prefetch()
|
||||
|
||||
for entry in [] if local_only else fetch_manifest():
|
||||
seen.add(entry.slug)
|
||||
gallery.append(
|
||||
{
|
||||
|
|
@ -5802,6 +5952,7 @@ def _(rid, params: dict) -> dict:
|
|||
# hand-picked/official set, identified by the asset path)
|
||||
# is the closest signal, so the picker can surface it first.
|
||||
"curated": "/curated/" in entry.spritesheet_url,
|
||||
"generated": entry.slug in installed and installed[entry.slug].generated,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - offline: fall back to installed
|
||||
|
|
@ -5811,7 +5962,13 @@ def _(rid, params: dict) -> dict:
|
|||
for slug, pet in installed.items():
|
||||
if slug not in seen:
|
||||
gallery.append(
|
||||
{"slug": slug, "displayName": pet.display_name, "installed": True, "spritesheetUrl": ""}
|
||||
{
|
||||
"slug": slug,
|
||||
"displayName": pet.display_name,
|
||||
"installed": True,
|
||||
"spritesheetUrl": "",
|
||||
"generated": pet.generated,
|
||||
}
|
||||
)
|
||||
|
||||
return _ok(
|
||||
|
|
@ -5884,6 +6041,71 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 5031, f"pet.remove failed: {exc}")
|
||||
|
||||
|
||||
@method("pet.export")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Export an installed pet as a re-importable ``.zip`` (pet.json + sprite).
|
||||
|
||||
Params: ``slug`` (required). Returns ``{ok, filename, zipBase64}`` — the
|
||||
client decodes the base64 and saves it. Heavy-ish (reads + zips files) but
|
||||
small; runs inline.
|
||||
"""
|
||||
slug = str(params.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return _err(rid, 4004, "missing slug")
|
||||
try:
|
||||
import base64
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
filename, data = store.export_pet(slug)
|
||||
return _ok(
|
||||
rid,
|
||||
{"ok": True, "filename": filename, "zipBase64": base64.standard_b64encode(data).decode("ascii")},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.export failed: %s", exc)
|
||||
return _err(rid, 5031, f"pet.export failed: {exc}")
|
||||
|
||||
|
||||
@method("pet.rename")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Rename an installed pet's display name + realign its slug/dir.
|
||||
|
||||
Params: ``slug`` + ``name`` (both required). Lets the generate flow hatch
|
||||
with a provisional name and apply the user's chosen name at adopt time.
|
||||
Returns ``{ok, slug, displayName}`` with the (possibly new) slug.
|
||||
"""
|
||||
slug = str(params.get("slug") or "").strip()
|
||||
name = str(params.get("name") or "").strip()
|
||||
if not slug:
|
||||
return _err(rid, 4004, "missing slug")
|
||||
if not name:
|
||||
return _err(rid, 4004, "missing name")
|
||||
try:
|
||||
from agent.pet import store
|
||||
|
||||
new_slug = store.rename_pet(slug, name)
|
||||
if not new_slug:
|
||||
return _err(rid, 5031, "pet.rename failed")
|
||||
|
||||
# The dir may have moved; if the renamed pet was active, follow the slug
|
||||
# in config so surfaces don't point at the old (now-missing) directory.
|
||||
if new_slug != slug:
|
||||
try:
|
||||
from hermes_cli.pets import _rename_active_if
|
||||
|
||||
_rename_active_if(slug, new_slug)
|
||||
except Exception as exc: # noqa: BLE001 - rename already succeeded
|
||||
logger.debug("pet.rename config update failed: %s", exc)
|
||||
|
||||
return _ok(rid, {"ok": True, "slug": new_slug, "displayName": name})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.rename failed: %s", exc)
|
||||
return _err(rid, 5031, f"pet.rename failed: {exc}")
|
||||
|
||||
|
||||
@method("pet.thumb")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
|
|
@ -5954,6 +6176,377 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 5031, f"pet.scale failed: {exc}")
|
||||
|
||||
|
||||
def _pet_gen_root():
|
||||
"""Profile-scoped staging dir for in-progress generation drafts."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
root = get_hermes_home() / "cache" / "pet-gen"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _pet_gen_sweep(root, *, max_age_s: float = 3600.0) -> None:
|
||||
"""Drop stale draft staging dirs so cache never grows unbounded."""
|
||||
import shutil
|
||||
import time
|
||||
|
||||
try:
|
||||
now = time.time()
|
||||
for child in root.iterdir():
|
||||
if child.is_dir() and now - child.stat().st_mtime > max_age_s:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
except Exception as exc: # noqa: BLE001 - cleanup is best-effort
|
||||
logger.debug("pet-gen sweep failed: %s", exc)
|
||||
|
||||
|
||||
def _pet_png_data_uri(path, *, max_px: int = 160) -> str:
|
||||
"""Downscaled PNG data URI for a draft image (small preview payload)."""
|
||||
import base64
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as opened:
|
||||
img = opened.convert("RGBA")
|
||||
img.thumbnail((max_px, max_px), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.standard_b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
# Cooperative cancellation for the heavy pet generation paths. The client's Stop
|
||||
# aborts its RPC immediately, but the worker-pool generation keeps running unless
|
||||
# told to stop — pet.cancel flips a token's flag, which generate_base_drafts /
|
||||
# hatch_pet poll between provider calls to skip work they haven't started.
|
||||
_pet_cancel_lock = threading.Lock()
|
||||
_pet_cancelled: set[str] = set()
|
||||
_PET_REFERENCE_MIME_EXT = {
|
||||
"png": "png",
|
||||
"jpeg": "jpg",
|
||||
"jpg": "jpg",
|
||||
"webp": "webp",
|
||||
"gif": "gif",
|
||||
}
|
||||
try:
|
||||
_PET_REFERENCE_MAX_BYTES = max(
|
||||
1,
|
||||
int(os.environ.get("HERMES_PET_REFERENCE_MAX_BYTES") or str(16 * 1024 * 1024)),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
_PET_REFERENCE_MAX_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def _pet_reference_images_from_data_url(ref_raw: str, stage) -> list:
|
||||
"""Decode + validate a reference-image data URL into the stage dir."""
|
||||
import base64
|
||||
import binascii
|
||||
import re as _re
|
||||
|
||||
match = _re.match(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", ref_raw, _re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("invalid reference image format")
|
||||
|
||||
mime = match.group(1).lower()
|
||||
ext = _PET_REFERENCE_MIME_EXT.get(mime)
|
||||
if ext is None:
|
||||
raise ValueError("unsupported reference image type")
|
||||
|
||||
payload = "".join(match.group(2).split())
|
||||
approx = (len(payload) * 3) // 4
|
||||
if approx > _PET_REFERENCE_MAX_BYTES:
|
||||
raise ValueError("reference image too large")
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(payload, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ValueError("invalid reference image data") from exc
|
||||
|
||||
if len(raw) > _PET_REFERENCE_MAX_BYTES:
|
||||
raise ValueError("reference image too large")
|
||||
|
||||
ref_path = stage / f"reference.{ext}"
|
||||
ref_path.write_bytes(raw)
|
||||
return [ref_path]
|
||||
|
||||
|
||||
def _pet_cancel_arm(token: str) -> None:
|
||||
"""Clear a stale cancel flag at the start of a generate/hatch run."""
|
||||
with _pet_cancel_lock:
|
||||
_pet_cancelled.discard(token)
|
||||
|
||||
|
||||
def _pet_cancel_request(token: str) -> None:
|
||||
with _pet_cancel_lock:
|
||||
_pet_cancelled.add(token)
|
||||
|
||||
|
||||
def _pet_is_cancelled(token: str) -> bool:
|
||||
with _pet_cancel_lock:
|
||||
return token in _pet_cancelled
|
||||
|
||||
|
||||
def _pet_cancel_release(token: str) -> None:
|
||||
with _pet_cancel_lock:
|
||||
_pet_cancelled.discard(token)
|
||||
|
||||
|
||||
@method("pet.cancel")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Signal an in-flight ``pet.generate``/``pet.hatch`` (by token) to stop.
|
||||
|
||||
Best-effort + idempotent: cancelling an unknown/finished token is a no-op.
|
||||
Stays off the worker pool so it lands while a heavy generation is occupying
|
||||
it. Returns ``{ok: True}``.
|
||||
"""
|
||||
token = str(params.get("token") or "").strip()
|
||||
if token:
|
||||
_pet_cancel_request(token)
|
||||
return _ok(rid, {"ok": True})
|
||||
|
||||
|
||||
@method("pet.generate.status")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Whether pet generation is possible right now.
|
||||
|
||||
True only when a reference-capable image backend (Nous Portal / OpenRouter /
|
||||
OpenAI gpt-image) is configured — the desktop checks this on open so it can
|
||||
offer setup instead of a dead prompt. Cheap (config + plugin discovery).
|
||||
"""
|
||||
try:
|
||||
from agent.pet.generate.imagegen import (
|
||||
GenerationError,
|
||||
list_sprite_providers,
|
||||
resolve_provider,
|
||||
)
|
||||
|
||||
try:
|
||||
resolve_provider(require_references=True)
|
||||
available = True
|
||||
except GenerationError:
|
||||
available = False
|
||||
try:
|
||||
providers = list_sprite_providers()
|
||||
except Exception as exc: # noqa: BLE001 - picker is best-effort
|
||||
logger.debug("pet provider list failed: %s", exc)
|
||||
providers = []
|
||||
return _ok(rid, {"available": available, "providers": providers})
|
||||
except Exception as exc: # noqa: BLE001 - never break the surface
|
||||
logger.debug("pet.generate.status failed: %s", exc)
|
||||
return _ok(rid, {"available": False, "providers": []})
|
||||
|
||||
|
||||
@method("pet.generate")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Generate candidate base looks for a new pet (the draft/variant step).
|
||||
|
||||
Params: ``prompt`` (required unless ``referenceImage`` is given), ``count``
|
||||
(default 4), ``style`` (default ``auto``), ``referenceImage`` (optional data
|
||||
URL — a user photo/reference every draft is grounded on, e.g. to make *their*
|
||||
pet). Returns ``{ok, token, drafts:[{index, dataUri}]}`` — the token keys the
|
||||
staged base images for a later ``pet.hatch``. Heavy (network): worker pool.
|
||||
"""
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
ref_raw = str(params.get("referenceImage") or "").strip()
|
||||
if not prompt and not ref_raw:
|
||||
return _err(rid, 4004, "missing prompt")
|
||||
try:
|
||||
count = max(1, min(4, int(params.get("count") or 4)))
|
||||
except (TypeError, ValueError):
|
||||
count = 4
|
||||
style = str(params.get("style") or "auto").strip() or "auto"
|
||||
|
||||
try:
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from agent.pet.generate import generate_base_drafts
|
||||
from agent.pet.generate.imagegen import GenerationError, resolve_provider
|
||||
|
||||
root = _pet_gen_root()
|
||||
_pet_gen_sweep(root)
|
||||
|
||||
# Token up front so each draft can be staged + streamed the moment it
|
||||
# lands, instead of the user staring at a blank grid until all N finish.
|
||||
token = uuid.uuid4().hex[:12]
|
||||
_pet_cancel_arm(token)
|
||||
stage = root / token
|
||||
stage.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
reference_images = None
|
||||
if ref_raw:
|
||||
try:
|
||||
reference_images = _pet_reference_images_from_data_url(ref_raw, stage)
|
||||
except ValueError as exc:
|
||||
_pet_cancel_release(token)
|
||||
return _err(rid, 4004, str(exc))
|
||||
|
||||
# Optional desktop picker override: resolve the chosen provider up front so
|
||||
# a bad/uncredentialed pick fails fast instead of mid-fan-out.
|
||||
provider_name = str(params.get("provider") or "").strip()
|
||||
sprite = None
|
||||
if provider_name:
|
||||
try:
|
||||
sprite = resolve_provider(require_references=bool(reference_images), prefer=provider_name)
|
||||
except GenerationError as exc:
|
||||
_pet_cancel_release(token)
|
||||
return _err(rid, 5031, str(exc))
|
||||
|
||||
concept = prompt or "a pet based on the reference image"
|
||||
out: list[dict] = []
|
||||
|
||||
# Hand the token to the client up front (token-only init event) so a Stop
|
||||
# fired before the first draft lands can still target this run.
|
||||
try:
|
||||
_emit("pet.generate.progress", "", {"token": token, "count": count})
|
||||
except Exception as exc: # noqa: BLE001 - streaming is best-effort
|
||||
logger.debug("pet.generate init emit failed: %s", exc)
|
||||
|
||||
def _on_draft(index: int, src) -> None:
|
||||
dest = stage / f"draft-{index}.png"
|
||||
try:
|
||||
shutil.copyfile(src, dest)
|
||||
data_uri = _pet_png_data_uri(dest)
|
||||
except Exception as exc: # noqa: BLE001 - skip a bad draft, keep the rest
|
||||
logger.debug("pet.generate draft %d failed: %s", index, exc)
|
||||
return
|
||||
out.append({"index": index, "dataUri": data_uri})
|
||||
# Stream this draft to the client so the grid fills in live. Best-
|
||||
# effort: a transport hiccup must not abort the generation itself.
|
||||
try:
|
||||
_emit(
|
||||
"pet.generate.progress",
|
||||
"",
|
||||
{"token": token, "index": index, "dataUri": data_uri, "count": count},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.generate progress emit failed: %s", exc)
|
||||
|
||||
try:
|
||||
generate_base_drafts(
|
||||
concept,
|
||||
n=count,
|
||||
style=style,
|
||||
reference_images=reference_images,
|
||||
provider=sprite,
|
||||
on_draft=_on_draft,
|
||||
is_cancelled=lambda: _pet_is_cancelled(token),
|
||||
)
|
||||
except GenerationError as exc:
|
||||
_pet_cancel_release(token)
|
||||
return _err(rid, 5031, str(exc))
|
||||
|
||||
cancelled = _pet_is_cancelled(token)
|
||||
_pet_cancel_release(token)
|
||||
if cancelled:
|
||||
return _err(rid, 5031, "generation cancelled")
|
||||
if not out:
|
||||
return _err(rid, 5031, "generation produced no usable drafts")
|
||||
out.sort(key=lambda d: d["index"])
|
||||
return _ok(rid, {"ok": True, "token": token, "drafts": out})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.generate failed: %s", exc)
|
||||
return _err(rid, 5031, f"pet.generate failed: {exc}")
|
||||
|
||||
|
||||
@method("pet.hatch")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Turn a chosen base draft into a full pet — installed but NOT yet active.
|
||||
|
||||
Generation is expensive and the result varies, so hatch produces a *preview*
|
||||
the surface plays (all frames) before the user commits: the pet is written to
|
||||
the store (so it can be rendered + later activated) but the active pet is left
|
||||
untouched. Adopt with ``pet.select`` or throw it away with ``pet.remove``.
|
||||
|
||||
Params: ``token`` + ``index`` (from ``pet.generate``), ``name`` (required),
|
||||
``description`` (optional), ``prompt`` (optional concept for row prompts),
|
||||
``style`` (optional). Returns ``{ok, slug, displayName, warnings, pet}`` where
|
||||
``pet`` is the renderer payload. Heavy (network + raster): worker pool.
|
||||
"""
|
||||
token = str(params.get("token") or "").strip()
|
||||
# Hatch cancellation rides its own key, not the generation token: hatching a
|
||||
# draft mid-generation means pet.generate is still releasing `token`, which
|
||||
# would otherwise wipe the arm we set here. Falls back to `token` for clients
|
||||
# that don't send one.
|
||||
cancel_token = str(params.get("cancelToken") or "").strip() or token
|
||||
index = params.get("index", 0)
|
||||
name = str(params.get("name") or "").strip()
|
||||
if not token:
|
||||
return _err(rid, 4004, "missing token")
|
||||
if not name:
|
||||
return _err(rid, 4004, "missing name")
|
||||
try:
|
||||
index = int(index)
|
||||
except (TypeError, ValueError):
|
||||
index = 0
|
||||
|
||||
try:
|
||||
from agent.pet import store
|
||||
from agent.pet.generate import hatch_pet
|
||||
from agent.pet.generate.imagegen import GenerationError, resolve_provider
|
||||
|
||||
base = _pet_gen_root() / token / f"draft-{index}.png"
|
||||
if not base.is_file():
|
||||
return _err(rid, 4004, "draft expired — generate again")
|
||||
|
||||
# Optional desktop picker override (rows always need reference grounding).
|
||||
provider_name = str(params.get("provider") or "").strip()
|
||||
sprite = None
|
||||
if provider_name:
|
||||
try:
|
||||
sprite = resolve_provider(require_references=True, prefer=provider_name)
|
||||
except GenerationError as exc:
|
||||
return _err(rid, 5031, str(exc))
|
||||
|
||||
_pet_cancel_arm(cancel_token)
|
||||
slug = store.unique_slug(name)
|
||||
|
||||
def _on_progress(event: str, detail: str) -> None:
|
||||
# Row progress is encoded as "<state>:<done>:<total>" so the egg
|
||||
# screen can show "Drawing <state>… (n/total)"; other phases
|
||||
# (compose, save) pass through as-is. Best-effort streaming.
|
||||
payload: dict = {"event": event, "detail": detail}
|
||||
if event == "row" and detail.count(":") == 2:
|
||||
state, done, total = detail.split(":")
|
||||
payload = {"event": "row", "state": state, "done": done, "total": total}
|
||||
try:
|
||||
_emit("pet.hatch.progress", "", payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.hatch progress emit failed: %s", exc)
|
||||
|
||||
try:
|
||||
result = hatch_pet(
|
||||
base_image=base,
|
||||
slug=slug,
|
||||
display_name=name,
|
||||
description=str(params.get("description") or ""),
|
||||
concept=str(params.get("prompt") or name),
|
||||
style=str(params.get("style") or "auto").strip() or "auto",
|
||||
provider=sprite,
|
||||
on_progress=_on_progress,
|
||||
is_cancelled=lambda: _pet_is_cancelled(cancel_token),
|
||||
)
|
||||
except GenerationError as exc:
|
||||
return _err(rid, 5031, str(exc))
|
||||
finally:
|
||||
_pet_cancel_release(cancel_token)
|
||||
|
||||
pet = store.load_pet(result.slug)
|
||||
payload = _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {}
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": True,
|
||||
"slug": result.slug,
|
||||
"displayName": result.display_name,
|
||||
"warnings": result.validation.get("warnings", []),
|
||||
"pet": payload,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.hatch failed: %s", exc)
|
||||
return _err(rid, 5031, f"pet.hatch failed: {exc}")
|
||||
|
||||
|
||||
@method("credits.view")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Structured Nous credit view for the TUI /credits command.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue