Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion templates/next-image/.env.local
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
NEXT_PUBLIC_ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc"
ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc"
ECHO_APP_ID="74d9c979-e036-4e43-904f-32d214b361fc"
# Required for image hosting (prevents HTTP 413 errors with large base64 payloads)
# Get from: https://vercel.com/dashboard -> Storage -> Blob -> Create
BLOB_READ_WRITE_TOKEN=""
18 changes: 18 additions & 0 deletions templates/next-image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ npx echo-start@latest --template next-image

You'll be prompted for your Echo App ID. Don't have one? Get it at [echo.merit.systems/new](https://echo.merit.systems/new).

## Prerequisites

- Node.js 18+
- pnpm (`npm install -g pnpm`)
- A [Vercel Blob](https://vercel.com/docs/storage/vercel-blob) store for image hosting (required to avoid HTTP 413 payload errors)

## Environment Variables

Copy `.env.local` and fill in your values:

| Variable | Description |
|---|---|
| `ECHO_APP_ID` | Your Echo App ID from [echo.merit.systems/new](https://echo.merit.systems/new) |
| `NEXT_PUBLIC_ECHO_APP_ID` | Same value as `ECHO_APP_ID` |
| `BLOB_READ_WRITE_TOKEN` | Vercel Blob token from your Vercel dashboard → Storage → Blob |

> **Why Vercel Blob?** AI image models return large base64-encoded images. Passing these between client and server exceeds Vercel's function payload limit (HTTP 413). Images are now stored in Vercel Blob and URLs are returned instead.

## Getting Started

First, run the development server:
Expand Down
12 changes: 12 additions & 0 deletions templates/next-image/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
transpilePackages: ['@merit-systems/echo-next-sdk'],
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.blob.vercel-storage.com',
},
{
protocol: 'https',
hostname: 'blob.vercel-storage.com',
},
],
},
};

export default nextConfig;
1 change: 1 addition & 0 deletions templates/next-image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@vercel/blob": "^0.27.0",
"@merit-systems/echo-next-sdk": "latest",
"@merit-systems/echo-react-sdk": "latest",
"@radix-ui/react-avatar": "^1.1.10",
Expand Down
18 changes: 13 additions & 5 deletions templates/next-image/src/app/api/edit-image/google.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/**
* Google Gemini image editing handler
*
* Accepts hosted image URLs (stored in Vercel Blob via /api/upload-image),
* edits them, and stores results back in Vercel Blob to avoid HTTP 413 errors.
*/

import { google } from '@/echo';
import { generateText } from 'ai';
import { getMediaTypeFromDataUrl } from '@/lib/image-utils';
import { put } from '@vercel/blob';
import { ERROR_MESSAGES } from '@/lib/constants';

/**
* Handles Google Gemini image editing
* Gemini accepts regular URLs directly, so no conversion needed for input.
*/
export async function handleGoogleEdit(
prompt: string,
Expand All @@ -22,8 +26,7 @@ export async function handleGoogleEdit(
},
...imageUrls.map(imageUrl => ({
type: 'image' as const,
image: imageUrl, // Direct data URL - Gemini handles it
mediaType: getMediaTypeFromDataUrl(imageUrl),
image: imageUrl, // Hosted URL - Gemini handles it directly
})),
];

Expand All @@ -48,9 +51,14 @@ export async function handleGoogleEdit(
);
}

