diff --git a/templates/next-image/.env.example b/templates/next-image/.env.example new file mode 100644 index 000000000..3c44a5fad --- /dev/null +++ b/templates/next-image/.env.example @@ -0,0 +1,3 @@ +NEXT_PUBLIC_ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc" +ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc" +BLOB_READ_WRITE_TOKEN= \ No newline at end of file diff --git a/templates/next-image/.env.local b/templates/next-image/.env.local index 054d0b66d..005e0844f 100644 --- a/templates/next-image/.env.local +++ b/templates/next-image/.env.local @@ -1,2 +1,5 @@ NEXT_PUBLIC_ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc" -ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc" \ No newline at end of file +ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc" + +# get your blob read write token from your vercel storage dashboard +BLOB_READ_WRITE_TOKEN="your-blob-read-write-token-here" \ No newline at end of file diff --git a/templates/next-image/.gitignore b/templates/next-image/.gitignore index 5340dd9a1..3b8750ae4 100644 --- a/templates/next-image/.gitignore +++ b/templates/next-image/.gitignore @@ -33,6 +33,9 @@ yarn-error.log* # vercel .vercel +# env +.env + # typescript *.tsbuildinfo next-env.d.ts diff --git a/templates/next-image/package.json b/templates/next-image/package.json index 144db1268..41aed4afb 100644 --- a/templates/next-image/package.json +++ b/templates/next-image/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@vercel/blob": "^0.25.1", "ai": "5.0.47", "autonumeric": "^4.10.9", "class-variance-authority": "^0.7.1", diff --git a/templates/next-image/src/app/api/blob/upload-handler/route.ts b/templates/next-image/src/app/api/blob/upload-handler/route.ts new file mode 100644 index 000000000..42fbae4e8 --- /dev/null +++ b/templates/next-image/src/app/api/blob/upload-handler/route.ts @@ -0,0 +1,50 @@ +/** + * API Route: Blob Upload Handler + * + * This route handles client-side blob upload token generation. + * It's called by @vercel/blob/client's upload() function to generate + * secure tokens that allow direct client-to-blob uploads. + * + * SECURITY: + * - Validates file types (images only) + * - Sets maximum file size (50MB) + * - Generates temporary tokens + */ + +import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'; +import { NextResponse } from 'next/server'; + +export async function POST(request: Request): Promise { + const body = (await request.json()) as HandleUploadBody; + + try { + const jsonResponse = await handleUpload({ + body, + request, + onBeforeGenerateToken: async (pathname, clientPayload, multipart) => { + // Here you can add authentication/authorization logic + // For example: verify user session, check quotas, etc. + + return { + allowedContentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif'], + maximumSizeInBytes: 50 * 1024 * 1024, // 50MB + addRandomSuffix: true, // Prevent filename collisions and add obscurity + }; + }, + onUploadCompleted: async ({ blob, tokenPayload }) => { + // Called after successful upload + // You can add logging, analytics, etc. here + console.log('Blob upload completed:', blob.pathname); + }, + }); + + return NextResponse.json(jsonResponse); + } catch (error) { + console.error('Upload handler error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Upload failed' }, + { status: 400 } + ); + } +} + diff --git a/templates/next-image/src/app/api/edit-image/google.ts b/templates/next-image/src/app/api/edit-image/google.ts index 527c4879d..a71f78e5b 100644 --- a/templates/next-image/src/app/api/edit-image/google.ts +++ b/templates/next-image/src/app/api/edit-image/google.ts @@ -5,25 +5,29 @@ import { google } from '@/echo'; import { generateText } from 'ai'; import { getMediaTypeFromDataUrl } from '@/lib/image-utils'; +import { processImageUrls } from '@/lib/server-blob-utils'; import { ERROR_MESSAGES } from '@/lib/constants'; /** * Handles Google Gemini image editing + * Converts image URLs to data URLs for processing */ export async function handleGoogleEdit( prompt: string, imageUrls: string[] ): Promise { try { + const dataUrls = await processImageUrls(imageUrls); + const content = [ { type: 'text' as const, text: prompt, }, - ...imageUrls.map(imageUrl => ({ + ...dataUrls.map(dataUrl => ({ type: 'image' as const, - image: imageUrl, // Direct data URL - Gemini handles it - mediaType: getMediaTypeFromDataUrl(imageUrl), + image: dataUrl, + mediaType: getMediaTypeFromDataUrl(dataUrl), })), ]; diff --git a/templates/next-image/src/app/api/edit-image/openai.ts b/templates/next-image/src/app/api/edit-image/openai.ts index 6fbee624d..8eabd9260 100644 --- a/templates/next-image/src/app/api/edit-image/openai.ts +++ b/templates/next-image/src/app/api/edit-image/openai.ts @@ -5,10 +5,12 @@ import { getEchoToken } from '@/echo'; import OpenAI from 'openai'; import { dataUrlToFile } from '@/lib/image-utils'; +import { processImageUrls } from '@/lib/server-blob-utils'; import { ERROR_MESSAGES } from '@/lib/constants'; /** * Handles OpenAI image editing + * Converts image URLs to data URLs for processing */ export async function handleOpenAIEdit( prompt: string, @@ -32,7 +34,9 @@ export async function handleOpenAIEdit( }); try { - const imageFiles = imageUrls.map(url => dataUrlToFile(url, 'image.png')); + const dataUrls = await processImageUrls(imageUrls); + + const imageFiles = dataUrls.map(url => dataUrlToFile(url, 'image.png')); const result = await openaiClient.images.edit({ image: imageFiles, diff --git a/templates/next-image/src/app/api/edit-image/route.ts b/templates/next-image/src/app/api/edit-image/route.ts index 11c52b38e..c268282c4 100644 --- a/templates/next-image/src/app/api/edit-image/route.ts +++ b/templates/next-image/src/app/api/edit-image/route.ts @@ -3,7 +3,6 @@ * * This route demonstrates Echo SDK integration with AI image editing: * - Uses both Google Gemini and OpenAI for image editing - * - Supports both data URLs (base64) and regular URLs * - Validates input images and prompts * - Returns edited images in appropriate format */ @@ -17,14 +16,6 @@ const providers = { gemini: handleGoogleEdit, }; -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; - export async function POST(req: Request) { try { const body = await req.json(); diff --git a/templates/next-image/src/app/api/generate-image/route.ts b/templates/next-image/src/app/api/generate-image/route.ts index 15bf30c3a..fbd600b90 100644 --- a/templates/next-image/src/app/api/generate-image/route.ts +++ b/templates/next-image/src/app/api/generate-image/route.ts @@ -18,15 +18,6 @@ const providers = { openai: handleOpenAIGenerate, gemini: handleGoogleGenerate, }; - -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; - export async function POST(req: Request) { try { const body = await req.json(); diff --git a/templates/next-image/src/app/page.tsx b/templates/next-image/src/app/page.tsx index c8a822b13..ba3a3fda1 100644 --- a/templates/next-image/src/app/page.tsx +++ b/templates/next-image/src/app/page.tsx @@ -21,7 +21,6 @@ import { isSignedIn } from '@/echo'; import ImageGenerator from '@/components/image-generator'; -import { EchoWidget } from '@/components/echo-tokens'; import { EchoSignIn } from '@merit-systems/echo-next-sdk/client'; import { EchoAccount } from '@/components/echo-account-next'; @@ -40,9 +39,9 @@ export default async function Home() { return (
{/* Header with title and token display */} -
+
-

