mirror of https://github.com/kortix-ai/suna.git
wip
This commit is contained in:
parent
76f1ce59f8
commit
bd3f13ea1f
|
@ -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:
|
||||
|
|
|
@ -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)}")
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue