This commit is contained in:
marko-kraemer 2025-07-09 16:50:43 +02:00
parent 76f1ce59f8
commit bd3f13ea1f
5 changed files with 82 additions and 522 deletions

View File

@ -519,6 +519,7 @@ The todo.md file is your primary working document and action plan:
13. STOPPING CONDITION: If you've made 3 consecutive updates to todo.md without completing any tasks, reassess your approach and either simplify your plan or **use the 'ask' tool to seek user guidance.**
14. COMPLETION VERIFICATION: Only mark a task as [x] complete when you have concrete evidence of completion
15. SIMPLICITY: Keep your todo.md lean and direct with clear actions, avoiding unnecessary verbosity or granularity
16. **TODO.MD UPDATE METHOD**: ALWAYS use the 'full_file_rewrite' tool to update todo.md files instead of 'str_replace'.
## 5.3 EXECUTION PHILOSOPHY
Your approach is deliberately methodical and persistent:

View File

@ -1,28 +1,30 @@
from typing import Optional
from typing import Optional, Literal
from agentpress.tool import ToolResult, openapi_schema, xml_schema
from sandbox.tool_base import SandboxToolsBase
from agentpress.thread_manager import ThreadManager
import httpx
from io import BytesIO
import uuid
from litellm import aimage_generation, aimage_edit
from openai import AsyncOpenAI
import base64
import struct
class SandboxImageEditTool(SandboxToolsBase):
"""Tool for generating or editing images using OpenAI GPT Image 1 via OpenAI SDK (no mask support)."""
"""Tool for generating or editing images using OpenAI GPT Image 1 via OpenAI SDK."""
def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager):
super().__init__(project_id, thread_manager)
self.thread_id = thread_id
self.thread_manager = thread_manager
self.client = AsyncOpenAI()
@openapi_schema(
{
"type": "function",
"function": {
"name": "image_edit_or_generate",
"description": "Generate a new image from a prompt, or edit an existing image (no mask support) using OpenAI GPT Image 1 via OpenAI SDK. Stores the result in the thread context.",
"description": "Generate a new image from a prompt, or edit an existing image using OpenAI GPT Image 1 via OpenAI SDK. Stores the result in the thread context.",
"parameters": {
"type": "object",
"properties": {
@ -39,11 +41,6 @@ class SandboxImageEditTool(SandboxToolsBase):
"type": "string",
"description": "(edit mode only) Path to the image file to edit, relative to /workspace. Required for 'edit'.",
},
"size": {
"type": "string",
"enum": ["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"],
"description": "Size of the generated image. Defaults to '1024x1024'.",
},
},
"required": ["mode", "prompt"],
},
@ -56,14 +53,12 @@ class SandboxImageEditTool(SandboxToolsBase):
{"param_name": "mode", "node_type": "attribute", "path": "."},
{"param_name": "prompt", "node_type": "attribute", "path": "."},
{"param_name": "image_path", "node_type": "attribute", "path": "."},
{"param_name": "size", "node_type": "attribute", "path": "."},
],
example="""
<function_calls>
<invoke name="image_edit_or_generate">
<parameter name="mode">generate</parameter>
<parameter name="prompt">A futuristic cityscape at sunset</parameter>
<parameter name="size">1024x1024</parameter>
</invoke>
</function_calls>
""",
@ -73,18 +68,18 @@ class SandboxImageEditTool(SandboxToolsBase):
mode: str,
prompt: str,
image_path: Optional[str] = None,
size: str = "1024x1024",
) -> ToolResult:
"""Generate or edit images using OpenAI GPT Image 1 via OpenAI SDK (no mask support)."""
"""Generate or edit images using OpenAI GPT Image 1 via OpenAI SDK."""
try:
await self._ensure_sandbox()
if mode == "generate":
response = await aimage_generation(
response = await self.client.images.generate(
model="gpt-image-1",
prompt=prompt,
n=1,
size=size,
size="auto", # type: ignore
quality="auto", # type: ignore
)
elif mode == "edit":
if not image_path:
@ -94,29 +89,52 @@ class SandboxImageEditTool(SandboxToolsBase):
if isinstance(image_bytes, ToolResult): # Error occurred
return image_bytes
# Create BytesIO object with proper filename to set MIME type
image_io = BytesIO(image_bytes)
image_io.name = (
"image.png" # Set filename to ensure proper MIME type detection
)
# Validate image bytes
if not image_bytes or len(image_bytes) == 0:
return self.fail_response("Image file is empty or could not be read.")
response = await aimage_edit(
image=image_io, # Fixed: Pass BytesIO directly instead of list
prompt=prompt,
# Check if it's a valid PNG file (basic check)
if not image_bytes.startswith(b'\x89PNG\r\n\x1a\n'):
return self.fail_response("Image file must be a valid PNG file. Please ensure the image is in PNG format.")
# Check image size constraints (OpenAI requires square images and < 4MB)
if len(image_bytes) > 4 * 1024 * 1024: # 4MB limit
return self.fail_response("Image file must be less than 4MB in size.")
# Check if image is square (required by OpenAI for editing)
try:
# Read PNG header to get dimensions
if len(image_bytes) >= 24:
width, height = struct.unpack('>II', image_bytes[16:24])
if width != height:
return self.fail_response(f"Image must be square for editing. Current dimensions: {width}x{height}. Please resize the image to be square.")
except:
return self.fail_response("Could not read image dimensions. Please ensure the image is a valid PNG file.")
# Create BytesIO object for OpenAI SDK
image_io = BytesIO(image_bytes)
image_io.seek(0)
# Set name attribute for proper file handling
image_io.name = "image.png"
response = await self.client.images.edit(
model="gpt-image-1",
image=image_io,
prompt=prompt,
n=1,
size=size,
size="auto", # type: ignore
quality="auto", # type: ignore
)
else:
return self.fail_response("Invalid mode. Use 'generate' or 'edit'.")
# Download and save the generated image to sandbox
# Process and save the generated image to sandbox
image_filename = await self._process_image_response(response)
if isinstance(image_filename, ToolResult): # Error occurred
return image_filename
return self.success_response(
f"Successfully generated image using mode '{mode}' with size '{size}'. Image saved as: {image_filename}. You can use the ask tool to display the image."
f"Successfully generated image using mode '{mode}'. Image saved as: {image_filename}. You can use the ask tool to display the image. You can switch to 'edit' mode to edit this same image."
)
except Exception as e:
@ -162,11 +180,22 @@ class SandboxImageEditTool(SandboxToolsBase):
)
async def _process_image_response(self, response) -> str | ToolResult:
"""Download generated image and save to sandbox with random name."""
"""Process OpenAI image response and save to sandbox with random name."""
try:
original_b64_str = response.data[0].b64_json
# Decode base64 image data
image_data = base64.b64decode(original_b64_str)
# OpenAI SDK response handling
# The response contains either b64_json or url in data[0]
if hasattr(response.data[0], 'b64_json') and response.data[0].b64_json:
# Base64 response
image_base64 = response.data[0].b64_json
image_data = base64.b64decode(image_base64)
elif hasattr(response.data[0], 'url') and response.data[0].url:
# URL response - download the image
async with httpx.AsyncClient() as client:
img_response = await client.get(response.data[0].url)
img_response.raise_for_status()
image_data = img_response.content
else:
return self.fail_response("No valid image data found in response")
# Generate random filename
random_filename = f"generated_image_{uuid.uuid4().hex[:8]}.png"
@ -177,4 +206,4 @@ class SandboxImageEditTool(SandboxToolsBase):
return random_filename
except Exception as e:
return self.fail_response(f"Failed to download and save image: {str(e)}")
return self.fail_response(f"Failed to process and save image: {str(e)}")

View File

@ -257,7 +257,10 @@ export function FileAttachment({
// Calculate dynamic height based on image dimensions and layout
const imageHeight = (() => {
if (!isGridLayout) return baseImageHeight;
if (!isGridLayout) {
// For inline layout (like chat input), keep it compact
return baseImageHeight;
}
// For grid layout, use dimensions to determine optimal height to show full image
if (dimensions) {
@ -303,7 +306,7 @@ export function FileAttachment({
"group relative min-h-[54px] min-w-fit rounded-xl cursor-pointer",
"border border-black/10 dark:border-white/10",
"bg-black/5 dark:bg-black/20",
"p-1 overflow-hidden",
isGridLayout ? "p-1 overflow-hidden" : "p-0 overflow-hidden",
"flex items-center justify-center",
isGridLayout ? "w-full" : "min-w-[54px]",
className
@ -330,7 +333,7 @@ export function FileAttachment({
"group relative min-h-[54px] min-w-fit rounded-xl cursor-pointer",
"border border-black/10 dark:border-white/10",
"bg-black/5 dark:bg-black/20",
"p-1 overflow-hidden",
isGridLayout ? "p-1 overflow-hidden" : "p-0 overflow-hidden",
"flex flex-col items-center justify-center gap-1",
isGridLayout ? "w-full" : "inline-block",
className
@ -349,9 +352,15 @@ export function FileAttachment({
);
}
// Always use contain to show the full image without cropping
const getObjectFit = (): 'contain' => {
return 'contain'; // Always show the full image
// Use different object-fit strategies based on layout
const getObjectFit = (): 'cover' | 'contain' => {
if (!isGridLayout) {
// For inline layout (like chat input), use cover to keep it compact and visually appealing
return 'cover';
}
// For grid layout, use contain to show the full image
return 'contain';
};
return (
@ -361,7 +370,7 @@ export function FileAttachment({
"group relative min-h-[54px] rounded-2xl cursor-pointer",
"border border-black/10 dark:border-white/10",
"bg-black/5 dark:bg-black/20",
"p-1 overflow-hidden", // Small padding to prevent edge touching
isGridLayout ? "p-1 overflow-hidden" : "p-0 overflow-hidden", // No padding for inline, small padding for grid
"flex items-center justify-center", // Center the image
isGridLayout ? "w-full" : "inline-block", // Full width in grid
className
@ -378,15 +387,17 @@ export function FileAttachment({
src={sandboxId && session?.access_token ? imageUrl : fileUrl}
alt={filename}
className={cn(
"max-w-full max-h-full", // Respect both width and height constraints
"object-contain", // Always show full image
// Different styling for inline vs grid
isGridLayout ? "max-w-full max-h-full" : "max-h-full w-auto",
// Add transition for smooth loading
"transition-opacity duration-200",
dimensionsLoading ? "opacity-0" : "opacity-100"
)}
style={{
objectPosition: "center",
objectFit: getObjectFit()
objectFit: getObjectFit(),
// For inline layout, maintain aspect ratio and fit within container
...(isGridLayout ? {} : { height: '54px' })
}}
onLoad={() => {
console.log("Image loaded successfully:", filename, dimensions ? `${dimensions.width}×${dimensions.height}` : 'dimensions unknown');

View File

@ -1,478 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react';
import {
Image as ImageIcon,
Sparkles,
Wand2,
CheckCircle,
AlertTriangle,
Loader2,
ImageOff,
Download,
ZoomIn,
ZoomOut,
} from 'lucide-react';
import { ToolViewProps } from '../types';
import { formatTimestamp, getToolTitle, extractToolData } from '../utils';
import { cn, truncateString } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { LoadingState } from '../shared/LoadingState';
import { GenericToolView } from '../GenericToolView';
import { useAuth } from '@/components/AuthProvider';
interface ImageGenerationData {
mode: 'generate' | 'edit';
prompt: string;
image_path?: string;
size?: string;
output?: string;
success?: boolean;
generatedImagePath?: string;
}
function extractImageGenerationData(
assistantContent: any,
toolContent: any,
isSuccess: boolean
): ImageGenerationData {
let data: Partial<ImageGenerationData> = {};
if (assistantContent) {
try {
let parsedAssistant = assistantContent;
if (typeof assistantContent === 'string') {
parsedAssistant = JSON.parse(assistantContent);
}
if (parsedAssistant.parameters) {
data.mode = parsedAssistant.parameters.mode;
data.prompt = parsedAssistant.parameters.prompt;
data.image_path = parsedAssistant.parameters.image_path;
data.size = parsedAssistant.parameters.size;
}
} catch (e) {
if (typeof assistantContent === 'string') {
const modeMatch = assistantContent.match(/"mode":\s*"([^"]+)"/);
const promptMatch = assistantContent.match(/"prompt":\s*"([^"]+)"/);
const imagePathMatch = assistantContent.match(/"image_path":\s*"([^"]+)"/);
const sizeMatch = assistantContent.match(/"size":\s*"([^"]+)"/);
if (modeMatch) data.mode = modeMatch[1] as 'generate' | 'edit';
if (promptMatch) data.prompt = promptMatch[1];
if (imagePathMatch) data.image_path = imagePathMatch[1];
if (sizeMatch) data.size = sizeMatch[1];
}
}
}
if (toolContent) {
try {
let parsedTool = toolContent;
if (typeof toolContent === 'string') {
parsedTool = JSON.parse(toolContent);
}
if (parsedTool.output) {
data.output = parsedTool.output;
const imageMatch = parsedTool.output.match(/Image saved as: ([^\s.]+\.png)/);
if (imageMatch) {
data.generatedImagePath = imageMatch[1];
}
}
} catch (e) {
if (typeof toolContent === 'string') {
data.output = toolContent;
const imageMatch = toolContent.match(/Image saved as: ([^\s.]+\.png)/);
if (imageMatch) {
data.generatedImagePath = imageMatch[1];
}
}
}
}
data.success = isSuccess;
return data as ImageGenerationData;
}
function constructImageUrl(imagePath: string, project: any): string {
if (!project?.sandbox?.sandbox_url) {
return '';
}
return `${project.sandbox.sandbox_url}/workspace/${imagePath}`;
}
function SafeImage({ src, alt, filePath, className }: { src: string; alt: string; filePath: string; className?: string }) {
const [imgSrc, setImgSrc] = useState<string | null>(null);
const [error, setError] = useState(false);
const [attempts, setAttempts] = useState(0);
const [isZoomed, setIsZoomed] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
const { session } = useAuth();
useEffect(() => {
const setupAuthenticatedImage = async () => {
if (src.includes('/sandboxes/') && src.includes('/files/content')) {
try {
const response = await fetch(src, {
headers: {
'Authorization': `Bearer ${session?.access_token}`
}
});
if (!response.ok) {
throw new Error(`Failed to load image: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setImgSrc(url);
} catch (err) {
console.error('Error loading authenticated image:', err);
setError(true);
}
} else {
setImgSrc(src);
}
};
setupAuthenticatedImage();
setError(false);
setAttempts(0);
return () => {
if (imgSrc && imgSrc.startsWith('blob:')) {
URL.revokeObjectURL(imgSrc);
}
};
}, [src, session?.access_token]);
const handleError = () => {
if (attempts < 3) {
setAttempts(attempts + 1);
if (attempts === 0) {
setImgSrc(filePath);
} else if (attempts === 1) {
if (!filePath.startsWith('/')) {
setImgSrc(`/${filePath}`);
} else {
setError(true);
}
} else {
setError(true);
}
} else {
setError(true);
}
};
const handleZoomToggle = () => {
setIsZoomed(!isZoomed);
setZoomLevel(1);
};
const handleZoomIn = (e: React.MouseEvent) => {
e.stopPropagation();
setZoomLevel(prev => Math.min(prev + 0.25, 3));
if (!isZoomed) setIsZoomed(true);
};
const handleZoomOut = (e: React.MouseEvent) => {
e.stopPropagation();
setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
};
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
if (!imgSrc) return;
const link = document.createElement('a');
link.href = imgSrc;
link.download = filePath.split('/').pop() || 'image';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (error) {
return (
<div className="flex flex-col items-center justify-center w-full h-64 bg-gradient-to-b from-rose-50 to-rose-100 dark:from-rose-950/30 dark:to-rose-900/20 rounded-lg border border-rose-200 dark:border-rose-800 text-rose-700 dark:text-rose-300 shadow-inner">
<div className="bg-white dark:bg-black/30 p-3 rounded-full shadow-md mb-3">
<ImageOff className="h-8 w-8 text-rose-500 dark:text-rose-400" />
</div>
<p className="text-sm font-medium">Unable to load generated image</p>
<p className="text-xs text-rose-600/70 dark:text-rose-400/70 mt-1 max-w-xs text-center break-all">
{filePath}
</p>
</div>
);
}
if (!imgSrc) {
return (
<div className="flex py-8 flex-col items-center justify-center w-full h-64 bg-gradient-to-b from-zinc-50 to-zinc-100 dark:from-zinc-900/50 dark:to-zinc-800/30 rounded-lg border-zinc-200 dark:border-zinc-700/50 shadow-inner">
<div className="space-y-2 w-full max-w-md py-8">
<Skeleton className="h-8 w-8 rounded-full mx-auto" />
<Skeleton className="h-4 w-48 mx-auto" />
<Skeleton className="h-64 w-full rounded-lg mt-4" />
</div>
</div>
);
}
return (
<div className="flex flex-col items-center">
<div className={cn(
"overflow-hidden transition-all duration-300 rounded-3xl border bg-card mb-3",
isZoomed ? "cursor-zoom-out" : "cursor-zoom-in"
)}>
<div className="relative flex items-center justify-center">
<img
src={imgSrc}
alt={alt}
onClick={handleZoomToggle}
className={cn(
"max-w-full object-contain transition-all duration-300 ease-in-out",
isZoomed
? "max-h-[80vh]"
: "max-h-[500px] hover:scale-[1.01]",
className
)}
style={{
transform: isZoomed ? `scale(${zoomLevel})` : 'none',
}}
onError={handleError}
/>
</div>
</div>
<div className="flex items-center justify-between w-full px-2 py-2 bg-zinc-50 dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800">
<Badge variant="secondary" className="bg-white/90 dark:bg-black/70 text-zinc-700 dark:text-zinc-300 shadow-sm">
<ImageIcon className="h-3 w-3 mr-1" />
{filePath.split('.').pop()?.toUpperCase()}
</Badge>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md bg-white dark:bg-zinc-800"
onClick={handleZoomOut}
disabled={zoomLevel <= 0.5}
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-xs font-mono px-2 text-zinc-700 dark:text-zinc-300 min-w-12 text-center">
{Math.round(zoomLevel * 100)}%
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md bg-white dark:bg-zinc-800"
onClick={handleZoomIn}
disabled={zoomLevel >= 3}
>
<ZoomIn className="h-4 w-4" />
</Button>
<span className="w-px h-6 bg-zinc-200 dark:bg-zinc-700 mx-2"></span>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md bg-white dark:bg-zinc-800"
onClick={handleDownload}
title="Download image"
>
<Download className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
export function ImageEditOrGenerateToolView({
assistantContent,
toolContent,
assistantTimestamp,
toolTimestamp,
isSuccess = true,
isStreaming = false,
name,
project,
}: ToolViewProps) {
const [progress, setProgress] = useState(0);
const data = extractImageGenerationData(assistantContent, toolContent, isSuccess);
const isGenerateMode = data.mode === 'generate';
const Icon = isGenerateMode ? Sparkles : Wand2;
useEffect(() => {
if (isStreaming) {
const timer = setInterval(() => {
setProgress((prevProgress) => {
if (prevProgress >= 95) {
clearInterval(timer);
return prevProgress;
}
return prevProgress + 5;
});
}, 300);
return () => clearInterval(timer);
} else {
setProgress(100);
}
}, [isStreaming]);
// If no generated image path, fall back to generic view
if (!isStreaming && !data.generatedImagePath) {
return (
<GenericToolView
name={name || 'image-edit-or-generate'}
assistantContent={assistantContent}
toolContent={toolContent}
assistantTimestamp={assistantTimestamp}
toolTimestamp={toolTimestamp}
isSuccess={isSuccess}
isStreaming={isStreaming}
/>
);
}
const config = {
color: isGenerateMode ? 'text-purple-500 dark:text-purple-400' : 'text-blue-500 dark:text-blue-400',
bgColor: isGenerateMode
? 'bg-gradient-to-b from-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20'
: 'bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20',
badgeColor: isGenerateMode
? 'bg-gradient-to-b from-purple-200 to-purple-100 text-purple-700 dark:from-purple-800/50 dark:to-purple-900/60 dark:text-purple-300'
: 'bg-gradient-to-b from-blue-200 to-blue-100 text-blue-700 dark:from-blue-800/50 dark:to-blue-900/60 dark:text-blue-300',
};
const imageUrl = data.generatedImagePath ? constructImageUrl(data.generatedImagePath, project) : '';
const filename = data.generatedImagePath || 'generated_image.png';
const toolTitle = isGenerateMode ? 'Generate Image' : 'Edit Image';
return (
<Card className="flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
<CardHeader className="h-14 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-b p-2 px-4 space-y-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn("relative p-2 rounded-xl bg-gradient-to-br border border-opacity-20 transition-colors", config.bgColor)}>
<Icon className={cn("w-5 h-5", config.color)} />
</div>
<div>
<div className="flex items-center">
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{toolTitle}
</CardTitle>
{data.size && (
<Badge variant="outline" className="ml-2 text-[10px] py-0 px-1.5 h-4">
{data.size}
</Badge>
)}
</div>
{data.prompt && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5 max-w-md truncate">
{truncateString(data.prompt, 50)}
</p>
)}
</div>
</div>
{!isStreaming && (
<Badge
variant="secondary"
className={
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
{isSuccess ? (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
) : (
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
)}
{isSuccess ? 'Generated' : 'Failed'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-0 flex-1 overflow-hidden relative">
{isStreaming ? (
<div className="flex flex-col items-center justify-center h-full p-12 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
<div className="text-center w-full max-w-xs">
<div className="space-y-3 mb-6">
<div className={cn("w-16 h-16 rounded-full mx-auto mb-6 flex items-center justify-center", config.bgColor)}>
<Icon className={cn("h-8 w-8", config.color)} />
</div>
<h3 className="text-xl font-semibold mb-4 text-zinc-900 dark:text-zinc-100">
{isGenerateMode ? "Generating image..." : "Editing image..."}
</h3>
{data.prompt && (
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full text-center mb-6 shadow-sm">
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
{data.prompt}
</code>
</div>
)}
</div>
<div className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-300 ease-out",
isGenerateMode
? "bg-gradient-to-r from-purple-400 to-purple-500 dark:from-purple-600 dark:to-purple-400"
: "bg-gradient-to-r from-blue-400 to-blue-500 dark:from-blue-600 dark:to-blue-400"
)}
style={{ width: `${progress}%` }}
></div>
</div>
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">{progress}%</p>
</div>
</div>
) : (
<div className="flex flex-col">
<div className="relative w-full overflow-hidden p-6 flex items-center justify-center">
{data.generatedImagePath && project ? (
<SafeImage
src={imageUrl}
alt={`Generated image: ${data.prompt || 'AI generated image'}`}
filePath={data.generatedImagePath}
className="max-w-full max-h-[500px] object-contain"
/>
) : (
<div className="flex flex-col items-center justify-center h-64 text-zinc-500 dark:text-zinc-400">
<ImageOff className="h-12 w-12 mb-4" />
<p className="text-sm">No image generated</p>
</div>
)}
</div>
</div>
)}
</CardContent>
<div className="h-10 px-4 py-2 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center">
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<Badge className={cn("py-0.5 h-6 border", config.badgeColor)}>
<Icon className="h-3 w-3 mr-1" />
{isGenerateMode ? 'GENERATED' : 'EDITED'}
</Badge>
<Badge variant="outline" className="py-0 px-1.5 h-5 text-[10px] uppercase font-medium">
PNG
</Badge>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{toolTimestamp ? formatTimestamp(toolTimestamp) : ''}
</div>
</div>
</Card>
);
}

View File

@ -16,7 +16,6 @@ import { CompleteToolView } from '../CompleteToolView';
import { ExecuteDataProviderCallToolView } from '../data-provider-tool/ExecuteDataProviderCallToolView';
import { DataProviderEndpointsToolView } from '../data-provider-tool/DataProviderEndpointsToolView';
import { DeployToolView } from '../DeployToolView';
import { ImageEditOrGenerateToolView } from '../image-edit-generate/ImageEditOrGenerateToolView';
export type ToolViewComponent = React.ComponentType<ToolViewProps>;
@ -70,8 +69,6 @@ const defaultRegistry: ToolViewRegistryType = {
'deploy': DeployToolView,
'image-edit-or-generate': ImageEditOrGenerateToolView,
'default': GenericToolView,
};