diff --git a/templates/assets/app/components/generation/GenerationResults.tsx b/templates/assets/app/components/generation/GenerationResults.tsx index 8a1b0d0fa6..19df0edfa2 100644 --- a/templates/assets/app/components/generation/GenerationResults.tsx +++ b/templates/assets/app/components/generation/GenerationResults.tsx @@ -1,13 +1,22 @@ import { readClientAppState, - sendToAgentChat, + setAgentChatContextItem, useActionMutation, useActionQuery, useT, } from "@agent-native/core/client"; -import { IconMessageCircle, IconPhoto, IconTrash } from "@tabler/icons-react"; +import { + IconChevronLeft, + IconChevronRight, + IconDeviceFloppy, + IconMessageCircle, + IconPhoto, + IconTrash, + IconX, +} from "@tabler/icons-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router"; import { toast } from "sonner"; import { @@ -22,7 +31,20 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { assetPreviewSources } from "@/lib/asset-preview-sources"; import type { @@ -34,6 +56,8 @@ type LibraryListResult = { libraries?: ImageLibrarySummary[]; }; +type VariantSlot = AssetVariantState["slots"][number]; + function variantStateKey(threadId: string | null) { return threadId ? `asset-variants:${threadId}` : "asset-variants"; } @@ -46,15 +70,13 @@ function variantStateKey(threadId: string | null) { // its finished image arrives. const STALE_PENDING_RUN_MS = 10 * 60 * 1000; -function slotTime(slot: AssetVariantState["slots"][number]): number { +function slotTime(slot: VariantSlot): number { const raw = slot.createdAt ?? slot.updatedAt ?? ""; const time = Date.parse(raw); return Number.isNaN(time) ? 0 : time; } -function stalePendingRunId( - slot: AssetVariantState["slots"][number], -): string | null { +function stalePendingRunId(slot: VariantSlot): string | null { if (slot.status !== "pending") return null; if (!slot.runId) return null; const timestamp = slotTime(slot); @@ -66,6 +88,7 @@ export function GenerationResults({ threadId }: { threadId: string | null }) { const t = useT(); const queryClient = useQueryClient(); const [clearAllOpen, setClearAllOpen] = useState(false); + const [previewSlotId, setPreviewSlotId] = useState(null); const stateKey = variantStateKey(threadId); const stateQueryKey = useMemo(() => ["app-state", stateKey], [stateKey]); const { data: variants } = useQuery({ @@ -137,6 +160,11 @@ export function GenerationResults({ threadId }: { threadId: string | null }) { ); }, [belongsToThread, queryClient, refreshGeneration, slots, stateQueryKey]); + const previewSlot = useMemo( + () => slots.find((slot) => slot.slotId === previewSlotId) ?? null, + [previewSlotId, slots], + ); + if (!belongsToThread || !variants) return null; if (slots.length === 0) return null; @@ -169,6 +197,65 @@ export function GenerationResults({ threadId }: { threadId: string | null }) { ); } + function saveSlot(slot: VariantSlot, onDone?: () => void) { + if (!slot.assetId && !slot.slotId) return; + saveGenerated.mutate( + { + ...(slot.assetId ? { assetId: slot.assetId } : {}), + ...(slot.slotId ? { slotId: slot.slotId } : {}), + threadId, + }, + { + onSuccess: () => { + toast.success(t("library.savedGeneratedAsset")); + void queryClient.invalidateQueries({ queryKey: stateQueryKey }); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-library"], + }); + onDone?.(); + }, + onError: (error) => + toast.error(error.message || t("library.couldNotSaveCandidate")), + }, + ); + } + + function dismissSlotById(slot: VariantSlot, onDone?: () => void) { + dismissSlot.mutate( + { slotId: slot.slotId, threadId }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: stateQueryKey }); + onDone?.(); + }, + onError: (error) => + toast.error(error.message || t("library.couldNotDismissCandidate")), + }, + ); + } + + function refineSlot(slot: VariantSlot, variantNumber: number) { + if (!slot.assetId) return; + const variantLabel = t("library.variantWithNumber", { + number: variantNumber, + }); + setAgentChatContextItem({ + key: "refine-asset:" + slot.assetId, + title: t("library.refine") + " : " + variantLabel, + context: [ + "## Assets candidate", + `Asset ID: ${slot.assetId}`, + `Run ID: ${slot.runId ?? "unknown"}`, + `Prompt: ${variants?.prompt ?? ""}`, + libraryTitle ? `Brand kit: ${libraryTitle}` : "", + "Use refine-image with assetId when the user describes the change.", + ] + .filter(Boolean) + .join("\n"), + openSidebar: true, + }); + } + return ( <> @@ -206,6 +293,27 @@ export function GenerationResults({ threadId }: { threadId: string | null }) { + { + if (!open) setPreviewSlotId(null); + }} + onSelect={setPreviewSlotId} + onSave={(slot) => saveSlot(slot, () => setPreviewSlotId(null))} + onRefine={(slot, variantNumber) => { + refineSlot(slot, variantNumber); + setPreviewSlotId(null); + }} + onDismiss={(slot) => + dismissSlotById(slot, () => setPreviewSlotId(null)) + } + /> +
@@ -244,64 +352,18 @@ export function GenerationResults({ threadId }: { threadId: string | null }) {
-
+
{slots.map((slot, index) => ( - { - if (!slot.assetId && !slot.slotId) return; - saveGenerated.mutate( - { - ...(slot.assetId ? { assetId: slot.assetId } : {}), - ...(slot.slotId ? { slotId: slot.slotId } : {}), - threadId, - }, - { - onSuccess: () => { - toast.success(t("library.savedGeneratedAsset")); - void queryClient.invalidateQueries({ - queryKey: stateQueryKey, - }); - void queryClient.invalidateQueries({ - queryKey: ["action", "get-library"], - }); - }, - onError: (error) => - toast.error( - error.message || t("library.couldNotSaveCandidate"), - ), - }, - ); - }} - onDismiss={() => { - dismissSlot.mutate( - { slotId: slot.slotId, threadId }, - { - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: stateQueryKey, - }); - }, - onError: (error) => - toast.error( - error.message || - t("library.couldNotDismissCandidate"), - ), - }, - ); - }} + onPreview={() => setPreviewSlotId(slot.slotId)} + onSave={() => saveSlot(slot)} + onRefine={() => refineSlot(slot, index + 1)} + onDismiss={() => dismissSlotById(slot)} /> ))}
@@ -312,117 +374,315 @@ export function GenerationResults({ threadId }: { threadId: string | null }) { ); } -function GenerationResultItem({ +function GenerationDraftCard({ slot, - index, - prompt, - libraryTitle, + variantNumber, isSaving, isDismissing, + onPreview, onSave, + onRefine, onDismiss, }: { - slot: AssetVariantState["slots"][number]; - index: number; - prompt: string; - libraryTitle: string | null; + slot: VariantSlot; + variantNumber: number; isSaving: boolean; isDismissing: boolean; + onPreview: () => void; onSave: () => void; + onRefine: () => void; onDismiss: () => void; }) { - const ready = slot.status === "ready" && Boolean(slot.assetId); const t = useT(); + const ready = slot.status === "ready" && Boolean(slot.assetId); + const variantLabel = t("library.variantWithNumber", { + number: variantNumber, + }); return ( -
-
- -
- {slot.status === "pending" ? : null} - - {slot.status === "pending" - ? t("library.generating") - : slot.status === "ready" - ? t("library.candidateWithNumber", { number: index + 1 }) - : t("library.failed")} - -
-
-
-
-
- {slot.status === "ready" - ? t("library.readyToSave") - : slot.status === "pending" - ? t("library.stillRendering") - : t("library.generationFailed")} +
+
+ + + {slot.status !== "ready" ? ( +
+ {slot.status === "pending" ? : null} + + {slot.status === "pending" + ? t("library.generating") + : t("library.failed")} +
-
-
- - - + + {t("library.save")} + + + + + + {t("library.refine")} + + + + + + {t("library.deleteCandidate")} + +
+ + ) : ( + -
+ + + )} +
+
+ {variantLabel}
-
+
); } -function GenerationSlotPreview({ +function GenerationPreviewDialog({ slot, + slots, + prompt, + libraryTitle, + isSaving, + isDismissing, + onOpenChange, + onSelect, + onSave, + onRefine, + onDismiss, }: { - slot: AssetVariantState["slots"][number]; + slot: VariantSlot | null; + slots: VariantSlot[]; + prompt: string; + libraryTitle: string | null; + isSaving: boolean; + isDismissing: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (slotId: string) => void; + onSave: (slot: VariantSlot) => void; + onRefine: (slot: VariantSlot, variantNumber: number) => void; + onDismiss: (slot: VariantSlot) => void; }) { + const t = useT(); + const previewableSlots = useMemo( + () => + slots.filter((item) => item.status === "ready" && Boolean(item.assetId)), + [slots], + ); + const previewIndex = slot + ? previewableSlots.findIndex((item) => item.slotId === slot.slotId) + : -1; + const hasPrev = previewIndex > 0; + const hasNext = + previewIndex >= 0 && previewIndex < previewableSlots.length - 1; + const showPreviousSlot = () => { + if (hasPrev) onSelect(previewableSlots[previewIndex - 1].slotId); + }; + const showNextSlot = () => { + if (hasNext) onSelect(previewableSlots[previewIndex + 1].slotId); + }; + const sources = slot ? assetPreviewSources(slot, "preview") : []; + const src = sources[0]; + const variantNumber = slot + ? slots.findIndex((item) => item.slotId === slot.slotId) + 1 + : 0; + const variantLabel = t("library.variantWithNumber", { + number: variantNumber, + }); + return ( + + {slot ? ( + { + if (event.key === "ArrowLeft") showPreviousSlot(); + if (event.key === "ArrowRight") showNextSlot(); + }} + className="flex max-h-[85vh] w-[calc(100vw-24px)] max-w-4xl flex-col gap-0 overflow-hidden p-0" + > + {variantLabel} + + {prompt || t("library.readyToSave")} + + +
+

+ {variantLabel} +

+
+ + + {slot.assetId ? ( + + ) : null} + + + + +
+
+ +
+ {src ? ( + {prompt + ) : ( +
+ +
+ )} +
+ + {hasPrev || hasNext ? ( +
+ + +
+ ) : null} +
+ ) : null} +
+ ); +} + +function GenerationSlotPreview({ slot }: { slot: VariantSlot }) { const t = useT(); const sources = assetPreviewSources(slot, "thumbnail"); const src = sources[0]; if (src) { - return ; + return ( + + ); } if (slot.status === "failed") { return ( diff --git a/templates/assets/app/i18n-data.ts b/templates/assets/app/i18n-data.ts index 66d589713c..2f083278f4 100644 --- a/templates/assets/app/i18n-data.ts +++ b/templates/assets/app/i18n-data.ts @@ -812,6 +812,7 @@ const enUS = { candidates: "Candidates", candidateActions: "Candidate actions", candidateWithNumber: "Candidate {{number}}", + variantWithNumber: "Variant {{number}}", checkingImageLibraries: "Checking image libraries...", checking: "Checking", checkingUpload: "Checking upload", diff --git a/templates/assets/app/i18n/zh-TW.ts b/templates/assets/app/i18n/zh-TW.ts index 28754acb62..6d4b9497a9 100644 --- a/templates/assets/app/i18n/zh-TW.ts +++ b/templates/assets/app/i18n/zh-TW.ts @@ -774,6 +774,7 @@ const messages = { candidates: "候選項", candidateActions: "候選項操作", candidateWithNumber: "候選項 {{number}}", + variantWithNumber: "變體 {{number}}", checkingImageLibraries: "正在檢查圖片資料庫...", checking: "正在檢查", checkingUpload: "正在檢查上傳",