return Response.json({
imageUrl: `data:${imageFile.mediaType};base64,${imageFile.base64}`,
// Store result in Vercel Blob and return hosted URL
const buffer = Buffer.from(imageFile.base64, 'base64');
const blob = await put(`edited-${Date.now()}.png`, buffer, {
access: 'public',
contentType: imageFile.mediaType || 'image/png',
});

return Response.json({ imageUrl: blob.url });
} catch (error) {
console.error('Google image editing error:', error);
return Response.json(
Expand Down
31 changes: 29 additions & 2 deletions templates/next-image/src/app/api/edit-image/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,34 @@

import { getEchoToken } from '@/echo';
import OpenAI from 'openai';
import { dataUrlToFile } from '@/lib/image-utils';
import { ERROR_MESSAGES } from '@/lib/constants';

/**
* Fetches a hosted URL and returns a File object.
* Falls back to treating url as a data URL if it starts with "data:".
*/
async function urlToFile(url: string, filename: string): Promise<File> {
if (url.startsWith('data:')) {
// Data URL path
const [header, base64] = url.split(',');
const mime = header.match(/:(.*?);/)?.[1] || 'image/png';
const bytes = atob(base64);
const array = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
array[i] = bytes.charCodeAt(i);
}
return new File([array], filename, { type: mime });
} else {
// Hosted URL path – fetch the image
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image from ${url}: ${response.status}`);
}
const blob = await response.blob();
return new File([blob], filename, { type: blob.type || 'image/png' });
}
}

/**
* Handles OpenAI image editing
*/
Expand All @@ -32,7 +57,9 @@ export async function handleOpenAIEdit(
});

try {
const imageFiles = imageUrls.map(url => dataUrlToFile(url, 'image.png'));
const imageFiles = await Promise.all(
imageUrls.map((url, i) => urlToFile(url, `image_${i}.png`))
);

const result = await openaiClient.images.edit({
image: imageFiles,
Expand Down
13 changes: 4 additions & 9 deletions templates/next-image/src/app/api/edit-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
*
* 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
* - Accepts hosted image URLs (stored via /api/upload-image) instead of base64
* - Returns hosted image URLs instead of base64 to avoid HTTP 413 errors
* - Validates input images and prompts
* - Returns edited images in appropriate format
*/

import { EditImageRequest, validateEditImageRequest } from './validation';
Expand All @@ -17,13 +17,8 @@ const providers = {
gemini: handleGoogleEdit,
};

export const config = {
api: {
bodyParser: {
sizeLimit: '4mb',
},
},
};
// Allow up to 60 seconds for image editing on Vercel
export const maxDuration = 60;

export async function POST(req: Request) {
try {
Expand Down
13 changes: 11 additions & 2 deletions templates/next-image/src/app/api/generate-image/google.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/**
* Google Gemini image generation handler
*
* Generates images and stores them in Vercel Blob to avoid
* large base64 payloads in API responses (fixes HTTP 413).
*/

import { google } from '@/echo';
import { generateText } from 'ai';
import { put } from '@vercel/blob';
import { ERROR_MESSAGES } from '@/lib/constants';

/**
Expand All @@ -27,9 +31,14 @@ export async function handleGoogleGenerate(prompt: string): Promise<Response> {
);
}

return Response.json({
imageUrl: `data:${imageFile.mediaType};base64,${imageFile.base64}`,
// Convert base64 to buffer and store in Vercel Blob
const buffer = Buffer.from(imageFile.base64, 'base64');
const blob = await put(`generated-${Date.now()}.png`, buffer, {
access: 'public',
contentType: imageFile.mediaType || 'image/png',
});

return Response.json({ imageUrl: blob.url });
} catch (error) {
console.error('Google image generation error:', error);
return Response.json(
Expand Down
14 changes: 12 additions & 2 deletions templates/next-image/src/app/api/generate-image/openai.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/**
* OpenAI image generation handler
*
* Generates images and stores them in Vercel Blob to avoid
* large base64 payloads in API responses (fixes HTTP 413).
*/

import { openai } from '@/echo';
import { experimental_generateImage as generateImage } from 'ai';
import { put } from '@vercel/blob';
import { ERROR_MESSAGES } from '@/lib/constants';

/**
Expand All @@ -17,9 +21,15 @@ export async function handleOpenAIGenerate(prompt: string): Promise<Response> {
});

const imageData = result.image;
return Response.json({
imageUrl: `data:${imageData.mediaType};base64,${imageData.base64}`,

// Convert base64 to buffer and store in Vercel Blob
const buffer = Buffer.from(imageData.base64, 'base64');
const blob = await put(`generated-${Date.now()}.png`, buffer, {
access: 'public',
contentType: imageData.mediaType || 'image/png',
});

return Response.json({ imageUrl: blob.url });
} catch (error) {
console.error('OpenAI image generation error:', error);
return Response.json(
Expand Down
13 changes: 4 additions & 9 deletions templates/next-image/src/app/api/generate-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
*
* This route demonstrates Echo SDK integration with AI image generation:
* - Supports both OpenAI and Gemini models
* - Handles text-to-image generation
* - Returns base64 encoded images for consistent handling
* - Generates images and stores them in Vercel Blob to return hosted URLs
* - Avoids HTTP 413 errors caused by large base64 payloads
*/

import {
Expand All @@ -19,13 +19,8 @@ const providers = {
gemini: handleGoogleGenerate,
};

export const config = {
api: {
bodyParser: {
sizeLimit: '4mb',
},
},
};
// Allow up to 60 seconds for image generation on Vercel
export const maxDuration = 60;

export async function POST(req: Request) {
try {
Expand Down
63 changes: 63 additions & 0 deletions templates/next-image/src/app/api/upload-image/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* API Route: Upload Image
*
* Accepts multipart form data with image files and stores them in Vercel Blob.
* Returns hosted URLs that can be passed to edit-image without triggering 413 errors.
*
* Usage:
* const form = new FormData();
* form.append('file', imageFile);
* const { url } = await fetch('/api/upload-image', { method: 'POST', body: form }).then(r => r.json());
*/

import { put } from '@vercel/blob';

// Allow up to 30 seconds for uploads
export const maxDuration = 30;

export async function POST(req: Request) {
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;

if (!file) {
return Response.json({ error: 'No file provided' }, { status: 400 });
}

if (!file.type.startsWith('image/')) {
return Response.json(
{ error: 'Only image files are supported' },
{ status: 400 }
);
}

// 10MB size limit for uploads
const MAX_SIZE = 10 * 1024 * 1024;
if (file.size > MAX_SIZE) {
return Response.json(
{ error: 'Image must be smaller than 10MB' },
{ status: 400 }
);
}

const buffer = await file.arrayBuffer();
const ext = file.type.split('/')[1] || 'png';
const blob = await put(`upload-${Date.now()}.${ext}`, buffer, {
access: 'public',
contentType: file.type,
});

return Response.json({ url: blob.url });
} catch (error) {
console.error('Image upload error:', error);
return Response.json(
{
error:
error instanceof Error
? error.message
: 'Image upload failed. Please try again.',
},
{ status: 500 }
);
}
}
29 changes: 25 additions & 4 deletions templates/next-image/src/components/image-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ async function generateImage(
return response.json();
}

/**
* Uploads an image file to Vercel Blob via the upload endpoint.
* Returns a hosted URL that can be passed to the edit API without
* triggering HTTP 413 errors caused by large base64 payloads.
*/
async function uploadImageFile(file: File): Promise<string> {
const form = new FormData();
form.append('file', file);
const response = await fetch('/api/upload-image', {
method: 'POST',
body: form,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Upload failed ${response.status}: ${errorText}`);
}
const { url } = await response.json();
return url as string;
}

async function editImage(request: EditImageRequest): Promise<ImageResponse> {
const response = await fetch('/api/edit-image', {
method: 'POST',
Expand Down Expand Up @@ -217,14 +237,15 @@ export default function ImageGenerator() {
}

try {
// Upload images to Vercel Blob and get hosted URLs.
// This avoids sending large base64 payloads in the JSON body
// which would exceed Vercel's function payload limit (HTTP 413).
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 })
);
const file = new File([blob], 'image', { type: imageFile.mediaType });
return await uploadImageFile(file);
})
);

Expand Down
Loading