+

Echo Image Gen

diff --git a/templates/next-image/src/components/ai-elements/prompt-input.tsx b/templates/next-image/src/components/ai-elements/prompt-input.tsx index 600b77bca..25fb1ef6a 100644 --- a/templates/next-image/src/components/ai-elements/prompt-input.tsx +++ b/templates/next-image/src/components/ai-elements/prompt-input.tsx @@ -688,7 +688,7 @@ export const PromptInputModelSelectTrigger = ({ { @@ -162,30 +163,33 @@ export default function ImageGenerator() { // Generate unique ID for this request const imageId = `img_${Date.now()}`; - // Convert attachment blob URLs to permanent data URLs for persistent display - const attachmentDataUrls = + // Convert blob URLs to File objects ONCE (for both upload and history display) + const imageFiles = message.files && message.files.length > 0 ? await Promise.all( message.files - .filter(f => f.mediaType?.startsWith('image/')) - .map(async f => { + .filter(f => f.mediaType?.startsWith('image/') || f.type === 'file') + .map(async (f, index) => { try { const response = await fetch(f.url); const blob = await response.blob(); - return await fileToDataUrl( - new File([blob], f.filename || 'image', { - type: f.mediaType, - }) + return new File( + [blob], + f.filename || `image-${index}.png`, + { type: f.mediaType } ); } catch (error) { - console.error( - 'Failed to convert attachment to data URL:', - error - ); - return f.url; // fallback + console.error('Failed to convert attachment:', error); + throw error; } }) ) + : []; + + // Convert to data URLs for history display (only if we have files) + const attachmentDataUrls = + imageFiles.length > 0 + ? await Promise.all(imageFiles.map(file => fileToDataUrl(file))) : undefined; // Create placeholder entry immediately for optimistic UI @@ -206,31 +210,20 @@ export default function ImageGenerator() { let imageUrl: ImageResponse['imageUrl']; if (isEdit) { - const imageFiles = - message.files?.filter( - file => - file.mediaType?.startsWith('image/') || file.type === 'file' - ) || []; - if (imageFiles.length === 0) { throw new Error('No image files found in attachments'); } try { - const imageUrls = await Promise.all( - imageFiles.map(async imageFile => { - // Convert blob URL to data URL for API - const response = await fetch(imageFile.url); - const blob = await response.blob(); - return await fileToDataUrl( - new File([blob], 'image', { type: imageFile.mediaType }) - ); - }) - ); + // Upload files to Vercel Blob storage (files already converted above) + const blobResults = await uploadFilesToBlob(imageFiles); + + // Extract blob URLs for API + const blobUrls = blobResults.map(result => result.url); const result = await editImage({ prompt, - imageUrls, + imageUrls: blobUrls, provider: model, }); imageUrl = result.imageUrl; diff --git a/templates/next-image/src/lib/blob-utils.ts b/templates/next-image/src/lib/blob-utils.ts new file mode 100644 index 000000000..30f758b30 --- /dev/null +++ b/templates/next-image/src/lib/blob-utils.ts @@ -0,0 +1,82 @@ +/** + * Client-side Vercel Blob utilities + * + * This module handles uploading images DIRECTLY to Vercel Blob storage from the client. + * This bypasses the 4.5MB server request limit entirely! + */ + +import { upload } from '@vercel/blob/client'; + +export interface BlobUploadResult { + url: string; + pathname: string; + contentType?: string; + contentDisposition: string; + downloadUrl: string; +} + +export async function uploadFileToBlob(file: File): Promise { + try { + const blob = await upload(file.name, file, { + access: 'public', + handleUploadUrl: '/api/blob/upload-handler', + }); + + return blob; + } catch (error) { + console.error('Blob upload error:', error); + throw new Error( + `Failed to upload file to blob storage: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +export async function uploadFilesToBlob( + files: File[] +): Promise { + if (files.length === 0) { + return []; + } + + const results = await Promise.allSettled( + files.map(file => uploadFileToBlob(file)) + ); + + const successful: BlobUploadResult[] = []; + const failed: { file: File; error: Error }[] = []; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successful.push(result.value); + } else { + failed.push({ + file: files[index], + error: result.reason instanceof Error + ? result.reason + : new Error('Unknown upload error'), + }); + } + }); + + if (failed.length > 0) { + console.warn(`${failed.length} of ${files.length} uploads failed:`, failed); + + if (successful.length === 0) { + throw new Error( + `All ${files.length} file uploads failed. First error: ${failed[0].error.message}` + ); + } + } + + return successful; +} + +export async function blobUrlToFile( + blobUrl: string, + filename: string +): Promise { + const response = await fetch(blobUrl); + const blob = await response.blob(); + return new File([blob], filename, { type: blob.type }); +} + diff --git a/templates/next-image/src/lib/server-blob-utils.ts b/templates/next-image/src/lib/server-blob-utils.ts new file mode 100644 index 000000000..116b84a1d --- /dev/null +++ b/templates/next-image/src/lib/server-blob-utils.ts @@ -0,0 +1,69 @@ +/** + * Server-side Vercel Blob utilities + * + * This module handles downloading images from Vercel Blob storage on the server + * and converting them to base64 for AI model consumption. + */ + +export function isBlobUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.hostname.includes('blob.vercel-storage.com'); + } catch { + return false; + } +} + +export async function downloadBlobToDataUrl( + blobUrl: string +): Promise { + try { + const response = await fetch(blobUrl); + + if (!response.ok) { + throw new Error(`Failed to download blob: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const base64 = buffer.toString('base64'); + + // Get content type from response headers + const contentType = response.headers.get('content-type') || 'image/png'; + + return `data:${contentType};base64,${base64}`; + } catch (error) { + console.error('Error downloading blob:', error); + throw new Error( + `Failed to download image from blob storage: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +export async function downloadBlobsToDataUrls( + blobUrls: string[] +): Promise { + return Promise.all(blobUrls.map(url => downloadBlobToDataUrl(url))); +} + +export async function processImageUrls( + imageUrls: string[] +): Promise { + return Promise.all( + imageUrls.map(async url => { + // If it's already a data URL, return as-is + if (url.startsWith('data:')) { + return url; + } + + // If it's a blob URL, download and convert + if (isBlobUrl(url)) { + return downloadBlobToDataUrl(url); + } + + // For any other URL type, try to download it + return downloadBlobToDataUrl(url); + }) + ); +} + diff --git a/templates/next-image/src/lib/types.ts b/templates/next-image/src/lib/types.ts index aa6e13a39..a8b79761b 100644 --- a/templates/next-image/src/lib/types.ts +++ b/templates/next-image/src/lib/types.ts @@ -53,7 +53,7 @@ export interface GenerateImageRequest { */ export interface EditImageRequest { prompt: string; - imageUrls: string[]; // Array of data URLs or regular URLs + imageUrls: string[]; // Array of Vercel Blob URLs, data URLs, or regular URLs provider: ModelOption; }