diff --git a/apps/desktop/src/app/pet-generate/components/draft-grid.tsx b/apps/desktop/src/app/pet-generate/components/draft-grid.tsx index abef61f027f..d8e98a415e6 100644 --- a/apps/desktop/src/app/pet-generate/components/draft-grid.tsx +++ b/apps/desktop/src/app/pet-generate/components/draft-grid.tsx @@ -1,5 +1,7 @@ import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite' import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { PawPrint } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' @@ -13,11 +15,21 @@ interface DraftGridProps { hasDrafts: boolean onCancel: () => void onHatch: () => void + onRemix: (draft: { index: number; dataUri: string }) => void onSelect: (index: number) => void selected: number | null } -export function DraftGrid({ drafts, generating, hasDrafts, onCancel, onHatch, onSelect, selected }: DraftGridProps) { +export function DraftGrid({ + drafts, + generating, + hasDrafts, + onCancel, + onHatch, + onRemix, + onSelect, + selected +}: DraftGridProps) { const { t } = useI18n() const copy = t.commandCenter.generatePet @@ -43,32 +55,56 @@ export function DraftGrid({ drafts, generating, hasDrafts, onCancel, onHatch, on const isSelected = draft != null && selected === draft.index return ( - + + {/* Remix: branch a new round off this look. Revealed on hover/focus. */} + {draft != null && !generating && ( + + + )} - 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. - - ) : ( - // Incubating: a creme egg bouncing on its contact shadow. -
- - -
- )} - + ) })} diff --git a/apps/desktop/src/app/pet-generate/pet-generate-content.tsx b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx index 87d34897cc7..0f183678b1d 100644 --- a/apps/desktop/src/app/pet-generate/pet-generate-content.tsx +++ b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx @@ -1,10 +1,11 @@ import { useStore } from '@nanostores/react' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } 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 { ConfirmDialog } from '@/components/ui/confirm-dialog' import { DialogHeader, DialogTitle } from '@/components/ui/dialog' import { GenerateButton } from '@/components/ui/generate-button' import { Input } from '@/components/ui/input' @@ -20,6 +21,7 @@ import { $petGenPreview, $petGenRefImage, $petGenRefName, + $petGenRemixConfirmed, $petGenSelected, $petGenStage, $petGenStatus, @@ -31,7 +33,8 @@ import { discardDrafts, discardHatched, generateDrafts, - hatchSelected + hatchSelected, + markRemixConfirmed } from '@/store/pet-generate' import { DraftGrid } from './components/draft-grid' @@ -68,6 +71,9 @@ export function PetGenerateContent() { const refName = useStore($petGenRefName) const fileRef = useRef(null) + // The draft awaiting the one-time "remix regenerates" confirmation. + const [remixPending, setRemixPending] = useState<{ dataUri: string } | null>(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. @@ -139,6 +145,27 @@ export function PetGenerateContent() { void generateDrafts(requestGateway, { prompt: example }) } + // A remix re-runs generation grounded on an existing draft — same prompt, stay + // on step 2 — so the user explores variations without starting over. + const runRemix = (draft: { dataUri: string }) => { + void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: draft.dataUri }) + } + + // Slow, and it replaces the current drafts — so confirm once, then remember it. + const remixDraft = (draft: { dataUri: string }) => { + if (busy) { + return + } + + if ($petGenRemixConfirmed.get()) { + runRemix(draft) + + return + } + + setRemixPending(draft) + } + // 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. @@ -283,11 +310,27 @@ export function PetGenerateContent() { hasDrafts={hasDrafts} onCancel={discardDrafts} onHatch={hatch} + onRemix={remixDraft} onSelect={index => $petGenSelected.set(index)} selected={selected} /> )} + + setRemixPending(null)} + onConfirm={() => { + markRemixConfirmed() + + if (remixPending) { + runRemix(remixPending) + } + }} + open={remixPending !== null} + title={copy.remixConfirmTitle} + /> ) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a607ed93efb..851792f8163 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -799,6 +799,10 @@ export const en: Translations = { staleBackend: 'Update Hermes to generate pets.', backgroundHint: 'You can close this — Hermes will notify you when it’s done.', slowProviderHint: 'This can take several minutes', + remix: 'Remix', + remixConfirmTitle: 'Remix this look?', + remixConfirmBody: + 'This generates a fresh set of drafts using this one as the starting point. It can take several minutes.', 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.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 2f5344e3d67..c58cc6be1f0 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -917,6 +917,9 @@ export const ja = defineLocale({ staleBackend: 'ペットを生成するには Hermes を更新してください。', backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。', slowProviderHint: '数分かかることがあります', + remix: 'リミックス', + remixConfirmTitle: 'この見た目でリミックスしますか?', + remixConfirmBody: 'これを起点に新しい候補を生成します。数分かかることがあります。', genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。', referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。', referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 0dcc51bca84..4986030231c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -703,6 +703,9 @@ export interface Translations { staleBackend: string backgroundHint: string slowProviderHint: string + remix: string + remixConfirmTitle: string + remixConfirmBody: string genericError: string referenceImageTooLarge: string referenceImageInvalid: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 439aa0bf5c2..7bd0473625b 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -889,6 +889,9 @@ export const zhHant = defineLocale({ staleBackend: '請更新 Hermes 以生成寵物。', backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。', slowProviderHint: '這可能需要幾分鐘', + remix: '混合生成', + remixConfirmTitle: '以此造型混合生成?', + remixConfirmBody: '將以此造型為起點生成一組新草圖,可能需要幾分鐘。', genericError: '生成失敗——請重試或選一個建議。', referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。', referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 619f9817b02..502d5954323 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -987,6 +987,9 @@ export const zh: Translations = { staleBackend: '请更新 Hermes 以生成宠物。', backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。', slowProviderHint: '这可能需要几分钟', + remix: '混合生成', + remixConfirmTitle: '以此造型混合生成?', + remixConfirmBody: '将以此造型为起点生成一组新草图,可能需要几分钟。', genericError: '生成失败——请重试或选择一个建议。', referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。', referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。', diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts index 5713b04a8a7..9659115fb9a 100644 --- a/apps/desktop/src/store/pet-generate.ts +++ b/apps/desktop/src/store/pet-generate.ts @@ -1,6 +1,6 @@ import { atom } from 'nanostores' -import { persistString, storedString } from '@/lib/storage' +import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' import { $gateway } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' @@ -118,6 +118,7 @@ export interface PetGenProvider { } const PROVIDER_KEY = 'hermes.desktop.petgen.provider' +const REMIX_CONFIRMED_KEY = 'hermes.desktop.petgen.remixConfirmed' /** Reference-capable providers available to pick (from `pet.generate.status`). */ export const $petGenProviders = atom([]) @@ -130,6 +131,15 @@ export function setPetGenProvider(name: string): void { persistString(PROVIDER_KEY, name || null) } +/** Whether the user has acknowledged the one-time "remix regenerates" notice. */ +export const $petGenRemixConfirmed = atom(storedBoolean(REMIX_CONFIRMED_KEY, false)) + +/** Remember that the remix notice has been shown so we don't ask again. */ +export function markRemixConfirmed(): void { + $petGenRemixConfirmed.set(true) + persistBoolean(REMIX_CONFIRMED_KEY, true) +} + /** Probe whether generation is possible (a reference-capable backend exists). */ export async function checkPetGenAvailable(request: GatewayRequest): Promise { try {