mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
feat(pets): polish generate flow and reduce hatch CPU pressure
Ship the final pet-generation UX polish (provider picker behavior, step-2 cancel flow, banner integration, and visual consistency) and make saturated-chroma background removal C-op driven so hatch processing no longer hammers the machine during long runs.
This commit is contained in:
parent
b674f7ba28
commit
1fe013ee16
35 changed files with 2013 additions and 729 deletions
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)
|
||||
}
|
||||
}
|
||||
291
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
291
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Egg, ImageIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petGenAvailable,
|
||||
$petGenDrafts,
|
||||
$petGenError,
|
||||
$petGenInput,
|
||||
$petGenPreview,
|
||||
$petGenRefImage,
|
||||
$petGenRefName,
|
||||
$petGenSelected,
|
||||
$petGenStage,
|
||||
$petGenStatus,
|
||||
adoptHatched,
|
||||
cancelGenerate,
|
||||
checkPetGenAvailable,
|
||||
cleanPetName,
|
||||
closePetGenerate,
|
||||
discardDrafts,
|
||||
discardHatched,
|
||||
generateDrafts,
|
||||
hatchSelected
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
import { DraftGrid } from './components/draft-grid'
|
||||
import { EmptyHint } from './components/empty-hint'
|
||||
import { GenerateUnavailable } from './components/generate-unavailable'
|
||||
import { HatchPreview } from './components/hatch-preview'
|
||||
import { HatchingView } from './components/hatching-view'
|
||||
import { ProviderPicker } from './components/provider-picker'
|
||||
import { ReferenceChip } from './components/reference-chip'
|
||||
import { readReferenceImage } from './lib/read-reference-image'
|
||||
|
||||
// The generate → hatch → adopt controller. A thin view over the `pet-generate`
|
||||
// store; the store owns the steps and persists inputs across close/reopen.
|
||||
export function PetGenerateContent() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const available = useStore($petGenAvailable)
|
||||
// `null` = not yet probed → stay optimistic (show the prompt); only the
|
||||
// confirmed-no-backend case swaps in the setup card.
|
||||
const unavailable = available === false
|
||||
const drafts = useStore($petGenDrafts)
|
||||
const selected = useStore($petGenSelected)
|
||||
const preview = useStore($petGenPreview)
|
||||
const stage = useStore($petGenStage)
|
||||
|
||||
// Inputs live in atoms so they survive a close/reopen (and background runs).
|
||||
const prompt = useStore($petGenInput)
|
||||
const refImage = useStore($petGenRefImage)
|
||||
const refName = useStore($petGenRefName)
|
||||
const fileRef = useRef<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}
|
||||
onCancel={cancelGenerate}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,92 +6,37 @@
|
|||
* breathe: a device-framed header, its own concept prompt, a roomy draft grid
|
||||
* that streams in live, and the egg-hatch + reveal flow. It's a thin view over
|
||||
* the `pet-generate` store; the store owns the generate → hatch → adopt steps.
|
||||
*
|
||||
* This file is just the dialog shell + sizing; the flow lives in
|
||||
* `PetGenerateContent`, and each screen is its own atomic component under
|
||||
* `./components`.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
|
||||
import { PetStarShower } from '@/components/pet/pet-star-shower'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Egg, Loader2, PawPrint, RefreshCw, Settings2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
import {
|
||||
$petGenAvailable,
|
||||
$petGenDrafts,
|
||||
$petGenerateOpen,
|
||||
$petGenError,
|
||||
$petGenPreview,
|
||||
$petGenSelected,
|
||||
$petGenStage,
|
||||
$petGenStatus,
|
||||
adoptHatched,
|
||||
cancelGenerate,
|
||||
cancelHatch,
|
||||
checkPetGenAvailable,
|
||||
cleanPetName,
|
||||
cleanupPetGen,
|
||||
closePetGenerate,
|
||||
discardHatched,
|
||||
generateDrafts,
|
||||
hatchSelected
|
||||
cleanupPetGenOnClose,
|
||||
closePetGenerate
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
const VARIANT_COUNT = 4
|
||||
const PREVIEW_SCALE = 0.7
|
||||
const PREVIEW_ROWS = [
|
||||
'idle',
|
||||
'waving',
|
||||
'running-right',
|
||||
'running-left',
|
||||
'running',
|
||||
'review',
|
||||
'jumping',
|
||||
'failed',
|
||||
'waiting'
|
||||
]
|
||||
const PREVIEW_STATE_MS = 1400
|
||||
|
||||
const ROW_TO_FRAME_KEY: Record<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'
|
||||
}
|
||||
|
||||
function frameCountForRow(pet: PetInfo, row: string): number {
|
||||
const byState = pet.framesByState
|
||||
const mapped = ROW_TO_FRAME_KEY[row]
|
||||
return byState?.[row] ?? (mapped ? byState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
|
||||
}
|
||||
import { PetGenerateContent } from './pet-generate-content'
|
||||
|
||||
export function PetGenerateOverlay() {
|
||||
const { t } = useI18n()
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const open = useStore($petGenerateOpen)
|
||||
const status = useStore($petGenStatus)
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const error = useStore($petGenError)
|
||||
const drafts = useStore($petGenDrafts)
|
||||
|
||||
// Yield the screen to a full-screen route overlay (e.g. /settings while the
|
||||
// user adds an image-gen key) without tearing down — the store keeps us open,
|
||||
|
|
@ -102,449 +47,39 @@ export function PetGenerateOverlay() {
|
|||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
// Deletes a hatched-but-unadopted preview pet so it doesn't linger, then
|
||||
// resets all generation state.
|
||||
cleanupPetGen(requestGateway)
|
||||
cleanupPetGenOnClose(requestGateway)
|
||||
// Never interrupt in-flight work. Generating/hatching continues in the
|
||||
// background; only an unadopted finished preview is discarded on close.
|
||||
closePetGenerate()
|
||||
}
|
||||
}
|
||||
|
||||
// The draft screen needs room for the 2×2 grid; the single-pet screens
|
||||
// (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box.
|
||||
// `fitContent` lets the dialog size to content; the `min-w` floors each phase.
|
||||
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
// The footer banner narrates the dialog's async state: the failure reason on a
|
||||
// dead-end error, else the "you can close this, we'll notify you" reassurance
|
||||
// while a generate/hatch runs in the background.
|
||||
const working = status === 'generating' || status === 'hatching'
|
||||
const errored = status === 'error' && drafts.length === 0
|
||||
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : undefined
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn('max-w-none gap-4 text-center', single ? 'w-[min(17rem,92vw)]' : 'w-[min(23rem,92vw)]')}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function PetGenerateContent() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const available = useStore($petGenAvailable)
|
||||
// `null` = not yet probed → stay optimistic (show the prompt); only the
|
||||
// confirmed-no-backend case swaps in the setup card.
|
||||
const unavailable = available === false
|
||||
const drafts = useStore($petGenDrafts)
|
||||
const selected = useStore($petGenSelected)
|
||||
const preview = useStore($petGenPreview)
|
||||
const stage = useStore($petGenStage)
|
||||
|
||||
const [prompt, setPrompt] = useState('')
|
||||
|
||||
// Probe backend availability on open — and again whenever the content
|
||||
// remounts (e.g. after returning from the providers settings), so adding a
|
||||
// key flips the setup card to the prompt with no manual refresh.
|
||||
useEffect(() => {
|
||||
void checkPetGenAvailable(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const busy = status === 'generating' || status === 'hatching'
|
||||
const hasDrafts = drafts.length > 0
|
||||
const generating = status === 'generating'
|
||||
// The idle "describe a pet" state — egg + suggestions get generous, equidistant
|
||||
// breathing room (gap-7.5) from the prompt; the working states stay compact.
|
||||
const isEmptyState =
|
||||
!hasDrafts &&
|
||||
!generating &&
|
||||
status !== 'hatching' &&
|
||||
status !== 'preview' &&
|
||||
status !== 'adopting' &&
|
||||
status !== 'stale'
|
||||
|
||||
const close = () => {
|
||||
cleanupPetGen(requestGateway)
|
||||
closePetGenerate()
|
||||
}
|
||||
|
||||
const generate = () => {
|
||||
if (prompt.trim() && !busy) {
|
||||
void generateDrafts(requestGateway, { prompt: prompt.trim() })
|
||||
}
|
||||
}
|
||||
|
||||
// One-click an example prompt straight into a draft round.
|
||||
const runExample = (example: string) => {
|
||||
setPrompt(example)
|
||||
void generateDrafts(requestGateway, { prompt: example })
|
||||
}
|
||||
|
||||
// Hatch with a clean default name derived from the prompt (the prompt itself
|
||||
// is grounding text, not a label); the user names it on the reveal screen.
|
||||
const hatch = () => {
|
||||
if (prompt.trim()) {
|
||||
void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() })
|
||||
}
|
||||
}
|
||||
|
||||
const adopt = (finalName: string) => {
|
||||
void adoptHatched(requestGateway, finalName).then(out => {
|
||||
if (out.ok) {
|
||||
triggerHaptic('crisp')
|
||||
close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The header title tracks the phase instead of sticking on "Generate a pet".
|
||||
const headerTitle =
|
||||
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
|
||||
// Send the user to set up a key without closing — the overlay yields to the
|
||||
// settings route (useRouteOverlayActive) and reappears + re-checks on return.
|
||||
const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
|
||||
// Prompt input only belongs on the describe/draft screens (and never when
|
||||
// there's no backend to generate with).
|
||||
const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting'
|
||||
|
||||
return (
|
||||
<>
|
||||
{unavailable ? (
|
||||
<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="relative">
|
||||
<Input
|
||||
autoFocus
|
||||
className="pr-9"
|
||||
onChange={event => setPrompt(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()}
|
||||
generating={generating}
|
||||
generatingLabel={t.common.cancel}
|
||||
label={copy.generate}
|
||||
onCancel={cancelGenerate}
|
||||
onGenerate={generate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !unavailable && status !== 'preview' && status !== 'adopting' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</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 ? (
|
||||
<EmptyHint onExample={runExample} />
|
||||
) : (
|
||||
<DraftGrid
|
||||
busy={busy}
|
||||
drafts={drafts}
|
||||
generating={generating}
|
||||
hasDrafts={hasDrafts}
|
||||
onHatch={hatch}
|
||||
onSelect={index => $petGenSelected.set(index)}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Creative seed prompts — specifics make better pets (petdex's own advice).
|
||||
// Doubling as guidance and a one-click way to see the flow.
|
||||
const EXAMPLE_PROMPTS = ['a bubble-tea otter', 'a tiny sock elf', 'a pixel dragon', 'a grumpy office cat', 'a neon axolotl']
|
||||
|
||||
// Shown when no reference-capable image backend is configured: generation is
|
||||
// impossible, so we replace the prompt entirely with a friendly path to set one
|
||||
// up (in-app) plus where to grab a key.
|
||||
function GenerateUnavailable({ onSetup }: { onSetup: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 px-2 py-6 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>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHint({ onExample }: { onExample: (prompt: string) => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">Need a spark?</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||
{EXAMPLE_PROMPTS.map(example => (
|
||||
<Button className="rounded-full" key={example} onClick={() => onExample(example)} size="xs" variant="outline">
|
||||
{example}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HatchingView({ stage }: { stage: { phase: string; state?: string; done?: number; total?: number } | null }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const subtitle = stage
|
||||
? stage.phase === 'row'
|
||||
? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0)
|
||||
: stage.phase === 'compose'
|
||||
? copy.hatchComposing
|
||||
: copy.hatchSaving
|
||||
: copy.hatchingSub
|
||||
|
||||
return <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
|
||||
}
|
||||
|
||||
interface DraftGridProps {
|
||||
busy: boolean
|
||||
drafts: { index: number; dataUri: string }[]
|
||||
generating: boolean
|
||||
hasDrafts: boolean
|
||||
onHatch: () => void
|
||||
onSelect: (index: number) => void
|
||||
selected: number | null
|
||||
}
|
||||
|
||||
function DraftGrid({ busy, drafts, generating, hasDrafts, onHatch, onSelect, selected }: DraftGridProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const slots = generating
|
||||
? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null)
|
||||
: drafts
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{generating && (
|
||||
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span className="shimmer">{copy.generating}</span>
|
||||
<span className="tabular-nums">
|
||||
{drafts.length}/{VARIANT_COUNT}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{slots.map((draft, i) => {
|
||||
const isSelected = !generating && 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={generating || busy || 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 resting 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 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasDrafts && (
|
||||
<Button className="w-full" disabled={busy || selected === null} onClick={onHatch}>
|
||||
<PawPrint />
|
||||
{copy.hatch}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HatchPreviewProps {
|
||||
pet: PetInfo
|
||||
adopting: boolean
|
||||
error: string | null
|
||||
onAdopt: (name: string) => void
|
||||
onDiscard: () => void
|
||||
}
|
||||
|
||||
function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
// Empty so the "Name your pet" placeholder shows; blank adopt keeps the
|
||||
// provisional name from the prompt.
|
||||
const [name, setName] = useState('')
|
||||
// Play the egg's crack/hatch frames once before swapping in the live pet.
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
// Right after the egg cracks the pet plays its "yay" jump a couple times, then
|
||||
// hands off to the normal state-cycling preview.
|
||||
const [celebrating, setCelebrating] = useState(false)
|
||||
const [stateIndex, setStateIndex] = useState(0)
|
||||
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
|
||||
const rows = previewRows.length > 0 ? previewRows : ['idle']
|
||||
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
|
||||
const canJump = frameCountForRow(pet, 'jumping') > 0
|
||||
const rowOverride = celebrating && canJump ? 'jumping' : activeRow
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [rows.length])
|
||||
|
||||
// On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview.
|
||||
useEffect(() => {
|
||||
if (!revealed) {
|
||||
return
|
||||
}
|
||||
setCelebrating(true)
|
||||
const id = setTimeout(() => {
|
||||
setCelebrating(false)
|
||||
setStateIndex(0)
|
||||
}, 2 * (pet.loopMs ?? 1100))
|
||||
return () => clearTimeout(id)
|
||||
}, [revealed, pet.loopMs])
|
||||
|
||||
useEffect(() => {
|
||||
setStateIndex(0)
|
||||
setName('')
|
||||
setRevealed(false)
|
||||
setCelebrating(false)
|
||||
}, [pet.slug])
|
||||
|
||||
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
{/* 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="pet-reveal">
|
||||
<PetSprite info={previewInfo} rowOverride={rowOverride} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -44,14 +44,16 @@ export function PetProgress({ done, total }: { done?: number; total?: number })
|
|||
|
||||
export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-2 py-5">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<PixelEggSprite mode="bounce" size={88} />
|
||||
<span className="pet-egg-shadow mt-1.5" />
|
||||
{/* 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 max-w-[15rem] text-center text-[length:var(--conversation-caption-font-size)] leading-snug">
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
const frameH = info.frameH ?? DEFAULT_FRAME_H
|
||||
const frames = info.framesPerState ?? DEFAULT_FRAMES
|
||||
const framesByState = info.framesByState
|
||||
const framesByRow = info.framesByRow
|
||||
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
|
||||
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
|
||||
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
|
||||
|
|
@ -134,6 +135,8 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
let lastStep = performance.now()
|
||||
let drawnFrame = -1
|
||||
let drawnRow = -1
|
||||
let activeRow = -1
|
||||
let activeCount = -1
|
||||
|
||||
const rowIndexForState = (s: PetState): number => {
|
||||
for (const key of STATE_ALIASES[s] ?? [s]) {
|
||||
|
|
@ -161,13 +164,25 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
const resolveRow = (rowName: string): { row: number; count: number } => {
|
||||
const row = rows.indexOf(rowName)
|
||||
const state = ROW_TO_STATE[rowName]
|
||||
const count = Math.max(1, framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames)
|
||||
const count = Math.max(
|
||||
1,
|
||||
framesByRow?.[rowName] ?? framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames
|
||||
)
|
||||
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
|
||||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
const forcedRow = rowOverrideRef.current
|
||||
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
|
||||
|
||||
if (row !== activeRow || count !== activeCount) {
|
||||
activeRow = row
|
||||
activeCount = count
|
||||
frame = 0
|
||||
lastStep = now
|
||||
drawnFrame = -1
|
||||
}
|
||||
|
||||
// Per-state step keeps every state's loop ~loopMs even when frame counts
|
||||
// differ; counts vary per row so derive the cadence here, not once.
|
||||
const stepMs = loopMs / count
|
||||
|
|
@ -201,7 +216,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
cancelAnimationFrame(raf)
|
||||
unsubState()
|
||||
}
|
||||
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -788,13 +788,17 @@ export const en: Translations = {
|
|||
hatch: 'Hatch',
|
||||
spawning: 'Spawning…',
|
||||
hatching: 'Hatching your pet…',
|
||||
hatchingSub: 'Bringing every frame to life — this takes a moment.',
|
||||
hatchingSub: 'Bringing it to life…',
|
||||
hatched: 'It hatched!',
|
||||
hatchRow: (state, done, total) => `Drawing ${state}… ${done}/${total}`,
|
||||
hatchComposing: 'Composing the spritesheet…',
|
||||
hatchSaving: 'Saving your pet…',
|
||||
hatchRow: (_state, done, total) => `Sketching frame ${done} of ${total}…`,
|
||||
hatchComposing: 'Piecing it together…',
|
||||
hatchSaving: 'Almost there…',
|
||||
namePlaceholder: 'Name your pet',
|
||||
staleBackend: 'Update Hermes to generate pets.',
|
||||
backgroundHint: 'You can close this — Hermes will notify you when it’s done.',
|
||||
genericError: 'Generation failed — try again or pick a suggestion.',
|
||||
referenceImageTooLarge: 'Reference image is too large. Use one under 16 MB.',
|
||||
referenceImageInvalid: 'Could not read that reference image. Try a PNG, JPG, WebP, or GIF.',
|
||||
adopt: 'Adopt',
|
||||
startOver: 'Start over'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -908,13 +908,17 @@ export const ja = defineLocale({
|
|||
hatch: '孵化',
|
||||
spawning: 'スポーン中…',
|
||||
hatching: 'ペットを孵化しています…',
|
||||
hatchingSub: 'すべてのフレームに命を吹き込んでいます。少々お待ちください。',
|
||||
hatchingSub: '命を吹き込んでいます…',
|
||||
hatched: '孵化しました!',
|
||||
hatchRow: (state, done, total) => `${state} を描画中… ${done}/${total}`,
|
||||
hatchComposing: 'スプライトシートを合成中…',
|
||||
hatchSaving: 'ペットを保存中…',
|
||||
hatchRow: (_state, done, total) => `フレームを描画中… ${done}/${total}`,
|
||||
hatchComposing: 'まとめています…',
|
||||
hatchSaving: 'もうすぐです…',
|
||||
namePlaceholder: 'ペットに名前を付ける',
|
||||
staleBackend: 'ペットを生成するには Hermes を更新してください。',
|
||||
backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。',
|
||||
genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。',
|
||||
referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。',
|
||||
referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。',
|
||||
adopt: '迎え入れる',
|
||||
startOver: 'やり直す'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -670,6 +670,10 @@ export interface Translations {
|
|||
hatchSaving: string
|
||||
namePlaceholder: string
|
||||
staleBackend: string
|
||||
backgroundHint: string
|
||||
genericError: string
|
||||
referenceImageTooLarge: string
|
||||
referenceImageInvalid: string
|
||||
adopt: string
|
||||
startOver: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -878,13 +878,17 @@ export const zhHant = defineLocale({
|
|||
hatch: '孵化',
|
||||
spawning: '召喚中……',
|
||||
hatching: '正在孵化你的寵物……',
|
||||
hatchingSub: '正在為每一格注入生命——請稍候。',
|
||||
hatchingSub: '正在注入生命……',
|
||||
hatched: '孵化成功!',
|
||||
hatchRow: (state, done, total) => `正在繪製 ${state}…… ${done}/${total}`,
|
||||
hatchComposing: '正在合成精靈表……',
|
||||
hatchSaving: '正在儲存你的寵物……',
|
||||
hatchRow: (_state, done, total) => `正在繪製畫面…… ${done}/${total}`,
|
||||
hatchComposing: '正在拼合……',
|
||||
hatchSaving: '快好了……',
|
||||
namePlaceholder: '為寵物命名',
|
||||
staleBackend: '請更新 Hermes 以生成寵物。',
|
||||
backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。',
|
||||
genericError: '生成失敗——請重試或選一個建議。',
|
||||
referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。',
|
||||
referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。',
|
||||
adopt: '領養',
|
||||
startOver: '重新開始'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -975,13 +975,17 @@ export const zh: Translations = {
|
|||
hatch: '孵化',
|
||||
spawning: '召唤中……',
|
||||
hatching: '正在孵化你的宠物……',
|
||||
hatchingSub: '正在为每一帧注入生命——请稍候。',
|
||||
hatchingSub: '正在注入生命……',
|
||||
hatched: '孵化成功!',
|
||||
hatchRow: (state, done, total) => `正在绘制 ${state}…… ${done}/${total}`,
|
||||
hatchComposing: '正在合成精灵表……',
|
||||
hatchSaving: '正在保存你的宠物……',
|
||||
hatchRow: (_state, done, total) => `正在绘制画面…… ${done}/${total}`,
|
||||
hatchComposing: '正在拼合……',
|
||||
hatchSaving: '马上就好……',
|
||||
namePlaceholder: '给宠物起个名字',
|
||||
staleBackend: '请更新 Hermes 以生成宠物。',
|
||||
backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。',
|
||||
genericError: '生成失败——请重试或选择一个建议。',
|
||||
referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。',
|
||||
referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。',
|
||||
adopt: '领养',
|
||||
startOver: '重新开始'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
import { type GatewayRequest, applyAdoptedPet } from '@/store/pet-gallery'
|
||||
import { applyAdoptedPet, type GatewayRequest } from '@/store/pet-gallery'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
/**
|
||||
* Feature store for the "generate a pet" flow (Cmd-K → Pets → Generate).
|
||||
|
|
@ -57,8 +61,10 @@ export function cleanPetName(prompt: string): string {
|
|||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
|
||||
const meaningful = words.filter(w => !NAME_STOPWORDS.has(w.toLowerCase()))
|
||||
const picked = (meaningful.length ? meaningful : words).slice(0, 3)
|
||||
|
||||
const name = picked
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
|
|
@ -101,11 +107,42 @@ export const $petGenError = atom<string | null>(null)
|
|||
// 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 }>('pet.generate.status')
|
||||
const res = await request<{ available: boolean; providers?: PetGenProvider[] }>('pet.generate.status')
|
||||
$petGenAvailable.set(Boolean(res?.available))
|
||||
const providers = res?.providers ?? []
|
||||
$petGenProviders.set(providers)
|
||||
// Drop a stale pick if that backend is no longer configured.
|
||||
const picked = $petGenProvider.get()
|
||||
|
||||
if (picked && !providers.some(p => p.name === picked)) {
|
||||
setPetGenProvider('')
|
||||
}
|
||||
} catch {
|
||||
// Unknown (old backend / transient) — don't gate the UI on a failed probe.
|
||||
$petGenAvailable.set(true)
|
||||
|
|
@ -116,14 +153,20 @@ export async function checkPetGenAvailable(request: GatewayRequest): Promise<voi
|
|||
export const $petGenerateOpen = atom(false)
|
||||
|
||||
export function openPetGenerate(): void {
|
||||
// Always open on a clean slate — don't resurface the last run's drafts/preview.
|
||||
resetPetGen()
|
||||
// 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>('')
|
||||
|
|
@ -132,13 +175,20 @@ 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 (on close, or before a fresh run). */
|
||||
/** Clear all generation state (before a fresh run). */
|
||||
export function resetPetGen(): void {
|
||||
$petGenStatus.set('idle')
|
||||
$petGenStage.set(null)
|
||||
|
|
@ -148,26 +198,44 @@ export function resetPetGen(): void {
|
|||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenPreview.set(null)
|
||||
$petGenInput.set('')
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset on palette close, deleting an unadopted preview pet first so a hatched-
|
||||
* but-never-adopted creature doesn't linger in the gallery. Fire-and-forget.
|
||||
* Close-time cleanup: if a pet is already hatched but not adopted, discard it so
|
||||
* abandoned previews do not accumulate on disk. In-flight generate/hatch runs
|
||||
* are intentionally left alone (background-resumable).
|
||||
*/
|
||||
export function cleanupPetGen(request: GatewayRequest): void {
|
||||
export function cleanupPetGenOnClose(request: GatewayRequest): void {
|
||||
const status = $petGenStatus.get()
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if ($petGenStatus.get() === 'preview' && preview?.slug) {
|
||||
if ((status === 'preview' || status === 'adopting') && preview?.slug) {
|
||||
void request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
resetPetGen()
|
||||
}
|
||||
}
|
||||
|
||||
// A finished background run (overlay closed) nudges the user back: an in-app
|
||||
// toast with a View action always, plus an OS notification when enabled and the
|
||||
// app is in the background. Clicking either reopens the overlay to its state.
|
||||
function notifyPetGenDone(title: string, message: string, kind: 'error' | 'success'): void {
|
||||
if ($petGenerateOpen.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
resetPetGen()
|
||||
notify({ kind, title, message, action: { label: 'View', onClick: openPetGenerate } })
|
||||
dispatchNativeNotification({ kind: 'backgroundDone', title, body: message, sessionId: $activeSessionId.get() })
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
prompt: string
|
||||
style?: string
|
||||
count?: number
|
||||
/** Optional data-URL reference image — every draft is grounded on it. */
|
||||
referenceImage?: string
|
||||
}
|
||||
|
||||
// A Stop (or a fresh round) must invalidate the in-flight call. This primitive
|
||||
|
|
@ -185,6 +253,7 @@ interface Run {
|
|||
function cancelableRun(): Run {
|
||||
let id = 0
|
||||
let cancel: (() => void) | null = null
|
||||
|
||||
return {
|
||||
begin: () => (id += 1),
|
||||
isCurrent: n => n === id,
|
||||
|
|
@ -216,11 +285,14 @@ export function cancelGenerate(): void {
|
|||
$petGenError.set(null)
|
||||
|
||||
const drafts = $petGenDrafts.get()
|
||||
|
||||
if (drafts.length > 0) {
|
||||
if ($petGenSelected.get() === null) {
|
||||
$petGenSelected.set(drafts[0]?.index ?? 0)
|
||||
}
|
||||
|
||||
$petGenStatus.set('ready')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +302,19 @@ export function cancelGenerate(): void {
|
|||
$petGenToken.set(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandon the current drafts and return to the prompt (step 1). Stops any
|
||||
* in-flight generation; keeps the prompt text so the user can tweak + retry.
|
||||
*/
|
||||
export function discardDrafts(): void {
|
||||
gen.stop()
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenToken.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set('idle')
|
||||
}
|
||||
|
||||
const hatch = cancelableRun()
|
||||
|
||||
// A Stop invalidates the in-flight hatch and drops back to the draft picker (the
|
||||
|
|
@ -245,8 +330,10 @@ export function cancelHatch(): void {
|
|||
/** Generate (or retry) a fresh set of base-look drafts for `prompt`. */
|
||||
export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise<boolean> {
|
||||
const prompt = options.prompt.trim()
|
||||
const referenceImage = options.referenceImage
|
||||
|
||||
if (!prompt) {
|
||||
// Need *something* to ground on: a description or a reference image.
|
||||
if (!prompt && !referenceImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +342,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
|
|||
gen.arm(() => {
|
||||
controller.abort()
|
||||
const token = $petGenToken.get()
|
||||
|
||||
if (token) {
|
||||
void request('pet.cancel', { token }).catch(() => {})
|
||||
}
|
||||
|
|
@ -262,6 +350,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
|
|||
|
||||
// Starting a fresh generation round supersedes any unadopted preview pet.
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (preview?.slug) {
|
||||
await request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
|
@ -284,6 +373,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
|
|||
if (gen.isCurrent(runId) && $petGenStatus.get() === 'generating') {
|
||||
$petGenToken.set(draft.token)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +392,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
|
|||
}
|
||||
|
||||
const current = $petGenDrafts.get()
|
||||
|
||||
if (current.some(d => d.index === draft.index)) {
|
||||
return
|
||||
}
|
||||
|
|
@ -317,7 +408,9 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
|
|||
{
|
||||
prompt,
|
||||
style: options.style ?? 'auto',
|
||||
count: options.count ?? 4
|
||||
count: options.count ?? 4,
|
||||
...(referenceImage ? { referenceImage } : {}),
|
||||
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
|
||||
},
|
||||
GENERATE_TIMEOUT_MS,
|
||||
controller.signal
|
||||
|
|
@ -333,10 +426,12 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
|
|||
}
|
||||
|
||||
$petGenToken.set(result.token)
|
||||
$petGenPrompt.set(prompt)
|
||||
// Keep a concept for the hatch row prompts even on an image-only generate.
|
||||
$petGenPrompt.set(prompt || 'a custom pet')
|
||||
$petGenDrafts.set(result.drafts)
|
||||
$petGenSelected.set(result.drafts[0]?.index ?? 0)
|
||||
$petGenStatus.set('ready')
|
||||
notifyPetGenDone('Pet drafts ready', 'Your pet looks finished — pick one to hatch.', 'success')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
|
|
@ -349,6 +444,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
|
|||
} else {
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not generate pet drafts.')
|
||||
notifyPetGenDone('Pet generation failed', 'Reopen to try again.', 'error')
|
||||
}
|
||||
|
||||
return false
|
||||
|
|
@ -381,11 +477,15 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio
|
|||
return false
|
||||
}
|
||||
|
||||
// Hatch cancellation rides its own token (not the draft token): hatching
|
||||
// mid-generation leaves pet.generate releasing that token, which would race
|
||||
// the arm. The draft token still locates the staged image server-side.
|
||||
const cancelToken = crypto.randomUUID()
|
||||
const hatchRunId = hatch.begin()
|
||||
const controller = new AbortController()
|
||||
hatch.arm(() => {
|
||||
controller.abort()
|
||||
void request('pet.cancel', { token }).catch(() => {})
|
||||
void request('pet.cancel', { token: cancelToken }).catch(() => {})
|
||||
})
|
||||
|
||||
$petGenStatus.set('hatching')
|
||||
|
|
@ -399,6 +499,7 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio
|
|||
.get()
|
||||
?.on<{ event: string; state?: string; done?: string; total?: string }>('pet.hatch.progress', event => {
|
||||
const p = event.payload
|
||||
|
||||
if (!p || !hatch.isCurrent(hatchRunId) || $petGenStatus.get() !== 'hatching') {
|
||||
return
|
||||
}
|
||||
|
|
@ -422,11 +523,13 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio
|
|||
'pet.hatch',
|
||||
{
|
||||
token,
|
||||
cancelToken,
|
||||
index,
|
||||
name,
|
||||
description: options.description ?? '',
|
||||
prompt: concept,
|
||||
style: options.style ?? 'auto'
|
||||
style: options.style ?? 'auto',
|
||||
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
|
||||
},
|
||||
HATCH_TIMEOUT_MS,
|
||||
controller.signal
|
||||
|
|
@ -437,6 +540,7 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio
|
|||
if (result?.slug) {
|
||||
void request('pet.remove', { slug: result.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -446,6 +550,7 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio
|
|||
|
||||
$petGenPreview.set({ ...result.pet, enabled: true })
|
||||
$petGenStatus.set('preview')
|
||||
notifyPetGenDone('Your pet hatched', 'Reopen to name and adopt it.', 'success')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
|
|
@ -455,10 +560,12 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio
|
|||
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not hatch the pet.')
|
||||
notifyPetGenDone('Hatching failed', 'Reopen to try again.', 'error')
|
||||
|
||||
return false
|
||||
} finally {
|
||||
offProgress()
|
||||
|
||||
if (hatch.isCurrent(hatchRunId)) {
|
||||
$petGenStage.set(null)
|
||||
hatch.disarmIf(hatchRunId)
|
||||
|
|
@ -494,11 +601,13 @@ export async function adoptHatched(request: GatewayRequest, name?: string): Prom
|
|||
// rename failure shouldn't block adopting under the provisional slug.
|
||||
const finalName = name?.trim()
|
||||
let adoptSlug = preview.slug
|
||||
|
||||
if (finalName && finalName !== preview.displayName) {
|
||||
const renamed = await request<{ ok: boolean; slug: string }>('pet.rename', {
|
||||
slug: preview.slug,
|
||||
name: finalName
|
||||
}).catch(() => null)
|
||||
|
||||
if (renamed?.slug) {
|
||||
adoptSlug = renamed.slug
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -1491,16 +1491,35 @@ canvas {
|
|||
width: 4.5rem;
|
||||
height: 0.8rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, #000 32%, transparent) 0%, transparent 72%);
|
||||
/* Lighter on light backgrounds (~20% less ink); dark mode keeps it grounded. */
|
||||
background: radial-gradient(circle, color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%, transparent 72%);
|
||||
animation: pet-egg-shadow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .pet-egg-shadow {
|
||||
--pet-egg-shadow-ink: 32%;
|
||||
}
|
||||
|
||||
/* Contact shadow sized for the compact incubator egg (roughly its footprint). */
|
||||
.pet-egg-shadow--sm {
|
||||
width: 3rem;
|
||||
height: 0.6rem;
|
||||
}
|
||||
|
||||
/* Contact shadow under the revealed pet — mirrors the floating mascot's in-app
|
||||
shadow: an ellipse at the feet, ~55% of the sprite width, sitting behind it. */
|
||||
.pet-contact-shadow {
|
||||
position: absolute;
|
||||
bottom: -0.15rem;
|
||||
left: 50%;
|
||||
width: 55%;
|
||||
aspect-ratio: 100 / 28;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(ellipse at center, color-mix(in srgb, #000 42%, transparent) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Hatch wiggle for the pixel egg (rocks around its base). */
|
||||
.pet-wobble {
|
||||
transform-origin: 50% 85%;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue