Hi and welcome back to part 8! Kudos for making it this far! π It’s time to put the finishing touches on our app and add an image of the character the user is chatting with, as promised in the last part.
The first thing we’ll have to do is update our ChatMessage
type to include an optional image URL property. This will allow us to display the image of the character the user is chatting with. So open up the chatMessage.ts
file in the types
folder:
π app π api π blockbuster π route.tsx π blockbuster_v2 π route.tsx π blockbuster_chat π page.tsx π blockbuster_chat_2 π page.tsx π counter π page.tsx π types π chatMessage.ts π οΈ We'll be working on this file π favicon.ico π globals.css π layout.tsx π navbar.tsx π page.tsx
We have our type interface defined as follows:
export interface ChatMessage { text: string; sender: string; isAnswer?: boolean; time_sent: Date; }
Now I’ve noticed one thing. This kind of stuff in JavaScript usually uses naming conventions like timeSent
instead of time_sent
. The reason I used time_sent
is because I’m very used to Python naming conventions but say we want to change this to timeSent
instead. If we just change the name in here we will break the code in other places where the time_sent
property is used.
Luckily for us, VSCode has a feature that will solve this problem for us. Simply click on the time_sent
name like so:
Now press F2
on your keyboard and you’ll be able to rename the property:
Change it to timeSent
and press Enter
. VSCode will now not just rename the property in the current file but also in all the other files where it’s used. Pretty neat, right?
Now let’s add the imageUrl
property to our ChatMessage
type:
export interface ChatMessage { text: string; sender: string; timeSent: Date; isAnswer?: boolean; imgUrl?: string; }
Make sure you use the ?
to make the property optional. This way we can have messages without an image URL, and messages before the image finishes generating and loading (that way we don’t have to keep the user waiting).
Adding the Image Generation API
The first thing we’ll need to do is add a backend API that can generate images. So go ahead and create a new folder named get_image
inside the API folder and create a new file inside it called route.tsx
:
π app π api π blockbuster π route.tsx π blockbuster_v2 π route.tsx π get_image π route.tsx β¨ New file π blockbuster_chat π page.tsx π blockbuster_chat_2 π page.tsx π counter π page.tsx π types π chatMessage.ts π favicon.ico π globals.css π layout.tsx π navbar.tsx π page.tsx
The API we’ll be using is DALLΒ·E 3 by OpenAI. It’s a powerful image generation model that can generate images based on text prompts. The nice thing is that we can use the same API key we used for ChatGPT so we have no extra setup work to be done! The pricing for the image size and quality we’ll be generating here will be $0.04 per image, see https://openai.com/api/pricing/ for more details on other sizes and pricing for the other models.
So open up get_image/route.tsx
and add the following code:
import { NextRequest, NextResponse } from "next/server"; import OpenAI from "openai"; const openAIConfig = { apiKey: process.env.OPENAI_API_KEY }; const openai = new OpenAI(openAIConfig);
This is just our usual setup, nothing new here. Notice how we can use the exact same OpenAI library and key, simple!
Now let’s create the POST
route/function that will generate an image and return the URL to the client. As this is fairly similar to the principles you’ve learned so far, I’ll just show the whole code in one go:
export async function POST(request: NextRequest) { const { movieName, movieCharacter }: { movieName: string; movieCharacter: string; } = await request.json(); console.log(`Generating image for ${movieCharacter} from ${movieName}`); if (!movieName || !movieCharacter ) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } const prompt = `A character from the movie ${movieName} named ${movieCharacter}`; const response = await openai.images.generate({ model: "dall-e-3", prompt: prompt, n: 1, size: "1024x1024", }); const image_url = response.data[0].url; console.log(image_url); return NextResponse.json({ image_url }); }
We take two variables from the request body, movieName
and movieCharacter
, and destructure them from the JSON body. Next is a print statement for our debugging purposes and a check like we have done before to make sure we have the required data to continue.
We then create the prompt string for the image generation model and call the openai.images.generate
function which is pretty similar to the chat functions we’ve been invoking so far. The model is dall-e-3
and the prompt is the string we created.
A quick tip from my future self: I have noticed that asking directly for A character from the movie ${movieName} named ${movieCharacter}
can sometimes lead to the model not generating an image due to a copyright filter being triggered. I advise trying with a slightly more indirect wording like A character in the style of ${movieCharacter} from the movie ${movieName}
or something similar if you run into this issue.
The n
or number parameter is set to 1 to generate only one image and the size
parameter is set to 1024x1024
to generate a 1024×1024 image. Images are generated at standard
quality by default, which is more than good enough, so we have omitted that parameter.
We extract the image URL from the response (response.data[0].url) and return it to the client as an object with the key
name image_url
. We also print it to the console for testing/debugging purposes.
Testing the Image Generation API
Now let’s test it using Postman by sending the following object to the route. Note that you can skip this step if you don’t have Postman or a similar tool installed and test it using the Blockbuster Chat V3 later on:
// Send this to Postman { "movieName": "The Matrix", "movieCharacter": "Neo" }
As you can see you will get an image URL as the response. Open it in your browser to see the generated image:
While it doesn’t look quite like Neo (as the API doesn’t want to break copyright), it is pretty cool and has exactly the right style and vibe to it! πΆοΈ This will work great for our chat purposes.
Updating the Navbar
Now that we have a working image generation endpoint we can move on to the frontend part and create Blockbuster Chat V3! π
First of all, let’s update our Navbar
component. Open up the navbar.tsx
file in the app
folder:
π app π api π blockbuster π route.tsx π blockbuster_v2 π route.tsx π get_image π route.tsx π blockbuster_chat π page.tsx π blockbuster_chat_2 π page.tsx π counter π page.tsx π types π chatMessage.ts π favicon.ico π globals.css π layout.tsx π navbar.tsx π οΈ We'll be working on this file π page.tsx
Just scroll down to the Navbar
component and edit the last link in there like this:
const Navbar = () => { return ( <nav className="bg-emerald-700 text-white p-4"> <ul className="flex justify-between items-center"> <NavItem href="/">Home</NavItem> <NavItem href="/blockbuster_chat">Blockbuster Chat V1</NavItem> <NavItem href="/blockbuster_chat_2">Blockbuster Chat V2</NavItem> <NavItem href="/blockbuster_chat_3">Blockbuster Chat V3</NavItem> </ul> </nav> ); }
We simply pointed the last NavItem
link to "/blockbuster_chat_3"
. Save and close this file and you should have a third link in your navbar now.
Getting started on Blockbuster Chat V3
Now we’ll have to create the Blockbuster Chat V3
page. Create a new folder named blockbuster_chat_3
inside the app
folder and create a new file named page.tsx
inside it:
π app π api π blockbuster π route.tsx π blockbuster_v2 π route.tsx π get_image π route.tsx π blockbuster_chat π page.tsx π blockbuster_chat_2 π page.tsx π blockbuster_chat_3 π page.tsx β¨ New file π counter π page.tsx π types π chatMessage.ts π favicon.ico π globals.css π layout.tsx π navbar.tsx π page.tsx
A lot of this will be repetition which is very similar to the V2 version so we’ll go over the repetitive parts pretty fast. In a real project you would never allow all this duplication and follow the DRY
(Don’t Repeat Yourself) principle, but for tutorial purposes, I think it’s nice to have several snapshots of the same code so you can see the progression.
Now open up blockbuster_chat_3/page.tsx
and let’s get started on our code. First we’ll import the necessary components and libraries:
"use client"; import React, { useState, ChangeEvent, FormEvent, useEffect, useRef, } from "react"; import { ChatMessage } from "../types/chatMessage";
No surprises here, this is all the same as last time. Next up are the InputFieldProps
interface and the InputField
component. Both of these are the same as the V2 version, so you can simply copy these:
interface InputFieldProps { label: string; value: string; onChange: (event: ChangeEvent<HTMLInputElement>) => void; disabled?: boolean; } const InputField: React.FC<InputFieldProps> = ({ label, value, onChange, disabled = false, }) => { return ( <div className="mb-4 w-full"> <label className="block mb-2 text-gray-700">{label}:</label> <input type="text" value={value} onChange={onChange} disabled={disabled} className="w-full p-2 border border-gray-300 rounded bg-white" /> </div> ); };
Again, outside of a tutorial environment you would never copy stuff like this but put it in a single shared place like we did with the ChatMessage
type. But we’re deliberately repeating ourselves here to show the progression and changes made to improve something over time.
Updating the ChatBubble Component
Now we get to the ChatBubble
component. This one is going to have several changes:
const ChatBubble: React.FC<ChatMessage> = ({ text, sender, isAnswer = false, timeSent, imgUrl, }) => { const chatLocation = isAnswer ? "chat-end" : "chat-start"; return ( <div className={`chat ${chatLocation}`}> {/* Conditionally render image */} {isAnswer && imgUrl && ( <div className="chat-image avatar"> <div className="rounded-2xl w-32"> <img src={imgUrl} alt="Avatar image" /> </div> </div> )} {/* Rest of the chat message */} <div className="chat-header"> {sender === "You" ? ( <span className="font-bold mr-1">You</span> ) : ( <span className="font-bold text-custom-grey mr-1">{sender}</span> )} <time className="text-xs opacity-50"> {timeSent.toLocaleTimeString()} </time> </div> <div className="chat-bubble">{text}</div> </div> ); };
We can see in the input destructuring we now have timeSent
under the new name and imgUrl
as a new property. We also have a new conditional rendering block in between the two comment lines. If this message isAnswer
and there is an imgUrl
then it will render this image. The specific structure and classNames are simply copied from the daisyUI chat bubble documentation like before, except we insert our own image.
Calling the Image Generation API
Now that we have our updated ChatBubble
component we need another function that will handle the call to our backend image API for us:
const getImageForMovieChar = async ( movieName: string, movieCharacter: string, setIsFirstRequest: React.Dispatch<React.SetStateAction<boolean>>, setImgUrl: React.Dispatch<React.SetStateAction<string>> ) => { setIsFirstRequest(false); try { const response = await fetch("/api/get_image", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ movieName, movieCharacter, }), }); if (!response.ok) { throw new Error("Failed to fetch image"); } const data = await response.json(); setImgUrl(data.image_url); } catch (error) { console.error("Error fetching image:", error); } };
We have a function that takes 4 input arguments. The movieName
and movieCharacter
are the two strings we need to generate the image. The setIsFirstRequest
and setImgUrl
are two state setters that we need to update the state of the component. So these will be the functions that update the state of the main Blockbuster Chat V3 component to let it know what the image URL is and whether we have already made a request.
The React.Dispatch<React.SetStateAction<boolean>>
TypeScript type just means that this setter function deals with React.Dispatch
which deals with a SetStateAction
which deals with a boolean
type. Don’t worry too much about these type-names if you find them confusing.
The first thing we do in the body of the function is set isFirstRequest
to false
as we never want to generate more than one image per chat. Now we simply make a request to our own backend API, like we have done with our other APIs many times before, passing in the required data inside the JSON.stringify
stringified body.
If the response is not ok, we throw an error (which we catch again in the catch
clause below) and if it is ok we extract the image URL from the response by parsing the .json()
. Finally, we update the imgUrl
state using the setImgUrl
function which will be passed in from the main Blockbuster Chat V3 component so that the response is communicated back.
The main BlockbusterChat
Component
Now it’s time to start on our BlockbusterChat
component. As is customary I’ll first show it in parts as it is so big, and then I’ll put the entire thing in there once more at the end for clarity.
const BlockbusterChat: React.FC = () => { const [movieName, setMovieName] = useState<string>(""); const [movieCharacter, setMovieCharacter] = useState<string>(""); const [question, setQuestion] = useState<string>(""); const [isSubmitted, setIsSubmitted] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false); const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]); const [isFirstRequest, setIsFirstRequest] = useState<boolean>(true); const [imgUrl, setImgUrl] = useState<string>("");
This is largely the same except for the last two states. We have seen why we need both of these above, the isFirstRequest
state is used to make sure we only generate one image per chat, and the imgUrl
state is used to store the image URL we get back from the API.
Next is the chatHistoryRef
, which stays the same as in the V2 version:
const chatHistoryRef = useRef<HTMLDivElement>(null); useEffect(() => { if (chatHistoryRef.current) { chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight; } }, [chatHistory]);
It is the same as before, we scroll to the bottom of the chat history whenever the chat history changes.
Now the handleSubmit
function, which is mostly the same except for the part between the comments:
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setIsSubmitted(true); setIsLoading(true); // This part is new if (isFirstRequest) { getImageForMovieChar( movieName, movieCharacter, setIsFirstRequest, setImgUrl ); } // End of new part - the rest is the same const questionChatMessage: ChatMessage = { text: question, sender: "You", timeSent: new Date(), }; setChatHistory((prevChatHistory) => [ ...prevChatHistory, questionChatMessage, ]); const response = await fetch("/api/blockbuster_v2", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ movieName, movieCharacter, question, chatHistory, }), }); const data: { answer: string } = await response.json(); const answerChatMessage: ChatMessage = { text: data.answer, sender: movieCharacter, isAnswer: true, timeSent: new Date(), }; setChatHistory((prevChatHistory) => [ ...prevChatHistory, answerChatMessage, ]); setQuestion(""); setIsLoading(false); };
So let’s have a look at the new part between the comments. If the state of isFirstRequest
is true
, which is its default value, we call the getImageForMovieChar
function we defined earlier. We pass in the movieName
, movieCharacter
, setIsFirstRequest
, and setImgUrl
functions as arguments.
Notice that we never use the await
keyword to wait for the result (our function doesn’t return anything, so we couldn’t even await it if we wanted!), even though the getImageForMovieChar
function we wrote is clearly an async
function. This is because we don’t want to wait for the image to be generated. We want to keep the chat flowing as this image-generation process can take quite a while. So the API call will simply be triggered just like we coded it, and then the code will move on to running our other functions.
It will come back to finish the getImageForMovieChar
function when a response is received later on. Our function will then update the imgUrl
state which will let React know that it needs to re-render the component with the new image as imgUrl
now has a value that we set to conditionally render inside our ChatBubble
component if there is a value, and our page will update automatically!
The handleInputChange
method once again requires no changes:
const handleInputChange = (setter: React.Dispatch<React.SetStateAction<string>>) => (event: ChangeEvent<HTMLInputElement>) => setter(event.target.value);
The return
statement is almost the same, so I will just paste it here with comments whenever something has changed compared to the previous version:
return ( <div className="max-w-4xl mx-auto p-4"> {/* The above was 3-xl but now 4-xl in V3 */} <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1> {/* Initial input form for character and first question */} {!isSubmitted && ( <form onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Movie Character" value={movieCharacter} onChange={handleInputChange(setMovieCharacter)} disabled={isSubmitted} /> <InputField label="Movie Name" value={movieName} onChange={handleInputChange(setMovieName)} disabled={isSubmitted} /> </div> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </form> )} {/* Display chat history */} {chatHistory && ( <div ref={chatHistoryRef} className="mt-6 max-h-[70vh] overflow-y-scroll"> {chatHistory.map((chatMessage: ChatMessage, index) => ( <ChatBubble key={index} text={chatMessage.text} isAnswer={chatMessage.isAnswer} sender={chatMessage.sender} timeSent={chatMessage.timeSent} imgUrl={imgUrl} /> ))} {/* We added the imgUrl input argument */} </div> )} {/* Loading message */} {isLoading && ( <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div> )} {/* Display form for follow up questions */} {isSubmitted && !isLoading && ( <form className="mt-3" onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 h-10 mt-8 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </div> </form> )} </div> ); }; export default BlockbusterChat;
As you can see, not much has changed here. Just make sure you change the max-w-3xl
to max-w-4xl
in the div
element at the top of the return statement and add the imgUrl
input argument to the ChatBubble
component. Here is the whole component with the changes in case you need it:
const BlockbusterChat: React.FC = () => { const [movieName, setMovieName] = useState<string>(""); const [movieCharacter, setMovieCharacter] = useState<string>(""); const [question, setQuestion] = useState<string>(""); const [isSubmitted, setIsSubmitted] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false); const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]); const [isFirstRequest, setIsFirstRequest] = useState<boolean>(true); const [imgUrl, setImgUrl] = useState<string>(""); const chatHistoryRef = useRef<HTMLDivElement>(null); useEffect(() => { if (chatHistoryRef.current) { chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight; } }, [chatHistory]); const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setIsSubmitted(true); setIsLoading(true); if (isFirstRequest) { getImageForMovieChar( movieName, movieCharacter, setIsFirstRequest, setImgUrl ); } const questionChatMessage: ChatMessage = { text: question, sender: "You", timeSent: new Date(), }; setChatHistory((prevChatHistory) => [ ...prevChatHistory, questionChatMessage, ]); const response = await fetch("/api/blockbuster_v2", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ movieName, movieCharacter, question, chatHistory, }), }); const data: { answer: string } = await response.json(); const answerChatMessage: ChatMessage = { text: data.answer, sender: movieCharacter, isAnswer: true, timeSent: new Date(), }; setChatHistory((prevChatHistory) => [ ...prevChatHistory, answerChatMessage, ]); setQuestion(""); setIsLoading(false); }; const handleInputChange = (setter: React.Dispatch<React.SetStateAction<string>>) => (event: ChangeEvent<HTMLInputElement>) => setter(event.target.value); return ( <div className="max-w-4xl mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1> {/* Initial input form for character and first question */} {!isSubmitted && ( <form onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Movie Character" value={movieCharacter} onChange={handleInputChange(setMovieCharacter)} disabled={isSubmitted} /> <InputField label="Movie Name" value={movieName} onChange={handleInputChange(setMovieName)} disabled={isSubmitted} /> </div> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </form> )} {/* Display chat history */} {chatHistory && ( <div ref={chatHistoryRef} className="mt-6 max-h-[70vh] overflow-y-scroll"> {chatHistory.map((chatMessage: ChatMessage, index) => ( <ChatBubble key={index} text={chatMessage.text} isAnswer={chatMessage.isAnswer} sender={chatMessage.sender} timeSent={chatMessage.timeSent} imgUrl={imgUrl} /> ))} </div> )} {/* Loading message */} {isLoading && ( <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div> )} {/* Display form for follow up questions */} {isSubmitted && !isLoading && ( <form className="mt-3" onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 h-10 mt-8 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </div> </form> )} </div> ); }; export default BlockbusterChat;
Apologies for the repetition but this is the easiest way to point out exactly where to make the changes to an already existing and rather large code block, instead of trying to describe exactly where to make these small changes without showing you.
Putting it to the test
Awesome, so go ahead and save that. Make sure your dev server is running and open your website in the browser. You should now see a third link in the navbar called Blockbuster Chat V3
, and the input boxes will be a bit wider to give us extra space for the image:
So let’s try it out, pick a character and a movie and ask a question. It will take a while to generate the image so your chat will start and the image will appear later:
This really adds a new dimension to our chat as I feel I am really talking to Godzilla now! π¦
As expected, Sherlock Holmes is a bit more mysterious and uses overly fancy English. π΅οΈββοΈ
Of course, it will not look 100% like the movie character for copyright reasons, but that is still pretty cool! You can now ask your favorite movie characters all those weird questions you’ve always been curious about! π₯
That’s our app with 3 different versions finished! Feel free to play with the styling, add more features, or even create your own version of the chat app! Maybe you want the images to be bigger, maybe you want to have a spinner there to indicate they are loading, or maybe you want to build a completely different app.
Before we go though, I’d like to cover one more very important topic. It’s nice that we have built a website and it has a working front end and back end with APIs and all, but how do we actually deploy this website so that other people can see it? π€
A website is not a real website if it only runs on our local computer! If we want to develop a real AI application then we’ll need to learn about deployment. So head over to the last and final bonus tutorial part to learn how to deploy your website to the internet! π