mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(pets): offer backend setup when generation is unavailable
When no reference-capable image backend is configured, generating a pet is impossible — so instead of a dead prompt + post-hoc error, the overlay now detects it up front and offers a way out: - pet.generate.status RPC reports whether a reference-capable provider (OpenRouter / Nous Portal / OpenAI) is set up; the overlay probes it on open and swaps the prompt for a friendly setup card (paw, one-line copy, "Set up image generation" → /settings?tab=providers, key links). - useRouteOverlayActive(): reusable hook so any portaled modal yields the screen to a full-screen route overlay (e.g. settings) and reappears — re-running its mount effects — on return, instead of closing. The probe re-runs on that remount, so adding a key flips the card to the prompt.
This commit is contained in:
parent
743985bf1e
commit
b674f7ba28
4 changed files with 145 additions and 8 deletions
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))
|
||||
}
|
||||
|
|
@ -10,8 +10,11 @@
|
|||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
|
||||
import { PetStarShower } from '@/components/pet/pet-star-shower'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
|
|
@ -22,12 +25,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Egg, Loader2, PawPrint, RefreshCw } from '@/lib/icons'
|
||||
import { Egg, Loader2, PawPrint, RefreshCw, Settings2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
import {
|
||||
$petGenAvailable,
|
||||
$petGenDrafts,
|
||||
$petGenerateOpen,
|
||||
$petGenError,
|
||||
|
|
@ -38,6 +43,7 @@ import {
|
|||
adoptHatched,
|
||||
cancelGenerate,
|
||||
cancelHatch,
|
||||
checkPetGenAvailable,
|
||||
cleanPetName,
|
||||
cleanupPetGen,
|
||||
closePetGenerate,
|
||||
|
|
@ -87,6 +93,13 @@ export function PetGenerateOverlay() {
|
|||
const status = useStore($petGenStatus)
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
// Yield the screen to a full-screen route overlay (e.g. /settings while the
|
||||
// user adds an image-gen key) without tearing down — the store keeps us open,
|
||||
// and we reappear + re-check on return.
|
||||
if (useRouteOverlayActive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
// Deletes a hatched-but-unadopted preview pet so it doesn't linger, then
|
||||
|
|
@ -116,9 +129,14 @@ function PetGenerateContent() {
|
|||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const available = useStore($petGenAvailable)
|
||||
// `null` = not yet probed → stay optimistic (show the prompt); only the
|
||||
// confirmed-no-backend case swaps in the setup card.
|
||||
const unavailable = available === false
|
||||
const drafts = useStore($petGenDrafts)
|
||||
const selected = useStore($petGenSelected)
|
||||
const preview = useStore($petGenPreview)
|
||||
|
|
@ -126,6 +144,13 @@ function PetGenerateContent() {
|
|||
|
||||
const [prompt, setPrompt] = useState('')
|
||||
|
||||
// Probe backend availability on open — and again whenever the content
|
||||
// remounts (e.g. after returning from the providers settings), so adding a
|
||||
// key flips the setup card to the prompt with no manual refresh.
|
||||
useEffect(() => {
|
||||
void checkPetGenAvailable(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const busy = status === 'generating' || status === 'hatching'
|
||||
const hasDrafts = drafts.length > 0
|
||||
const generating = status === 'generating'
|
||||
|
|
@ -176,14 +201,23 @@ function PetGenerateContent() {
|
|||
// The header title tracks the phase instead of sticking on "Generate a pet".
|
||||
const headerTitle =
|
||||
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
|
||||
// Prompt input only belongs on the describe/draft screens.
|
||||
const showPrompt = status !== 'hatching' && status !== 'preview' && status !== 'adopting'
|
||||
// Send the user to set up a key without closing — the overlay yields to the
|
||||
// settings route (useRouteOverlayActive) and reappears + re-checks on return.
|
||||
const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
|
||||
// Prompt input only belongs on the describe/draft screens (and never when
|
||||
// there's no backend to generate with).
|
||||
const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting'
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{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
|
||||
|
|
@ -215,13 +249,15 @@ function PetGenerateContent() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && status !== 'preview' && status !== 'adopting' && (
|
||||
{error && !unavailable && status !== 'preview' && status !== 'adopting' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'stale' ? (
|
||||
{unavailable ? (
|
||||
<GenerateUnavailable onSetup={setupImageGen} />
|
||||
) : status === 'stale' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{copy.staleBackend}</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -257,6 +293,51 @@ function PetGenerateContent() {
|
|||
// Doubling as guidance and a one-click way to see the flow.
|
||||
const EXAMPLE_PROMPTS = ['a bubble-tea otter', 'a tiny sock elf', 'a pixel dragon', 'a grumpy office cat', 'a neon axolotl']
|
||||
|
||||
// Shown when no reference-capable image backend is configured: generation is
|
||||
// impossible, so we replace the prompt entirely with a friendly path to set one
|
||||
// up (in-app) plus where to grab a key.
|
||||
function GenerateUnavailable({ onSetup }: { onSetup: () => void }) {
|
||||
return (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -96,6 +96,22 @@ 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)
|
||||
|
||||
/** 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')
|
||||
$petGenAvailable.set(Boolean(res?.available))
|
||||
} catch {
|
||||
// Unknown (old backend / transient) — don't gate the UI on a failed probe.
|
||||
$petGenAvailable.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the dedicated "Generate a pet" Pokédex overlay is open. */
|
||||
export const $petGenerateOpen = atom(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -6144,6 +6144,27 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"ok": True})
|
||||
|
||||
|
||||
@method("pet.generate.status")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Whether pet generation is possible right now.
|
||||
|
||||
True only when a reference-capable image backend (OpenRouter / Nous Portal /
|
||||
OpenAI gpt-image) is configured — the desktop checks this on open so it can
|
||||
offer setup instead of a dead prompt. Cheap (config + plugin discovery).
|
||||
"""
|
||||
try:
|
||||
from agent.pet.generate.imagegen import GenerationError, resolve_provider
|
||||
|
||||
try:
|
||||
resolve_provider(require_references=True)
|
||||
return _ok(rid, {"available": True})
|
||||
except GenerationError:
|
||||
return _ok(rid, {"available": False})
|
||||
except Exception as exc: # noqa: BLE001 - never break the surface
|
||||
logger.debug("pet.generate.status failed: %s", exc)
|
||||
return _ok(rid, {"available": False})
|
||||
|
||||
|
||||
@method("pet.generate")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Generate candidate base looks for a new pet (the draft/variant step).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue