From 15a81dbf41325e59e0c83128e2388a11381bdb38 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:03:00 +0530 Subject: [PATCH] feat: add GenerateImageToolUI component for rendering generated images with error handling and loading states --- .../components/tool-ui/generate-image.tsx | 138 ++++++++++++++++++ .../components/tool-ui/image/index.tsx | 58 ++++---- 2 files changed, 166 insertions(+), 30 deletions(-) create mode 100644 surfsense_web/components/tool-ui/generate-image.tsx diff --git a/surfsense_web/components/tool-ui/generate-image.tsx b/surfsense_web/components/tool-ui/generate-image.tsx new file mode 100644 index 000000000..fd2e9992e --- /dev/null +++ b/surfsense_web/components/tool-ui/generate-image.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { AlertCircleIcon, ImageIcon } from "lucide-react"; +import { z } from "zod"; +import { + Image, + ImageErrorBoundary, + ImageLoading, + parseSerializableImage, +} from "@/components/tool-ui/image"; + +const GenerateImageArgsSchema = z.object({ + prompt: z.string(), + n: z.number().nullish(), +}); + +const GenerateImageResultSchema = z.object({ + id: z.string(), + assetId: z.string(), + src: z.string(), + alt: z.string().nullish(), + title: z.string().nullish(), + description: z.string().nullish(), + domain: z.string().nullish(), + ratio: z.string().nullish(), + generated: z.boolean().nullish(), + prompt: z.string().nullish(), + image_count: z.number().nullish(), + error: z.string().nullish(), +}); + +type GenerateImageArgs = z.infer; +type GenerateImageResult = z.infer; + +function ImageErrorState({ prompt, error }: { prompt: string; error: string }) { + return ( +
+
+
+ +
+
+

Image generation failed

+

{prompt}

+

{error}

+
+
+
+ ); +} + +function ImageCancelledState({ prompt }: { prompt: string }) { + return ( +
+

+ + Generate: {prompt} +

+
+ ); +} + +function ParsedImage({ result }: { result: unknown }) { + const image = parseSerializableImage(result); + return ( + {image.alt} + ); +} + +/** + * Tool UI for generate_image — renders the generated image directly + * from the tool result directly. + */ +export const GenerateImageToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { + const prompt = args.prompt || "Generating image..."; + + if (status.type === "running" || status.type === "requires-action") { + return ( +
+ +
+ ); + } + + if (status.type === "incomplete") { + if (status.reason === "cancelled") { + return ; + } + if (status.reason === "error") { + return ( + + ); + } + } + + if (!result) { + return ( +
+ +
+ ); + } + + if (result.error) { + return ; + } + + return ( +
+ + + +
+ ); +}; + +export { + GenerateImageArgsSchema, + GenerateImageResultSchema, + type GenerateImageArgs, + type GenerateImageResult, +}; diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx index ec04b779f..b8e37b620 100644 --- a/surfsense_web/components/tool-ui/image/index.tsx +++ b/surfsense_web/components/tool-ui/image/index.tsx @@ -6,7 +6,7 @@ import { Component, type ReactNode, useState } from "react"; import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; +import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { cn } from "@/lib/utils"; /** @@ -145,14 +145,14 @@ export class ImageErrorBoundary extends Component< render() { if (this.state.hasError) { return ( - -
-
- -

Failed to load image

-
+ +
+
+ +

Failed to load image

- +
+
); } @@ -165,7 +165,7 @@ export class ImageErrorBoundary extends Component< */ export function ImageSkeleton({ maxWidth = "512px" }: { maxWidth?: string }) { return ( - +
@@ -176,14 +176,11 @@ export function ImageSkeleton({ maxWidth = "512px" }: { maxWidth?: string }) { /** * Image Loading State */ -export function ImageLoading({ title = "Loading image..." }: { title?: string }) { +export function ImageLoading({ title = "Loading", maxWidth = "512px" }: { title?: string; maxWidth?: string }) { return ( - +
-
- -

{title}

-
+
); @@ -214,8 +211,8 @@ export function Image({ const [isHovered, setIsHovered] = useState(false); const [imageError, setImageError] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); - const displayDomain = domain || source?.label; const isGenerated = domain === "ai-generated"; + const displayDomain = isGenerated ? "AI Generated" : (domain || source?.label); const isAutoRatio = !ratio || ratio === "auto"; const handleClick = () => { @@ -227,7 +224,7 @@ export function Image({ if (imageError) { return ( - +
@@ -242,8 +239,7 @@ export function Image({ - {!imageLoaded && ( -
- -
- )} - {/* eslint-disable-next-line @next/next/no-img-element */} - + +
+ )} + setImageLoaded(true)} onError={() => setImageError(true)} /> @@ -316,11 +316,9 @@ export function Image({ {description && (

{description}

)} - {displayDomain && ( + {displayDomain && !isGenerated && (
- {isGenerated ? ( - - ) : source?.iconUrl ? ( + {source?.iconUrl ? (