image edit-gen tool-view

This commit is contained in:
marko-kraemer 2025-08-14 18:19:01 -07:00
parent 7cbf8d961e
commit a4519f81ce
3 changed files with 490 additions and 1 deletions

View File

@ -0,0 +1,266 @@
import React from 'react';
import {
Palette,
CheckCircle,
AlertTriangle,
Loader2,
Wand2,
Edit3,
ImageIcon,
Sparkles,
} from 'lucide-react';
import { ToolViewProps } from '../types';
import {
formatTimestamp,
getToolTitle,
} from '../utils';
import { extractImageEditGenerateData } from './_utils';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileAttachment } from '../../file-attachment';
interface ImageEditGenerateToolViewProps extends ToolViewProps {
onFileClick?: (filePath: string) => void;
}
export function ImageEditGenerateToolView({
name = 'image_edit_or_generate',
assistantContent,
toolContent,
assistantTimestamp,
toolTimestamp,
isSuccess = true,
isStreaming = false,
onFileClick,
project,
}: ImageEditGenerateToolViewProps) {
const {
mode,
prompt,
imagePath,
generatedImagePath,
status,
error,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
} = extractImageEditGenerateData(
assistantContent,
toolContent,
isSuccess,
toolTimestamp,
assistantTimestamp
);
const toolTitle = getToolTitle(name) || 'Image Generation';
const handleFileClick = (filePath: string) => {
if (onFileClick) {
onFileClick(filePath);
}
};
const getModeIcon = () => {
if (mode === 'generate') {
return <Sparkles className="w-5 h-5 text-purple-500 dark:text-purple-400" />;
} else if (mode === 'edit') {
return <Edit3 className="w-5 h-5 text-purple-500 dark:text-purple-400" />;
}
return <Palette className="w-5 h-5 text-purple-500 dark:text-purple-400" />;
};
const getModeText = () => {
if (mode === 'generate') return 'Image Generation';
if (mode === 'edit') return 'Image Editing';
return 'Image Tool';
};
const getDisplayPrompt = () => {
if (!prompt) return 'No prompt provided';
return prompt.length > 100 ? `${prompt.substring(0, 100)}...` : prompt;
};
// Collect all images to display
const imagesToDisplay: string[] = [];
if (imagePath) imagesToDisplay.push(imagePath);
if (generatedImagePath) imagesToDisplay.push(generatedImagePath);
return (
<Card className="gap-0 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-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 border border-purple-500/20">
{getModeIcon()}
</div>
<div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{getModeText()}
</CardTitle>
{prompt && (
<p className="text-xs text-muted-foreground mt-0.5 max-w-md truncate">
{getDisplayPrompt()}
</p>
)}
</div>
</div>
{!isStreaming && (
<Badge
variant="secondary"
className={
actualIsSuccess
? "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"
}
>
{actualIsSuccess ? (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
) : (
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
)}
{actualIsSuccess ? 'Success' : 'Failed'}
</Badge>
)}
{isStreaming && (
<Badge className="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">
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
{mode === 'generate' ? 'Generating' : 'Editing'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-0 flex-1 overflow-hidden relative">
<ScrollArea className="h-full w-full">
<div className="p-4 space-y-6">
{/* Error Message */}
{error && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium">Error</span>
</div>
<p className="text-sm text-red-600 dark:text-red-300 mt-1">{error}</p>
</div>
)}
{/* Generated Images */}
{imagesToDisplay.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ImageIcon className="h-4 w-4" />
{mode === 'generate' ? 'Generated Image' : 'Images'} ({imagesToDisplay.length})
</div>
<div className={cn(
"grid gap-3",
imagesToDisplay.length === 1 ? "grid-cols-1" :
imagesToDisplay.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
"grid-cols-1 sm:grid-cols-2"
)}>
{imagesToDisplay.map((imagePath, index) => {
const isInputImage = imagePath === imagePath && mode === 'edit' && index === 0;
const isGeneratedImage = imagePath === generatedImagePath;
return (
<div
key={index}
className="relative group"
>
{/* Image Label */}
{imagesToDisplay.length > 1 && (
<div className="absolute top-2 left-2 z-10">
<Badge
variant="secondary"
className="text-xs bg-black/70 text-white border-0"
>
{isInputImage ? 'Input' : isGeneratedImage ? 'Generated' : `Image ${index + 1}`}
</Badge>
</div>
)}
<FileAttachment
filepath={imagePath}
onClick={handleFileClick}
sandboxId={project?.sandbox?.id}
showPreview={true}
className="aspect-square w-full"
customStyle={{
width: '100%',
height: '100%',
'--attachment-height': '100%'
} as React.CSSProperties}
collapsed={false}
project={project}
/>
</div>
);
})}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
{mode === 'generate' ? (
<Sparkles className="h-8 w-8 text-muted-foreground" />
) : (
<Edit3 className="h-8 w-8 text-muted-foreground" />
)}
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{mode === 'generate' ? 'Image Generation' : 'Image Editing'}
</h3>
<p className="text-sm text-muted-foreground">
{actualIsSuccess ? 'Processing completed' : 'No image generated'}
</p>
</div>
)}
{/* Prompt Details */}
{prompt && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Wand2 className="h-4 w-4" />
Prompt
</div>
<div className="p-3 rounded-lg bg-muted/50 border">
<p className="text-sm text-foreground break-words">{prompt}</p>
</div>
</div>
)}
{/* Status Message */}
{status && status !== prompt && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Status
</div>
<div className="p-3 rounded-lg bg-muted/50 border">
<p className="text-sm text-foreground break-words">{status}</p>
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
<div className="px-4 py-2 h-10 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 gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<Badge className="h-6 py-0.5" variant="outline">
<Palette className="h-3 w-3" />
Image Tool
</Badge>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{actualAssistantTimestamp ? formatTimestamp(actualAssistantTimestamp) : ''}
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,222 @@
import { extractToolData, normalizeContentToString } from '../utils';
export interface ImageEditGenerateData {
mode: 'generate' | 'edit' | null;
prompt: string | null;
imagePath: string | null;
generatedImagePath: string | null;
status: string | null;
success?: boolean;
timestamp?: string;
error?: string | null;
}
const parseContent = (content: any): any => {
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (e) {
return content;
}
}
return content;
};
const extractFromNewFormat = (content: any): ImageEditGenerateData => {
const parsedContent = parseContent(content);
if (!parsedContent || typeof parsedContent !== 'object') {
return {
mode: null,
prompt: null,
imagePath: null,
generatedImagePath: null,
status: null,
success: undefined,
timestamp: undefined,
error: null
};
}
if ('tool_execution' in parsedContent && typeof parsedContent.tool_execution === 'object') {
const toolExecution = parsedContent.tool_execution;
const args = toolExecution.arguments || {};
const result = toolExecution.result || {};
// Extract generated image path from the output
let generatedImagePath: string | null = null;
if (result.output && typeof result.output === 'string') {
// Look for patterns like "Image saved as: generated_image_xxx.png"
const imagePathMatch = result.output.match(/Image saved as:\s*([^\s.]+\.(png|jpg|jpeg|webp|gif))/i);
if (imagePathMatch) {
generatedImagePath = imagePathMatch[1];
}
}
const extractedData: ImageEditGenerateData = {
mode: args.mode || null,
prompt: args.prompt || null,
imagePath: args.image_path || null,
generatedImagePath,
status: result.output || null,
success: result.success,
timestamp: toolExecution.execution_details?.timestamp,
error: result.error || null
};
return extractedData;
}
if ('role' in parsedContent && 'content' in parsedContent) {
return extractFromNewFormat(parsedContent.content);
}
return {
mode: null,
prompt: null,
imagePath: null,
generatedImagePath: null,
status: null,
success: undefined,
timestamp: undefined,
error: null
};
};
const extractFromLegacyFormat = (content: any): ImageEditGenerateData => {
const toolData = extractToolData(content);
if (toolData.toolResult && toolData.arguments) {
// Extract generated image path from the result
let generatedImagePath: string | null = null;
if (toolData.toolResult && typeof toolData.toolResult === 'string') {
const imagePathMatch = toolData.toolResult.match(/Image saved as:\s*([^\s.]+\.(png|jpg|jpeg|webp|gif))/i);
if (imagePathMatch) {
generatedImagePath = imagePathMatch[1];
}
}
return {
mode: toolData.arguments.mode || null,
prompt: toolData.arguments.prompt || null,
imagePath: toolData.arguments.image_path || null,
generatedImagePath,
status: toolData.toolResult,
success: toolData.success,
timestamp: undefined,
error: toolData.error || null
};
}
const contentStr = normalizeContentToString(content);
if (!contentStr) {
return {
mode: null,
prompt: null,
imagePath: null,
generatedImagePath: null,
status: null,
success: undefined,
timestamp: undefined,
error: null
};
}
// Try to extract data from XML-like format
let mode: 'generate' | 'edit' | null = null;
const modeMatch = contentStr.match(/<parameter name="mode">([^<]*)<\/parameter>/i);
if (modeMatch) {
mode = modeMatch[1].trim() as 'generate' | 'edit';
}
let prompt: string | null = null;
const promptMatch = contentStr.match(/<parameter name="prompt">([^<]*)<\/parameter>/i);
if (promptMatch) {
prompt = promptMatch[1].trim();
}
let imagePath: string | null = null;
const imagePathMatch = contentStr.match(/<parameter name="image_path">([^<]*)<\/parameter>/i);
if (imagePathMatch) {
imagePath = imagePathMatch[1].trim();
}
// Try to extract generated image path from output
let generatedImagePath: string | null = null;
const generatedImageMatch = contentStr.match(/Image saved as:\s*([^\s.]+\.(png|jpg|jpeg|webp|gif))/i);
if (generatedImageMatch) {
generatedImagePath = generatedImageMatch[1];
}
return {
mode,
prompt,
imagePath,
generatedImagePath,
status: null,
success: undefined,
timestamp: undefined,
error: null
};
};
export function extractImageEditGenerateData(
assistantContent: any,
toolContent: any,
isSuccess: boolean,
toolTimestamp?: string,
assistantTimestamp?: string
): ImageEditGenerateData & {
actualIsSuccess: boolean;
actualToolTimestamp?: string;
actualAssistantTimestamp?: string;
} {
let actualIsSuccess = isSuccess;
let actualToolTimestamp = toolTimestamp;
let actualAssistantTimestamp = assistantTimestamp;
const assistantNewFormat = extractFromNewFormat(assistantContent);
const toolNewFormat = extractFromNewFormat(toolContent);
// Prefer data from toolContent if it has meaningful data
let finalData = { ...assistantNewFormat };
if (toolNewFormat.mode || toolNewFormat.prompt || toolNewFormat.generatedImagePath) {
finalData = { ...toolNewFormat };
if (toolNewFormat.success !== undefined) {
actualIsSuccess = toolNewFormat.success;
}
if (toolNewFormat.timestamp) {
actualToolTimestamp = toolNewFormat.timestamp;
}
} else if (assistantNewFormat.mode || assistantNewFormat.prompt || assistantNewFormat.generatedImagePath) {
if (assistantNewFormat.success !== undefined) {
actualIsSuccess = assistantNewFormat.success;
}
if (assistantNewFormat.timestamp) {
actualAssistantTimestamp = assistantNewFormat.timestamp;
}
} else {
// Fall back to legacy format
const assistantLegacy = extractFromLegacyFormat(assistantContent);
const toolLegacy = extractFromLegacyFormat(toolContent);
finalData = {
mode: toolLegacy.mode || assistantLegacy.mode,
prompt: toolLegacy.prompt || assistantLegacy.prompt,
imagePath: toolLegacy.imagePath || assistantLegacy.imagePath,
generatedImagePath: toolLegacy.generatedImagePath || assistantLegacy.generatedImagePath,
status: toolLegacy.status || assistantLegacy.status,
success: toolLegacy.success || assistantLegacy.success,
timestamp: toolLegacy.timestamp || assistantLegacy.timestamp,
error: toolLegacy.error || assistantLegacy.error
};
}
return {
...finalData,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
};
}

View File

@ -29,6 +29,7 @@ import { GetCurrentAgentConfigToolView } from '../get-current-agent-config/get-c
import { TaskListToolView } from '../task-list/TaskListToolView';
import { SheetsToolView } from '../sheets-tools/sheets-tool-view';
import { GetProjectStructureView } from '../web-dev/GetProjectStructureView';
import { ImageEditGenerateToolView } from '../image-edit-generate-tool/ImageEditGenerateToolView';
export type ToolViewComponent = React.ComponentType<ToolViewProps>;
@ -79,7 +80,7 @@ const defaultRegistry: ToolViewRegistryType = {
'expose-port': ExposePortToolView,
'see-image': SeeImageToolView,
'image_edit_or_generate': GenericToolView,
'image-edit-or-generate': ImageEditGenerateToolView,
'ask': AskToolView,
'complete': CompleteToolView,