diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx index ac4738d283..5d0028454c 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx @@ -9,6 +9,7 @@ import { faTasks, faBroom, faBomb, + faCircleExclamation, } from "@fortawesome/pro-solid-svg-icons"; import { Modal } from "@storyteller/ui-modal"; import { @@ -69,6 +70,14 @@ type CompletedTask = { batchImageToken?: string; }; +type FailedTask = { + id: string; + title: string; + subtitle?: string; + failedAt?: Date; + status: string; +}; + const formatTimeLeft = (ms: number): string => { const totalSeconds = Math.ceil(ms / 1000); const hours = Math.floor(totalSeconds / 3600); @@ -219,10 +228,70 @@ const CompletedCard = ({ ); }; +const FAILED_STATUS_LABEL: Record = { + complete_failure: "Failed", + attempt_failed: "Failed", + dead: "Failed", + cancelled_by_user: "Cancelled", + cancelled_by_provider: "Cancelled by provider", + cancelled_by_us: "Cancelled", +}; + +const FailedCard = ({ + task, + onDismiss, +}: { + task: FailedTask; + onDismiss?: () => void; +}) => { + const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed"; + return ( +
+
+
+ +
+
+
+
+ {task.title} +
+
+ {task.subtitle && ( +
+ {task.subtitle} +
+ )} +
+ {statusLabel} +
+
+ {onDismiss && ( + + )} +
+
+ ); +}; + export const TaskQueue = () => { const [isModalOpen, setModalOpen] = useState(false); const [inProgress, setInProgress] = useState([]); const [completed, setCompleted] = useState([]); + const [failed, setFailed] = useState([]); const [lastReadAt, setLastReadAt] = useState(() => { const stored = localStorage.getItem("taskQueueLastReadAt"); return stored ? parseInt(stored, 10) : 0; @@ -290,6 +359,25 @@ export const TaskQueue = () => { }); }; + const handleClearFailed = () => { + setConfirmationConfig({ + isOpen: true, + title: "Clear failed tasks?", + message: ( + + This will remove all failed/cancelled tasks from the queue. + + ), + primaryActionText: "Clear failed", + primaryActionIcon: faTrashAlt, + primaryActionBtnClassName: + "bg-red-500/10 hover:bg-red-500/20 text-red-500", + onConfirm: async () => { + await dismissFailed(); + }, + }); + }; + const handleRemoveAll = () => { setConfirmationConfig({ isOpen: true, @@ -452,8 +540,32 @@ export const TaskQueue = () => { }; }); + const FAILED_STATUSES = new Set([ + "complete_failure", + "attempt_failed", + "dead", + "cancelled_by_user", + "cancelled_by_provider", + "cancelled_by_us", + ]); + + const failedTasks = tasks + .filter((t) => FAILED_STATUSES.has(t.task_status)) + .sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime()) + .map((t: TaskQueueItem) => { + const parts = formatTitleParts(t); + return { + id: t.id, + title: parts.title || "Task", + subtitle: parts.subtitle, + failedAt: t.completed_at || t.updated_at, + status: t.task_status, + }; + }); + setInProgress(inProg); setCompleted(done); + setFailed(failedTasks); // Track newly completed IDs when popover is closed const newCompletedIdSet = new Set(done.map((d) => d.id)); @@ -486,10 +598,17 @@ export const TaskQueue = () => { }; window.addEventListener("cover-image-uploaded", handleCoverUploaded); - let unlisten: Promise | null = null; + let unlistenComplete: Promise | null = null; + let unlistenFailed: Promise | null = null; (async () => { // Update immediately when Tauri signals a generation completion - unlisten = listen("generation-complete-event", () => { + unlistenComplete = listen("generation-complete-event", () => { + if (!cancelled) { + load(); + } + }); + // Also update immediately when a generation fails + unlistenFailed = listen("generation-failed-event", () => { if (!cancelled) { load(); } @@ -499,8 +618,11 @@ export const TaskQueue = () => { cancelled = true; clearInterval(id); window.removeEventListener("cover-image-uploaded", handleCoverUploaded); - if (unlisten) { - unlisten.then((f) => f()); + if (unlistenComplete) { + unlistenComplete.then((f) => f()); + } + if (unlistenFailed) { + unlistenFailed.then((f) => f()); } }; }, [ @@ -511,8 +633,9 @@ export const TaskQueue = () => { ]); const hasNothing = useMemo( - () => inProgress.length === 0 && completed.length === 0, - [inProgress.length, completed.length], + () => + inProgress.length === 0 && completed.length === 0 && failed.length === 0, + [inProgress.length, completed.length, failed.length], ); const inProgressCount = inProgress.length; @@ -533,6 +656,7 @@ export const TaskQueue = () => { await MarkTaskAsDismissed(id); setInProgress((prev) => prev.filter((t) => t.id !== id)); setCompleted((prev) => prev.filter((t) => t.id !== id)); + setFailed((prev) => prev.filter((t) => t.id !== id)); setUnreadCompletedIds((prev) => (prev ?? []).filter((x) => x !== id)); taskDurationRef.current.delete(id); } catch (_) { @@ -552,6 +676,17 @@ export const TaskQueue = () => { } }; + const dismissFailed = async () => { + const ids = failed.map((t) => t.id); + try { + await Promise.all(ids.map((id) => MarkTaskAsDismissed(id))); + } catch (_) { + // ignore + } finally { + setFailed([]); + } + }; + const dismissStale = async () => { const staleIds = inProgress.filter((t) => t.canDismiss).map((t) => t.id); try { @@ -575,6 +710,7 @@ export const TaskQueue = () => { } finally { setCompleted([]); setInProgress([]); + setFailed([]); setUnreadCompletedIds([]); taskDurationRef.current.clear(); } @@ -609,8 +745,8 @@ export const TaskQueue = () => { > {(close) => ( <> -
-
+
+
{hasNothing ? (
@@ -637,10 +773,48 @@ export const TaskQueue = () => { ))}
)} + {failed.length > 0 && ( +
+
+
+ Failed +
+ +
+ {failed.map((t) => ( + dismissTask(t.id)} + /> + ))} +
+ )} {completed.length > 0 && (
-
- Completed +
+
+ Completed +
+
{completed.map((t) => ( {
)}
-
-
+
+
- - -
@@ -738,6 +899,13 @@ export const TaskQueue = () => { Clear stale +
)} + {failed.length > 0 && ( +
+
+ Failed +
+ {failed.map((t) => ( + dismissTask(t.id)} + /> + ))} +
+ )} {completed.length > 0 && (
diff --git a/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx b/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx index c36d0665b9..bc4648b502 100644 --- a/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageImage/TextToImage.tsx @@ -3,6 +3,7 @@ import { JobContextType } from "@storyteller/common"; import { PromptBoxImage } from "@storyteller/ui-promptbox"; import { uploadImage } from "../../components/reusable/UploadModalMedia/uploadImage"; import BackgroundGallery from "./BackgroundGallery"; +import { listen, UnlistenFn } from "@tauri-apps/api/event"; import { TEXT_TO_IMAGE_PAGE_MODEL_LIST, ModelPage, @@ -13,11 +14,13 @@ import { //PROVIDER_LOOKUP_BY_PAGE, } from "@storyteller/ui-model-selector"; import { ImageModel } from "@storyteller/model-list"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons"; interface TextToImageProps { imageMediaId?: string; imageUrl?: string; } -import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events"; +//import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events"; import { useTextToImageStore } from "./TextToImageStore"; import { animated, useSpring } from "@react-spring/web"; import { @@ -40,7 +43,9 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { const containerRef = useRef(null); const batches = useTextToImageStore((s) => s.batches); const startBatch = useTextToImageStore((s) => s.startBatch); - const completeBatch = useTextToImageStore((s) => s.completeBatch); + //const completeBatch = useTextToImageStore((s) => s.completeBatch); + const failBatch = useTextToImageStore((s) => s.failBatch); + const dismissBatch = useTextToImageStore((s) => s.dismissBatch); const resetBatches = useTextToImageStore((s) => s.reset); const [imageRowVisible, setImageRowVisible] = useState(false); const promptContentRef = useRef(null); @@ -95,6 +100,23 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { config: { tension: 200, friction: 28, mass: 1.1 }, }); + // Listen for generation-failed-event and mark the oldest pending image batch as failed + useEffect(() => { + let unlisten: Promise | null = null; + unlisten = listen<{ data: { action: string; reason?: string } }>( + "generation-failed-event", + (event) => { + const { action, reason } = event.payload.data; + if (action === "generate_image") { + failBatch(reason || "Generation failed"); + } + }, + ); + return () => { + if (unlisten) unlisten.then((f) => f()); + }; + }, [failBatch]); + // Show the batches in reverse order, with the newest items at top. // Like Midjourney instead of a "chat history" style. const inverseBatch = [...batches].reverse(); @@ -129,10 +151,40 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { >
{inverseBatch.map((batch) => ( -
+
- {batch.status === "pending" && batch.images.length === 0 - ? Array.from({ + {batch.status === "failed" ? ( + <> +
+ + + {batch.failureReason || "Generation failed"} + + +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} + + ) : batch.status === "pending" && + batch.images.length === 0 ? ( + <> + {Array.from({ length: Math.max( 1, Math.min(4, batch.requestedCount ?? 4), @@ -140,10 +192,30 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => { }).map((_, i) => (
+
+
+ ))} + {Array.from({ + length: Math.max( + 0, + 4 - + Math.max( + 1, + Math.min(4, batch.requestedCount ?? 4), + ), + ), + }).map((_, i) => ( +
- )) - : batch.images.slice(0, 4).map((img) => ( + ))} + + ) : ( + <> + {batch.images.slice(0, 4).map((img) => ( ))} + {Array.from({ + length: Math.max( + 0, + 4 - batch.images.slice(0, 4).length, + ), + }).map((_, i) => ( +
+ ))} + + )}
-
-
+