const starterImages = [ { imageUrl: 'https://replicate.delivery/pbxt/N55l5TWGh8mSlNzW8usReoaNhGbFwvLeZR3TX1NL4pd2Wtfv/replicate-prediction-f2d25rg6gnrma0cq257vdw2n4c.png', suggestedPrompt: 'make it into a 90s cartoon', }, { imageUrl: 'https://replicate.delivery/pbxt/N5cepICxyaagdvULl0phi7ImdxuFz05TR2l623zqxhNR9q5Y/van-gogh.jpeg', suggestedPrompt: 'Using this style, a panda astronaut riding a unicorn', }, { imageUrl: 'https://replicate.delivery/xezq/OKWfR6jlQwzekkSsfQOppX55O3vaNv6xZ4qY6RfHjwQHOwDTB/tmp9p3v3brc.png', suggestedPrompt: 'remove the text from the sweatshirt', }, { imageUrl: 'https://replicate.delivery/pbxt/N5trWTJCJQbJVWz5nhLEscS1w16r1hGl5zuWceJhVSnWZfGu/mona-lisa-1024.jpg', suggestedPrompt: 'close her eyes', }, { imageUrl: 'https://replicate.delivery/mgxm/b033ff07-1d2e-4768-a137-6c16b5ed4bed/d_1.png', suggestedPrompt: 'Convert to a high-quality restoration, enhancing details and removing any damage or degradation', } ] function PoweredByBanner() { return (
); } function App() { // State for upload vs chat mode const [showUpload, setShowUpload] = React.useState(true); const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [loading, setLoading] = React.useState(false); // No need for separate image state - just use what's in the chat! const [predictionId, setPredictionId] = React.useState(null); const [abortController, setAbortController] = React.useState(null); // New: track if a starter image was used (for future logic if needed) const [starterUsed, setStarterUsed] = React.useState(false); // Replicate API token state const [replicateToken, setReplicateToken] = React.useState(() => { if (typeof window !== 'undefined') { return localStorage.getItem('replicateApiToken') || ''; } return ''; }); const [showTokenModal, setShowTokenModal] = React.useState(!replicateToken); const [tokenInput, setTokenInput] = React.useState(''); const [tokenError, setTokenError] = React.useState(''); // Simple desktop check const [isDesktop, setIsDesktop] = React.useState(false); React.useEffect(() => { const checkDesktop = () => setIsDesktop(window.innerWidth >= 768); checkDesktop(); window.addEventListener('resize', checkDesktop); return () => window.removeEventListener('resize', checkDesktop); }, []); // Ref for chat container and file input const chatContainerRef = React.useRef(null); const fileInputRef = React.useRef(null); const textareaRef = React.useRef(null); // Focus textarea when component mounts or when switching to chat mode React.useEffect(() => { if (!showUpload && textareaRef.current) { textareaRef.current.focus(); textareaRef.current.select(); } }, [showUpload]); // Scroll to bottom when messages change React.useEffect(() => { if (chatContainerRef.current) { setTimeout(() => { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; }, 50); } }, [messages]); // Lock body scrolling on mobile for a consistent experience across screens React.useEffect(() => { if (!isDesktop) { document.body.style.overflow = 'hidden'; document.body.style.position = 'fixed'; document.body.style.width = '100%'; } else { document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.width = ''; } return () => { document.body.style.overflow = ''; document.body.style.position = ''; document.body.style.width = ''; }; }, [isDesktop]); // Drag and drop state const [dragActive, setDragActive] = React.useState(false); // Helper to scroll to bottom (for image onLoad) function scrollToBottom() { if (chatContainerRef.current) { setTimeout(() => { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; }, 50); } } // Drag and drop handlers for upload area function handleDrag(e) { e.preventDefault(); e.stopPropagation(); if (e.type === 'dragenter' || e.type === 'dragover') { setDragActive(true); } else if (e.type === 'dragleave') { setDragActive(false); } } function handleDrop(e) { e.preventDefault(); e.stopPropagation(); setDragActive(false); const files = e.dataTransfer.files; if (files && files[0] && files[0].type.startsWith('image/')) { handleFile(files[0]); } } // Handle file selection function handleFileSelect(e) { const file = e.target.files[0]; if (file) { handleFile(file); } } // Process uploaded file async function handleFile(file) { if (!file.type.startsWith('image/')) { alert('Please select a valid image file.'); return; } // Validate file size (10MB limit) const maxSize = 10 * 1024 * 1024; if (file.size > maxSize) { alert('Image file is too large. Please select an image under 10MB.'); return; } try { // Scale down the image to 1 megapixel const scaledBlob = await scaleImageTo1Megapixel(file); const url = URL.createObjectURL(scaledBlob); // Add initial messages setMessages([ { type: 'image', image: url, imageBlob: scaledBlob, from: 'assistant', id: Date.now() }, { type: 'text', text: 'Image uploaded! How would you like to edit it?', from: 'system', id: Date.now() + 1 } ]); // Switch to chat mode setShowUpload(false); } catch (error) { alert('Failed to process image: ' + error.message); } } // Handle click on starter image async function handleStarterImageClick(starter) { // Fetch the image as a blob so it behaves like uploaded images try { setLoading(true); const res = await fetch(starter.imageUrl); const blob = await res.blob(); // Add image as first message setMessages([ { type: 'image', image: starter.imageUrl, imageBlob: blob, from: 'assistant', id: Date.now() }, { type: 'text', text: "Image loaded! Tell me how you'd like to edit it.", from: 'system', id: Date.now() + 1 } ]); setShowUpload(false); setInput(starter.suggestedPrompt || ''); setStarterUsed(true); } catch (err) { alert('Failed to load starter image.'); } finally { setLoading(false); } } // Helper function to convert blob to data URL function blobToDataUrl(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } // Get the most recent image from the chat function getLastImageBlob() { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].type === 'image' && messages[i].imageBlob) { return messages[i].imageBlob; } } return null; } // Scale image function async function scaleImageTo1Megapixel(file) { return new Promise((resolve, reject) => { const img = new Image(); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); img.onload = () => { const originalWidth = img.width; const originalHeight = img.height; const originalPixels = originalWidth * originalHeight; const targetPixels = 1000000; let newWidth, newHeight; if (originalPixels <= targetPixels) { newWidth = originalWidth; newHeight = originalHeight; } else { const scaleFactor = Math.sqrt(targetPixels / originalPixels); newWidth = Math.round(originalWidth * scaleFactor); newHeight = Math.round(originalHeight * scaleFactor); } canvas.width = newWidth; canvas.height = newHeight; ctx.drawImage(img, 0, 0, newWidth, newHeight); canvas.toBlob((blob) => { if (blob) { resolve(blob); } else { reject(new Error('Failed to create scaled image')); } }, 'image/jpeg', 0.9); }; img.onerror = () => { reject(new Error('Failed to load image')); }; img.src = URL.createObjectURL(file); }); } // Reset to upload mode function resetApp() { if (abortController) { abortController.abort(); } setShowUpload(true); setMessages([]); setInput(''); setPredictionId(null); setAbortController(null); setLoading(false); setStarterUsed(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } } // Handle sending a message const handleSend = async (e) => { e.preventDefault(); const lastImageBlob = getLastImageBlob(); if (!input.trim() || loading || !lastImageBlob || !replicateToken) return; const userMsg = { type: 'text', text: input, from: 'user', id: Date.now() }; setMessages(prev => [...prev, userMsg]); setInput(''); setLoading(true); // Add loading message const loadingMsg = { type: 'loading', from: 'assistant', id: Date.now() + 1 }; setMessages(prev => [...prev, loadingMsg]); try { // Create abort controller for this request const controller = new AbortController(); setAbortController(controller); // Convert blob to data URL for the API console.log('Converting blob to data URL...'); const imageDataUrl = await blobToDataUrl(lastImageBlob); console.log('Image data URL length:', imageDataUrl.length); console.log('Sending request to /generate-image...'); const requestBody = { prompt: input, input_image: imageDataUrl }; console.log('Request body size:', JSON.stringify(requestBody).length); const res = await fetch('/generate-image', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Replicate-Api-Token': replicateToken }, body: JSON.stringify(requestBody), signal: controller.signal }); console.log('Response status:', res.status); console.log('Response headers:', Object.fromEntries(res.headers.entries())); if (!res.ok) { const errorText = await res.text(); console.error('Error response body:', errorText); throw new Error(`HTTP ${res.status}: ${errorText}`); } // The response is now JSON with the Cloudflare Images URL const result = await res.json(); console.log('Response result:', result); if (result.error) { throw new Error(result.error); } const imageUrl = result.imageUrl; console.log('Cloudflare Images URL:', imageUrl); // Fetch the image to create a blob for local storage/UI purposes const imageResponse = await fetch(imageUrl); const imageBlob = await imageResponse.blob(); console.log('Image blob size:', imageBlob.size); // Replace loading with image (store blob in message) setMessages(prev => prev.map(msg => msg.type === 'loading' ? { type: 'image', image: imageUrl, imageBlob: imageBlob, from: 'assistant', id: msg.id } : msg )); // Add delete button to user message setMessages(prev => prev.map(msg => msg.id === userMsg.id ? { ...msg, showDelete: true } : msg )); } catch (err) { // Don't show error if request was aborted (cancelled) if (err.name !== 'AbortError') { setMessages(prev => prev.filter(msg => msg.type !== 'loading')); setMessages(prev => [...prev, { type: 'text', text: 'Sorry, there was an error generating the image: ' + err.message, from: 'assistant', id: Date.now() }]); } else { console.log('Request was cancelled'); setMessages(prev => prev.filter(msg => msg.type !== 'loading')); } } finally { setLoading(false); setPredictionId(null); setAbortController(null); } }; // Cancel generation function cancelGeneration() { console.log('Cancel generation called'); // Abort the ongoing request if (abortController) { console.log('Aborting request...'); abortController.abort(); setAbortController(null); } // Stop loading setLoading(false); setPredictionId(null); // Find the most recent user message to restore to input const currentMessages = [...messages]; const lastUserMessage = currentMessages.slice().reverse().find(msg => msg.from === 'user' && msg.type === 'text'); console.log('Last user message:', lastUserMessage); // Remove loading message and the most recent user message // This automatically makes the previous image the "last image" again setMessages(prev => { const filtered = prev.filter(msg => msg.type !== 'loading' && !(lastUserMessage && msg.from === 'user' && msg.type === 'text' && msg.id === lastUserMessage.id) ); console.log('Messages after cancel:', filtered.length); return filtered; }); // Restore the cancelled message to input if (lastUserMessage) { setTimeout(() => { console.log('Restoring text to input:', lastUserMessage.text); setInput(lastUserMessage.text); }, 50); } } // Delete message and all subsequent messages function deleteFromMessage(messageId) { const messageIndex = messages.findIndex(msg => msg.id === messageId); if (messageIndex === -1) return; // Keep messages up to the clicked message (excluding it) const remainingMessages = messages.slice(0, messageIndex); setMessages(remainingMessages); // No need to set current image - getLastImageBlob will find it automatically } // No longer needed - state is derived from chat messages // Handle image click for full screen function handleImageClick(imageUrl) { window.open(imageUrl, '_blank'); } // Attach drag events when in upload mode React.useEffect(() => { if (showUpload) { window.addEventListener('dragenter', handleDrag); window.addEventListener('dragover', handleDrag); window.addEventListener('dragleave', handleDrag); window.addEventListener('drop', handleDrop); return () => { window.removeEventListener('dragenter', handleDrag); window.removeEventListener('dragover', handleDrag); window.removeEventListener('dragleave', handleDrag); window.removeEventListener('drop', handleDrop); }; } }, [showUpload]); React.useEffect(() => { if (!replicateToken) { setShowTokenModal(true); } else { setShowTokenModal(false); } }, [replicateToken]); function handleTokenSubmit(e) { e.preventDefault(); if (!tokenInput.trim()) { setTokenError('Please enter your Replicate API token.'); return; } localStorage.setItem('replicateApiToken', tokenInput.trim()); setReplicateToken(tokenInput.trim()); setShowTokenModal(false); setTokenInput(''); setTokenError(''); } function handleTokenLogout() { localStorage.removeItem('replicateApiToken'); setReplicateToken(''); setShowTokenModal(true); } return (
To use Kontext Chat, you'll need a Replicate API token.
Create a token here and paste it below.
Chat with images to edit them.
Drag and drop an image here, or click to browse
Kontext Chat is powered by Flux Kontext Pro, a new image model from Black Forest Labs, running on Replicate. The app is built with Hono and React, running on Cloudflare Workers. Learn how to build your own app by taking a look at the source code on GitHub.
{/* Token logout button */}
{replicateToken && (
)}