mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(image-gen) (#199)
* feat: image generation model with dalle * updates to image-gen-block * improvements: image-gen --------- Co-authored-by: Rishabh Shinde <rishabhsshinde27@gail.com>
This commit is contained in:
co-authored by
Rishabh Shinde
parent
56bec10749
commit
4eb0f5f629
+10
-27
@@ -1,35 +1,21 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
*/node_modules
|
||||
docs/node_modules
|
||||
/packages/**/node_modules
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
*package*
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
sim/.next/
|
||||
sim/out/
|
||||
sim/build
|
||||
docs/.next/
|
||||
docs/out/
|
||||
docs/build
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
**/dist/
|
||||
**/standalone/
|
||||
sim-standalone.tar.gz
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -39,15 +25,10 @@ sim-standalone.tar.gz
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -62,4 +43,6 @@ next-env.d.ts
|
||||
# docs
|
||||
docs/.source
|
||||
docs/.contentlayer
|
||||
docs/.content-collections
|
||||
docs/.content-collections
|
||||
.qodo
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const imageUrl = searchParams.get('url')
|
||||
|
||||
if (!imageUrl) {
|
||||
console.error('Missing URL parameter in proxy-image request')
|
||||
return new NextResponse('Missing URL parameter', { status: 400 })
|
||||
}
|
||||
|
||||
console.log('Proxying image from:', imageUrl)
|
||||
|
||||
// Add appropriate headers for fetching images
|
||||
const response = await fetch(imageUrl, {
|
||||
headers: {
|
||||
'Accept': 'image/*, */*',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ImageProxyBot/1.0)',
|
||||
},
|
||||
// Set a reasonable timeout
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch image from ${imageUrl}:`, response.status, response.statusText)
|
||||
return new NextResponse(`Failed to fetch image: ${response.status} ${response.statusText}`, {
|
||||
status: response.status
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')
|
||||
console.log('Image content-type:', contentType)
|
||||
|
||||
const blob = await response.blob()
|
||||
console.log('Image size:', blob.size, 'bytes')
|
||||
|
||||
if (blob.size === 0) {
|
||||
console.error('Empty image received from source URL')
|
||||
return new NextResponse('Empty image received from source', { status: 422 })
|
||||
}
|
||||
|
||||
// Return the image with appropriate headers
|
||||
return new NextResponse(blob, {
|
||||
headers: {
|
||||
'Content-Type': contentType || 'image/png',
|
||||
'Cache-Control': 'public, max-age=31536000', // Cache for a year
|
||||
'Access-Control-Allow-Origin': '*', // CORS support
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Log the full error for debugging
|
||||
console.error('Error proxying image:', error)
|
||||
|
||||
// Return a helpful error response
|
||||
return new NextResponse(
|
||||
`Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+428
-7
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Download, Image as ImageIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface JSONViewProps {
|
||||
@@ -39,6 +40,136 @@ const copyToClipboard = (data: any) => {
|
||||
navigator.clipboard.writeText(stringified)
|
||||
}
|
||||
|
||||
// Helper function to check if an object contains an image URL
|
||||
const isImageData = (obj: any): boolean => {
|
||||
return obj && typeof obj === 'object' && 'url' in obj && typeof obj.url === 'string'
|
||||
}
|
||||
|
||||
// Helper function to check if a string is likely a base64 image
|
||||
const isBase64Image = (str: string): boolean => {
|
||||
if (typeof str !== 'string') return false
|
||||
// Check if it's a reasonably long string that could be a base64 image
|
||||
// and contains only valid base64 characters
|
||||
return str.length > 100 && /^[A-Za-z0-9+/=]+$/.test(str)
|
||||
}
|
||||
|
||||
// Check if this is a response with the new image structure
|
||||
// Modified to be more flexible - content must be a URL, image is optional
|
||||
const hasImageContent = (obj: any): boolean => {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
'content' in obj &&
|
||||
typeof obj.content === 'string' &&
|
||||
'metadata' in obj &&
|
||||
typeof obj.metadata === 'object' &&
|
||||
// Either has valid image data
|
||||
(('image' in obj && typeof obj.image === 'string') ||
|
||||
// Or at least has content that looks like a URL (more permissive check)
|
||||
obj.content.startsWith('http'))
|
||||
)
|
||||
}
|
||||
|
||||
// Image preview component with support for both URL and base64
|
||||
const ImagePreview = ({
|
||||
imageUrl,
|
||||
imageData,
|
||||
isBase64 = false,
|
||||
}: {
|
||||
imageUrl?: string
|
||||
imageData?: string
|
||||
isBase64?: boolean
|
||||
}) => {
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
let blob: Blob
|
||||
if (isBase64 && imageData && imageData.length > 0) {
|
||||
// Convert base64 to blob
|
||||
const byteString = atob(imageData)
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length)
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
blob = new Blob([arrayBuffer], { type: 'image/png' })
|
||||
} else if (imageUrl && imageUrl.length > 0) {
|
||||
// Use proxy endpoint to fetch image
|
||||
const proxyUrl = `/api/proxy-image?url=${encodeURIComponent(imageUrl)}`
|
||||
const response = await fetch(proxyUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`)
|
||||
}
|
||||
blob = await response.blob()
|
||||
} else {
|
||||
throw new Error('No image data or URL provided')
|
||||
}
|
||||
|
||||
// Create object URL and trigger download
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `generated-image-${Date.now()}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
// Clean up the URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
} catch (error) {
|
||||
console.error('Error downloading image:', error)
|
||||
alert('Failed to download image. Please try again later.')
|
||||
}
|
||||
}
|
||||
|
||||
// Only display image if we have valid data
|
||||
const hasValidData =
|
||||
(isBase64 && imageData && imageData.length > 0) || (imageUrl && imageUrl.length > 0)
|
||||
|
||||
if (!hasValidData) {
|
||||
return <div className="my-2 text-muted-foreground">Image data unavailable</div>
|
||||
}
|
||||
|
||||
// Determine the source for the image
|
||||
const imageSrc =
|
||||
isBase64 && imageData && imageData.length > 0
|
||||
? `data:image/png;base64,${imageData}`
|
||||
: imageUrl || ''
|
||||
|
||||
return (
|
||||
<div className="my-2 relative group">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Generated image"
|
||||
className="max-w-full h-auto rounded-md border"
|
||||
onError={(e) => {
|
||||
console.error('Image failed to load:', imageSrc)
|
||||
e.currentTarget.alt = 'Failed to load image'
|
||||
e.currentTarget.style.height = '100px'
|
||||
e.currentTarget.style.width = '100%'
|
||||
e.currentTarget.style.display = 'flex'
|
||||
e.currentTarget.style.alignItems = 'center'
|
||||
e.currentTarget.style.justifyContent = 'center'
|
||||
e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-background/80 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadImage()
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="sr-only">Download image</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONViewProps) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(!initiallyExpanded)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{
|
||||
@@ -63,7 +194,54 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
|
||||
}
|
||||
}, [contextMenuPosition])
|
||||
|
||||
// Check if this is a base64 image string
|
||||
const isBase64ImageString = typeof data === 'string' && isBase64Image(data)
|
||||
|
||||
// Check if current object contains image URL
|
||||
const hasImageUrl = isImageData(data)
|
||||
|
||||
// Check if this is a response object with the new image format
|
||||
const isResponseWithImage = hasImageContent(data)
|
||||
|
||||
// Check if this is response.output with the new image structure
|
||||
const isToolResponseWithImage =
|
||||
data && typeof data === 'object' && data.output && hasImageContent(data.output)
|
||||
|
||||
if (data === null) return <span className="text-muted-foreground">null</span>
|
||||
|
||||
// Handle base64 image strings directly
|
||||
if (isBase64ImageString) {
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<ImagePreview imageData={data} isBase64={true} />
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className="fixed z-50 bg-popover border rounded-md shadow-md py-1 min-w-[160px]"
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent"
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy base64 string
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent flex items-center gap-2"
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof data !== 'object') {
|
||||
const stringValue = JSON.stringify(data)
|
||||
return (
|
||||
@@ -89,6 +267,219 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
|
||||
)
|
||||
}
|
||||
|
||||
// Handle objects that have the new image structure
|
||||
if (isResponseWithImage) {
|
||||
// Get the URL from content field since that's where it should be
|
||||
const imageUrl = data.content && typeof data.content === 'string' ? data.content : undefined
|
||||
// Check if we have valid image data
|
||||
const hasValidImage = data.image && typeof data.image === 'string' && data.image.length > 0
|
||||
|
||||
return (
|
||||
<div className="relative" onContextMenu={handleContextMenu}>
|
||||
<span
|
||||
className="cursor-pointer select-none inline-flex items-center text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}}
|
||||
>
|
||||
<span className="text-xs leading-none mr-1">{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span>{'{'}</span>
|
||||
{isCollapsed ? '...' : ''}
|
||||
</span>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="ml-4 break-words">
|
||||
{Object.entries(data).map(([key, value], index) => {
|
||||
const isImageKey = key === 'image'
|
||||
|
||||
return (
|
||||
<div key={key} className="break-all">
|
||||
<span className="text-muted-foreground">{key}</span>:{' '}
|
||||
{isImageKey ? (
|
||||
<div>
|
||||
<span className="text-success inline-block mb-2">
|
||||
{hasValidImage && typeof value === 'string' && value.length > 100 ? (
|
||||
<TruncatedValue value={JSON.stringify('[base64 image data]')} />
|
||||
) : (
|
||||
'""'
|
||||
)}
|
||||
</span>
|
||||
{/* Show image preview within the image field */}
|
||||
<ImagePreview
|
||||
imageUrl={imageUrl}
|
||||
imageData={
|
||||
hasValidImage && isBase64Image(data.image) ? data.image : undefined
|
||||
}
|
||||
isBase64={hasValidImage && isBase64Image(data.image)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView data={value} level={level + 1} />
|
||||
)}
|
||||
{index < Object.entries(data).length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className="fixed z-50 bg-popover border rounded-md shadow-md py-1 min-w-[160px]"
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent"
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent flex items-center gap-2"
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground">{'}'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle tool response objects with the new image structure in output
|
||||
if (isToolResponseWithImage) {
|
||||
const outputData = data.output || {}
|
||||
const imageUrl =
|
||||
outputData.content && typeof outputData.content === 'string' ? outputData.content : undefined
|
||||
const hasValidImage =
|
||||
outputData.image && typeof outputData.image === 'string' && outputData.image.length > 0
|
||||
|
||||
return (
|
||||
<div className="relative" onContextMenu={handleContextMenu}>
|
||||
<span
|
||||
className="cursor-pointer select-none inline-flex items-center text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}}
|
||||
>
|
||||
<span className="text-xs leading-none mr-1">{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span>{'{'}</span>
|
||||
{isCollapsed ? '...' : ''}
|
||||
</span>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="ml-4 break-words">
|
||||
{Object.entries(data).map(([key, value]: [string, any], index) => {
|
||||
const isOutputKey = key === 'output'
|
||||
|
||||
return (
|
||||
<div key={key} className="break-all">
|
||||
<span className="text-muted-foreground">{key}</span>:{' '}
|
||||
{isOutputKey ? (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="cursor-pointer select-none inline-flex items-center text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const nestedElem = e.currentTarget.nextElementSibling
|
||||
if (nestedElem) {
|
||||
nestedElem.classList.toggle('hidden')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-xs leading-none mr-1">▼</span>
|
||||
<span>{'{'}</span>
|
||||
</span>
|
||||
<div className="ml-4 break-words">
|
||||
{Object.entries(value).map(
|
||||
([outputKey, outputValue]: [string, any], idx) => {
|
||||
const isImageSubKey = outputKey === 'image'
|
||||
|
||||
return (
|
||||
<div key={outputKey} className="break-all">
|
||||
<span className="text-muted-foreground">{outputKey}</span>:{' '}
|
||||
{isImageSubKey ? (
|
||||
<div>
|
||||
<span className="text-success inline-block mb-2">
|
||||
{hasValidImage && outputValue.length > 100 ? (
|
||||
<TruncatedValue
|
||||
value={JSON.stringify('[base64 image data]')}
|
||||
/>
|
||||
) : (
|
||||
'""'
|
||||
)}
|
||||
</span>
|
||||
{/* Show image preview within nested image field */}
|
||||
<ImagePreview
|
||||
imageUrl={imageUrl}
|
||||
imageData={
|
||||
hasValidImage && isBase64Image(outputValue)
|
||||
? outputValue
|
||||
: undefined
|
||||
}
|
||||
isBase64={hasValidImage && isBase64Image(outputValue)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView data={outputValue} level={level + 2} />
|
||||
)}
|
||||
{idx < Object.entries(value).length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">{'}'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView data={value} level={level + 1} />
|
||||
)}
|
||||
{index < Object.entries(data).length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className="fixed z-50 bg-popover border rounded-md shadow-md py-1 min-w-[160px]"
|
||||
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent"
|
||||
onClick={() => copyToClipboard(data)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent flex items-center gap-2"
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground">{'}'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isArray = Array.isArray(data)
|
||||
const items = isArray ? data : Object.entries(data)
|
||||
const isEmpty = items.length === 0
|
||||
@@ -110,6 +501,10 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
|
||||
<span>{isArray ? '[' : '{'}</span>
|
||||
{isCollapsed ? '...' : ''}
|
||||
</span>
|
||||
|
||||
{/* Direct image render for objects with image URLs */}
|
||||
{!isCollapsed && hasImageUrl && <ImagePreview imageUrl={data.url} />}
|
||||
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
className="fixed z-50 bg-popover border rounded-md shadow-md py-1 min-w-[160px]"
|
||||
@@ -121,8 +516,22 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
{hasImageUrl && (
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-accent flex items-center gap-2"
|
||||
onClick={() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
|
||||
?.click()
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="ml-4 break-words">
|
||||
{isArray
|
||||
@@ -132,13 +541,25 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
|
||||
{index < items.length - 1 && ','}
|
||||
</div>
|
||||
))
|
||||
: (items as [string, any][]).map(([key, value], index) => (
|
||||
<div key={key} className="break-all">
|
||||
<span className="text-muted-foreground">{key}</span>:{' '}
|
||||
<JSONView data={value} level={level + 1} />
|
||||
{index < items.length - 1 && ','}
|
||||
</div>
|
||||
))}
|
||||
: (items as [string, any][]).map(([key, value], index) => {
|
||||
// Handle the case where we have content (URL) and image (base64) fields
|
||||
const isImageField =
|
||||
key === 'image' && typeof value === 'string' && value.length > 100
|
||||
|
||||
return (
|
||||
<div key={key} className="break-all">
|
||||
<span className="text-muted-foreground">{key}</span>:{' '}
|
||||
{isImageField ? (
|
||||
<span className="text-success">
|
||||
<TruncatedValue value={JSON.stringify('[base64 image data]')} />
|
||||
</span>
|
||||
) : (
|
||||
<JSONView data={value} level={level + 1} />
|
||||
)}
|
||||
{index < items.length - 1 && ','}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground">{isArray ? ']' : '}'}</span>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { AirplaneIcon, ImageIcon } from '@/components/icons'
|
||||
import { DalleResponse } from '@/tools/openai/dalle'
|
||||
import { BlockConfig } from '../types'
|
||||
|
||||
export const ImageGeneratorBlock: BlockConfig<DalleResponse> = {
|
||||
type: 'image_generator',
|
||||
name: 'Image Generator',
|
||||
description: 'Generate images',
|
||||
longDescription:
|
||||
'Create high-quality images using DALL-E. Configure resolution, quality, style, and other parameters to get exactly the image you need.',
|
||||
category: 'tools',
|
||||
bgColor: '#FF6B6B',
|
||||
icon: ImageIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'provider',
|
||||
title: 'Provider',
|
||||
type: 'dropdown',
|
||||
layout: 'full',
|
||||
options: [
|
||||
{ label: 'DALL-E', id: 'dalle' },
|
||||
],
|
||||
value: () => 'dalle',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
title: 'Prompt',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Describe the image you want to generate...',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: [
|
||||
{ label: 'DALL-E 2', id: 'dall-e-2' },
|
||||
{ label: 'DALL-E 3', id: 'dall-e-3' },
|
||||
],
|
||||
value: () => 'dall-e-3',
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Size',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: [
|
||||
{ label: '1024x1024', id: '1024x1024' },
|
||||
{ label: '1024x1792', id: '1024x1792' },
|
||||
{ label: '1792x1024', id: '1792x1024' },
|
||||
],
|
||||
value: () => '1024x1024',
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
title: 'Quality',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: [
|
||||
{ label: 'Standard', id: 'standard' },
|
||||
{ label: 'HD', id: 'hd' },
|
||||
],
|
||||
value: () => 'standard',
|
||||
},
|
||||
{
|
||||
id: 'style',
|
||||
title: 'Style',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: [
|
||||
{ label: 'Vivid', id: 'vivid' },
|
||||
{ label: 'Natural', id: 'natural' },
|
||||
],
|
||||
value: () => 'vivid',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter your OpenAI API key',
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['dalle_generate'],
|
||||
config: {
|
||||
tool: () => 'dalle_generate',
|
||||
params: (params) => {
|
||||
if (!params.apiKey) {
|
||||
throw new Error('API key is required')
|
||||
}
|
||||
if (!params.prompt) {
|
||||
throw new Error('Prompt is required')
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: params.prompt,
|
||||
model: params.model || 'dall-e-3',
|
||||
size: params.size || '1024x1024',
|
||||
quality: params.quality || 'standard',
|
||||
style: params.style || 'vivid',
|
||||
apiKey: params.apiKey,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
provider: { type: 'string', required: true },
|
||||
prompt: { type: 'string', required: true },
|
||||
model: { type: 'string', required: true },
|
||||
size: { type: 'string', required: false },
|
||||
quality: { type: 'string', required: false },
|
||||
style: { type: 'string', required: false },
|
||||
apiKey: { type: 'string', required: true },
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: {
|
||||
content: 'string', // URL of the generated image
|
||||
image: 'string', // Base64 image data
|
||||
metadata: 'json' // Contains only model information
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { FunctionBlock } from './blocks/function'
|
||||
import { GitHubBlock } from './blocks/github'
|
||||
import { GmailBlock } from './blocks/gmail'
|
||||
import { GuestyBlock } from './blocks/guesty'
|
||||
import { ImageGeneratorBlock } from './blocks/image-generator'
|
||||
import { JinaBlock } from './blocks/jina'
|
||||
import { NotionBlock } from './blocks/notion'
|
||||
import { OpenAIBlock } from './blocks/openai'
|
||||
@@ -65,6 +66,7 @@ export {
|
||||
GoogleSheetsBlock,
|
||||
PerplexityBlock,
|
||||
ConfluenceBlock,
|
||||
ImageGeneratorBlock,
|
||||
}
|
||||
|
||||
// Registry of all block configurations, alphabetically sorted
|
||||
@@ -83,6 +85,7 @@ const blocks: Record<string, BlockConfig> = {
|
||||
google_drive: GoogleDriveBlock,
|
||||
google_sheets: GoogleSheetsBlock,
|
||||
// guesty: GuestyBlock,
|
||||
image_generator: ImageGeneratorBlock,
|
||||
jina: JinaBlock,
|
||||
notion: NotionBlock,
|
||||
openai: OpenAIBlock,
|
||||
|
||||
@@ -137,6 +137,10 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
|
||||
whenFilled: 'json'
|
||||
}
|
||||
}
|
||||
visualization?: {
|
||||
type: 'image'
|
||||
url: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1735,3 +1735,24 @@ export function ConfluenceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ImageIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="22" height="22" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="M3 21L10 14L14 18L21 11L25 15V21" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+48
-84
@@ -822,61 +822,35 @@ export class Executor {
|
||||
* @returns A meaningful error message string
|
||||
*/
|
||||
private extractErrorMessage(error: any): string {
|
||||
if (!error) return 'Unknown error occurred'
|
||||
|
||||
// Handle Error instances
|
||||
if (error instanceof Error) {
|
||||
return error.message || `Error: ${String(error)}`
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
// If it's already a string, return it
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
|
||||
// Handle object errors with nested structure
|
||||
if (typeof error === 'object') {
|
||||
// Case: { error: { message: "msg" } }
|
||||
if (error.error && typeof error.error === 'object' && error.error.message) {
|
||||
return error.error.message
|
||||
}
|
||||
|
||||
// Case: { error: "msg" }
|
||||
if (error.error && typeof error.error === 'string') {
|
||||
return error.error
|
||||
}
|
||||
|
||||
// Case: { message: "msg" }
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
// Add specific handling for HTTP errors
|
||||
if (error.status || error.request) {
|
||||
let message = 'API request failed'
|
||||
|
||||
// Add URL information if available
|
||||
if (error.request && error.request.url) {
|
||||
message += `: ${error.request.url}`
|
||||
}
|
||||
|
||||
// Add status code if available
|
||||
if (error.status) {
|
||||
message += ` (Status: ${error.status})`
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// Last resort: try to stringify the object
|
||||
try {
|
||||
return `Error details: ${JSON.stringify(error)}`
|
||||
} catch {
|
||||
return 'Error occurred but details could not be displayed'
|
||||
}
|
||||
// If it has a message property, use that
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return 'Unknown error occurred'
|
||||
// If it's an object with response data, include that
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
if (data.message) {
|
||||
return data.message
|
||||
}
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
|
||||
// If it's an object, stringify it
|
||||
if (typeof error === 'object') {
|
||||
return JSON.stringify(error)
|
||||
}
|
||||
|
||||
// Fallback to string conversion
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -887,44 +861,34 @@ export class Executor {
|
||||
* @returns A sanitized version of the error for logging
|
||||
*/
|
||||
private sanitizeError(error: any): any {
|
||||
if (!error) return { message: 'No error details available' }
|
||||
|
||||
// Handle Error instances
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message || 'Error without message',
|
||||
stack: error.stack,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
// If it's already a string, return it
|
||||
if (typeof error === 'string') {
|
||||
return { message: error }
|
||||
}
|
||||
|
||||
// Handle object errors with nested structure
|
||||
if (typeof error === 'object') {
|
||||
// If error has a nested error object with undefined message, fix it
|
||||
if (error.error && typeof error.error === 'object') {
|
||||
if (!error.error.message) {
|
||||
error.error.message = 'No specific error message provided'
|
||||
}
|
||||
}
|
||||
|
||||
// If no message property exists at root level, add one
|
||||
if (!error.message) {
|
||||
if (error.error && typeof error.error === 'string') {
|
||||
error.message = error.error
|
||||
} else if (error.status) {
|
||||
error.message = `API request failed with status ${error.status}`
|
||||
} else {
|
||||
error.message = 'Error occurred during workflow execution'
|
||||
}
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
return { message: `Unexpected error type: ${typeof error}` }
|
||||
// If it has a message property, return that
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
// If it's an object with response data, include that
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
if (data.message) {
|
||||
return data.message
|
||||
}
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
|
||||
// If it's an object, stringify it
|
||||
if (typeof error === 'object') {
|
||||
return JSON.stringify(error)
|
||||
}
|
||||
|
||||
// Fallback to string conversion
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -6,7 +6,11 @@ const isStandaloneBuild = process.env.USE_LOCAL_STORAGE === 'true'
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
images: {
|
||||
domains: ['avatars.githubusercontent.com'],
|
||||
domains: [
|
||||
'avatars.githubusercontent.com',
|
||||
'oaidalleapiprodscus.blob.core.windows.net',
|
||||
'api.stability.ai',
|
||||
],
|
||||
// Enable static image optimization for standalone export
|
||||
unoptimized: isStandaloneBuild,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import { requestTool as httpRequest } from './http/request'
|
||||
import { contactsTool as hubspotContacts } from './hubspot/contacts'
|
||||
import { readUrlTool } from './jina/reader'
|
||||
import { notionReadTool, notionWriteTool } from './notion'
|
||||
import { dalleTool } from './openai/dalle'
|
||||
import { embeddingsTool as openAIEmbeddings } from './openai/embeddings'
|
||||
import { perplexityChatTool } from './perplexity'
|
||||
import {
|
||||
@@ -102,6 +103,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
confluence_retrieve: confluenceRetrieveTool,
|
||||
confluence_list: confluenceListTool,
|
||||
confluence_update: confluenceUpdateTool,
|
||||
dalle_generate: dalleTool,
|
||||
}
|
||||
|
||||
// Get a tool by its ID
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { ToolConfig, ToolResponse } from '../types'
|
||||
|
||||
export interface DalleResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string // This will now be the image URL
|
||||
image: string // This will be the base64 image data
|
||||
metadata: {
|
||||
model: string // Only contains model name now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dalleTool: ToolConfig = {
|
||||
id: 'dalle_generate',
|
||||
name: 'DALL-E Generate',
|
||||
description: 'Generate images using OpenAI\'s DALL-E model',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'A text description of the desired image(s)',
|
||||
},
|
||||
model: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The DALL-E model to use (dall-e-2 or dall-e-3)',
|
||||
},
|
||||
size: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'The size of the generated images (1024x1024, 1024x1792, or 1792x1024)',
|
||||
},
|
||||
quality: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'The quality of the image (standard or hd)',
|
||||
},
|
||||
style: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'The style of the image (vivid or natural)',
|
||||
},
|
||||
n: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'The number of images to generate (1-10)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Your OpenAI API key',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: 'https://api.openai.com/v1/images/generations',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
body: (params) => ({
|
||||
model: params.model,
|
||||
prompt: params.prompt,
|
||||
size: params.size || '1024x1024',
|
||||
quality: params.quality || 'standard',
|
||||
style: params.style || 'vivid',
|
||||
n: params.n || 1,
|
||||
}),
|
||||
},
|
||||
transformResponse: async (response, params) => {
|
||||
try {
|
||||
const data = await response.json()
|
||||
|
||||
console.log('DALL-E API response:', JSON.stringify(data, null, 2))
|
||||
|
||||
if (!data.data?.[0]?.url) {
|
||||
console.error('No image URL in DALL-E response:', data)
|
||||
throw new Error('No image URL in response')
|
||||
}
|
||||
|
||||
const imageUrl = data.data[0].url
|
||||
const modelName = data.model || params?.model || 'dall-e'
|
||||
|
||||
console.log('Got image URL:', imageUrl)
|
||||
console.log('Using model:', modelName)
|
||||
|
||||
try {
|
||||
// Fetch the image using the proxy-image endpoint instead of direct fetch
|
||||
console.log('Fetching image from URL via proxy...')
|
||||
const proxyUrl = `/api/proxy-image?url=${encodeURIComponent(imageUrl)}`
|
||||
|
||||
const imageResponse = await fetch(proxyUrl, {
|
||||
headers: {
|
||||
'Accept': 'image/*, */*',
|
||||
},
|
||||
cache: 'no-store', // Don't use cache
|
||||
})
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
console.error('Failed to fetch image:', imageResponse.status, imageResponse.statusText)
|
||||
throw new Error(`Failed to fetch image: ${imageResponse.statusText}`)
|
||||
}
|
||||
|
||||
console.log('Image fetch successful, content-type:', imageResponse.headers.get('content-type'))
|
||||
|
||||
const imageBlob = await imageResponse.blob()
|
||||
console.log('Image blob size:', imageBlob.size)
|
||||
|
||||
if (imageBlob.size === 0) {
|
||||
console.error('Empty image blob received')
|
||||
throw new Error('Empty image received')
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
try {
|
||||
const base64data = reader.result as string
|
||||
if (!base64data) {
|
||||
reject(new Error('No data read from image'))
|
||||
return
|
||||
}
|
||||
|
||||
const base64Content = base64data.split(',')[1] // Remove the data URL prefix
|
||||
console.log('Successfully converted image to base64, length:', base64Content.length)
|
||||
resolve(base64Content)
|
||||
} catch (err) {
|
||||
console.error('Error in FileReader onloadend:', err)
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
reader.onerror = (err) => {
|
||||
console.error('FileReader error:', err)
|
||||
reject(new Error('Failed to read image data'))
|
||||
}
|
||||
reader.readAsDataURL(imageBlob)
|
||||
})
|
||||
|
||||
const base64Image = await base64Promise
|
||||
|
||||
console.log('Returning success response with image data')
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: imageUrl, // Now using image URL as content
|
||||
image: base64Image, // Base64 image in separate field
|
||||
metadata: {
|
||||
model: modelName, // Only include model name in metadata
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but continue with returning the URL
|
||||
console.error('Error fetching or processing image:', error)
|
||||
|
||||
// Try again with a direct browser fetch as fallback
|
||||
try {
|
||||
console.log('Attempting fallback with direct browser fetch...')
|
||||
const directImageResponse = await fetch(imageUrl, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Accept': 'image/*, */*',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; DalleProxy/1.0)',
|
||||
},
|
||||
})
|
||||
|
||||
if (!directImageResponse.ok) {
|
||||
throw new Error(`Direct fetch failed: ${directImageResponse.status}`)
|
||||
}
|
||||
|
||||
const imageBlob = await directImageResponse.blob()
|
||||
if (imageBlob.size === 0) {
|
||||
throw new Error('Empty blob received from direct fetch')
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
try {
|
||||
const base64data = reader.result as string
|
||||
if (!base64data) {
|
||||
reject(new Error('No data read from image'))
|
||||
return
|
||||
}
|
||||
|
||||
const base64Content = base64data.split(',')[1]
|
||||
console.log('Successfully converted image to base64 via direct fetch, length:', base64Content.length)
|
||||
resolve(base64Content)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(imageBlob)
|
||||
})
|
||||
|
||||
const base64Image = await base64Promise
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: imageUrl,
|
||||
image: base64Image,
|
||||
metadata: {
|
||||
model: modelName,
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback fetch also failed:', fallbackError)
|
||||
|
||||
// Even if both attempts fail, still return the URL and metadata
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: imageUrl, // URL as content
|
||||
image: '', // Empty image since we couldn't get it
|
||||
metadata: {
|
||||
model: modelName,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in DALL-E response handling:', error)
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
transformError: (error) => {
|
||||
console.error('DALL-E error:', error)
|
||||
if (error.response?.data?.error?.message) {
|
||||
return error.response.data.error.message
|
||||
}
|
||||
return error.message || 'Failed to generate image with DALL-E'
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user