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
3 changes: 3 additions & 0 deletions templates/next-image/.env.example
Original file line number Diff line number Diff line change
@@ -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=
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"

# get your blob read write token from your vercel storage dashboard
BLOB_READ_WRITE_TOKEN="your-blob-read-write-token-here"
3 changes: 3 additions & 0 deletions templates/next-image/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ yarn-error.log*
# vercel
.vercel

# env
.env

# typescript
*.tsbuildinfo
next-env.d.ts
1 change: 1 addition & 0 deletions templates/next-image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions templates/next-image/src/app/api/blob/upload-handler/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 }
);
}
}

10 changes: 7 additions & 3 deletions templates/next-image/src/app/api/edit-image/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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),
})),
];

Expand Down
6 changes: 5 additions & 1 deletion templates/next-image/src/app/api/edit-image/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
9 changes: 0 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,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
*/
Expand All @@ -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();
Expand Down
9 changes: 0 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 @@ -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();
Expand Down
5 changes: 2 additions & 3 deletions templates/next-image/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -40,9 +39,9 @@ export default async function Home() {
return (
<div className="flex flex-col h-screen p-2 sm:p-4 max-w-6xl mx-auto">
{/* Header with title and token display */}
<header className="flex flex-col sm:flex-row sm:justify-between sm:items-center w-full mb-4 sm:mb-8 p-4 sm:p-6 bg-gradient-to-r from-slate-50 to-gray-100 rounded-xl border border-gray-200 shadow-sm gap-3 sm:gap-0">
<header className="flex flex-col sm:flex-row sm:justify-between sm:items-center w-full mb-4 sm:mb-8 p-4 sm:p-6 bg-linear-to-r from-slate-50 to-gray-100 rounded-xl border border-gray-200 shadow-sm gap-3 sm:gap-0">
<div className="flex items-center space-x-3">
<h1 className="text-2xl sm:text-3xl font-mono bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
<h1 className="text-2xl sm:text-3xl font-mono bg-linear-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
Echo Image Gen
</h1>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ export const PromptInputModelSelectTrigger = ({
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
'hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground',
className
)}
{...props}
Expand Down
55 changes: 24 additions & 31 deletions templates/next-image/src/components/image-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';

import { fileToDataUrl } from '@/lib/image-utils';
import { uploadFilesToBlob } from '@/lib/blob-utils';
import type {
EditImageRequest,
GeneratedImage,
Expand Down Expand Up @@ -144,7 +145,7 @@ export default function ImageGenerator() {
/**
* Handles form submission for both image generation and editing
* - Text-only: generates new image using selected model
* - Text + attachments: edits uploaded images using Gemini
* - Text + attachments: uploads to blob storage, then edits images
*/
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
Expand All @@ -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
Expand All @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions templates/next-image/src/lib/blob-utils.ts
Original file line number Diff line number Diff line change
@@ -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<BlobUploadResult> {
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<BlobUploadResult[]> {
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<File> {
const response = await fetch(blobUrl);
const blob = await response.blob();
return new File([blob], filename, { type: blob.type });
}

Loading