In this chapter, we're going to build the conversation system. We're going to learn how to store conversations and messages, build a chat sidebar with user and assistant messages, create past conversations history dialogue, handle message sending and cancellation functionality, display the thinking indicator while processing, and many other things. Let's take a look. So this is the final product and take a look that when I send a message for example, hey, how are you? The chat turns into processing state.
So that's what we're going to implement. You can see it's now thinking and I have an option to cancel this. And then I get back an answer. We're not exactly going to be focusing on how the AI model works, nor how it responds. We're more going to focus on the system architecture behind storing the conversation and the messages itself.
So we will also have this history dialog where we will be able to go in the past. We will be able to of course cancel a message like this. You can see I have canceled my request. So it's a spare tokens and we will be able to create a new conversations. So that's the goal.
We will see if the actual AI responses is something we can build in this chapter or if it's logical to move that into its own chapter. So for now we're just going to focus on making the UI of the chat sidebar as well as the actual back end of maintaining storing and displaying those messages. So let's go ahead and do npm run dev in our project here and let's refresh localhost 3000. So Right now our conversation sidebar is completely empty and that's perfectly fine because I'm not going to build a UI yet. I want to start with the backend.
I feel like it's always easier that way. Let's go inside of convex schema and let's build the conversations table. So make sure you also have NPX convex dev running as we will need to synchronize our new schema here. So I'm going to go ahead and define the table for conversations. Each conversation will have its project stored in project ID, and then it's going to have a title, which is a string, createdAt, which is not needed, creation time exists by itself, but it will have updated at.
And we're only going to have one index, which is going to be by project. Now, besides conversations, we're also going to need messages. So Let's go ahead and define the messages table. Each message will have to belong to a certain conversation, meaning it's gonna need to have a conversation ID. And for easier traversing through messages, and right now, if I wanna find out if this message belongs to a project ID, I will first have to fetch the conversation and then fetch the project.
Similarly to what we had to do with our files a lot of times, specifically for the owner ID, right? So to avoid that, let's add the project ID directly to messages because it's going to be very useful later. So for the role, the message can either belong to a user or an assistant. So we are creating a union of those two literals. Then for the content, it's just going to be a string.
And then for the status, it will be, again, an optional union of three states processing, completed or cancelled. And let's go ahead and keep it at that. That is good. Now we're going to have to add two indexes. So right here I'm going to chain by conversation which is basically using the conversation ID and by project and status which will use project ID and the status.
This will help us query the messages faster. Great. So we now have messages and conversations. Let's go here and confirm all functions are ready. Table indexes have been added and no errors have been found.
Perfect. Now that we have this, let's go ahead and new file here. Conversations.ts. And inside of here, we're going to go ahead and import v from convex values, mutation and query from generated server and verify out from .slash out. We're going to start with a very simple create mutation.
So let's go ahead and give it an arguments of project ID and title. And let's go ahead and give it an arguments of project ID and title. And let's go ahead and give it a handler. I'm just trying to close this function so we get rid of the syntax error. There we go.
So the handler needs access to context and arguments so that we can properly get our identity using verify out. We are then going to get the project using arguments project ID. And as always, let's do a quick check. If there is no project, project is not found. If The project's owner ID doesn't match the identity subject.
This is unauthorized and we shouldn't allow them to do that. And now let's go ahead and build this by using await context database insert into conversations project ID title. And the only thing we're going to need is updated at which can be date.now. That's it. And the conversation ID here is what context.database.insert will return.
This is actually quite important so make sure that you return the conversation ID because we are going to be using it later on the front end. When we invoke this mutation we are going to be using it to immediately select that as the active conversation. So that's why it's important to return the newly created conversation ID because usually in mutations we just do this, right? So just make sure you return it this time. And then besides creation we're going to have some quite normal functions such as get by ID.
So get by ID will have the arguments ID which is belonging to a conversations ID. And then as usual we're going to go ahead and get the identity. We're going to get the conversation itself from context database, get conversations arguments ID. If there is no conversation, we're going to throw an error. Otherwise we're going to check.
We're going to try to fetch the project using the conversation project ID. If the project doesn't exist, we throw an error. Otherwise, we throw unauthorized to access this project if the owner ID is different than the currently logged in identity subject. And last thing, return the conversation. So if it passes all of these checks, it means, okay, we can definitely access this conversation.
Perfect. So nothing we haven't built yet, right? So now let's go ahead and prepare this again. So I keep copying this because it's always the same code, right? For the GetBy project, obviously we are accepting project ID and in here we are going to fetch all conversations that belong to that project.
So we start with our identity and we grab the project using arguments project ID. We do our usual checks. If there is no project, we throw an error. If projects owner ID is different, we throw an error as well. And then we're going to do a simple query using our by project index, right?
So we have defined by project index here. So now we can do return await context database query conversations with index by project query equals project ID arguments project ID order by descending so the newest one are on top and collect all the data. Excellent. So I'm going to start copying entire functions at this point, you can do that too. The next one will be getMessages and we're going to get all messages in a conversation, so change the argument to be byConversationId.
So instead of fetching the project, we can only fetch the conversation right now. So const conversation context database getConversations using arguments.ConversationID. Then let's do a usual check. If there is no conversation let's throw an error conversation not found and instead of using arguments.projectid we're going to do conversation.projectid and the rest of the checks are exactly the same. And now we have to modify the query right.
But again the query is quite easy because we have an index already. So inside of our schema you can see we have by conversation. So we are simply going to query the messages and use the by conversation index. Let's go ahead and do that here. Very simple.
Query messages with index by conversation using the conversation ID, arguments conversation ID, order by ascending simply because the messages are loaded in reverse and collect all the information. There we go. We just built the entire backend for conversation and messages. So now let's go ahead and immediately create our features for conversations. So features, conversations, let me fix the typo here, conversations and let's go ahead and create hooks.
And inside of here, use-conversations.ts. So I'm going to start by importing use mutation and use query. I'm going to import API from convex generated API and ID from data model. Let's start with a very simple use conversation which accepts the ID of conversations or null and it returns use query and calls our newly created API conversations getById. In case you're getting an error here, it means the conversations functions have not properly registered.
Make sure you have created them within the convex folder and make sure you have no errors inside of this. If you do, let's go ahead and do npx convex dev again and you will see the most latest error message appear here. For example, you can see I have none, meaning the convex functions are ready and synchronized. Your error will be written here in case you have any and then you can debug. So just make sure you have conversations inside of the convex folder and then you should be able to query api.conversations.getById.
It's important to allow ID to be null so we can skip this query if we want to do so. Then let's go ahead and do the very same to use messages. So use messages hook again accepts conversation ID which can be ID conversations or null and it returns a use query API conversations get messages and again checks for the conversation ID. Then let's go ahead and create use conversations hook which will simply load all conversations for a project. So get by project.
We are calling it use conversations because it makes more semantic sense to be used that way when it comes to our UI blocks. So use query is using API conversations get by project. That's important. Project. That's important.
And then let's go ahead and create another one. Use create conversation and let me just go ahead and close this. There we go. Use create conversation will not accept any props actually. And we're just going to go ahead and return useMutation API conversations create and I will add to do add optimistic mutation here.
Simply because it's not required but it makes a much nicer user experience when it's instantly created, right? We actually have a lot of mutations where we have to add optimistic mutation, but once you learn how to add them in one place, it's almost identical in every other place. Perfect, so those are all the hooks we need to have. And now we are ready to revisit our features projects components and I think it is project ID view in here. Let's see.
I don't actually think it's ID view. I think it's ID layout right here. You can see we have a div conversation sidebar. So let's go ahead and this time render an actual component conversations sidebar conversation sidebar like this. Passing the project ID And let's go ahead and create it.
So I'm going to create the Conversations sidebar inside of Conversations here. So I'm going to create components and then I will create conversation-sidebar.tsx let's go ahead and quickly create our props so conversation sidebar props accepts project ID and let's go ahead and return I mean export conversation sidebar function. In here I'm just going to return a div conversation sidebar. There we go. Now let's go back to project ID layout and let's import conversation sidebar from features.
Let me just move it here. And let's also do one more thing. We can move this allotment this style CSS away from here and we can add it to our root layout. So this way wherever we decide to use it, it's added. Maybe it should be before global, maybe after, I'm not sure.
Just make sure it works. Right. So nothing should really be changed here. You can see that the only thing has changed is the text here. I think I added an exclamation point now.
Great. So how do we develop the conversation sidebar? There's so many elements we have to add. Well, we are in luck because AISDK, yes, the toolkit we actually used to add providers and various things, has a thing called AI elements. Using the link on the screen, you can visit that, or you can just see the command you have to run.
So they actually offer you two ways of adding this to your project. You can use npx-ai-elements or shat-cn-cli. Since we're using shat-cn I'm just going to use shat-cn. So what that's going to do is going to add a bunch of elements to our project inside of components here. So let's go ahead and run that in our project.
ShatCN, well, okay, I'm going to use the latest for now. If you want to, you can use the exact same version you did at the beginning of the tutorial. And it's just going to add all AI elements. So this is using Shazzy and Registry. So they are kind of verified components.
They're not just the random components. So you have to be scared of what's being added to your project. So there's quite a lot of them so maybe this will take a moment so let's just go ahead and wait for it to install. If you're being asked if you want to overwrite the button, select no, simply because we added some custom changes to that button. So it's detecting that it already exists, but we don't have to overwrite it.
So select no. If you select yes, it's not a big problem. You can almost just revert that using git or you can just copy the button, like command Z to what it was before. Basically, we added icon extra small variant and the highlight variant to our button. But you can see besides that everything else is fine.
Let's take a look at all the components that were added. So you can find all of them in one place. AI elements. Keep in mind that some of them might be a little bit buggy when it comes to type errors. I've noticed that myself, but most of them should be completely fine.
The reason I'm telling you that they might be buggy is because this will maybe cause build errors when you try to deploy. So just be mindful of that. But we're not going to concern ourselves with that right now. We're going to go ahead and build. So you can see I have 40 unsaved changes here.
And you can see besides layout, schema, conversations and some generated files here, all of other ones are source components AI elements, right? And use conversation, conversation cyber, okay. So you should have, well you should have the exact amount of changes but you know, maybe you did something that I didn't. But yeah, that's how many files have been added. Also, maybe in the future more files or less files will be added.
So yes, don't take this number too seriously. Let's go ahead now and go back inside of the conversation dash sidebar. So that's inside of features conversations components conversation sidebar. And in here I want to start importing some things. So we have ID imported but now I'm going to add from components AI elements conversation, conversation content and conversation scroll button.
Then I'm going to go ahead and add message, message content, response actions and actions and action from AI elements message. After that, I'm going to import prompt input, body, footer, submit, text area, tools, and type prompt input message from AI elements prompt input. So I'm going to remove all the gaps between the imports now because we know what they are and since I'm wrapping up the imports I'm just going to add all of them. So besides this I will also have KY package, toast from Sonar, use state from React and from Lucid Icon I'm going to have Copy Icon, History Icon, Loader Icon and Plus Icon. Alright.
And let's see, I don't think we need to use Client here because it already is within a client component. Let's go ahead and add button from components UI button. And I think that's it for components. Now let's go ahead and import all hooks from use conversations, use conversation, use conversations, use create conversation and use messages. So this is a hook we've developed before we started building the conversation sidebar.
So you should have all of these here. Great. Let's see. What should I do next? Instead of convex, I'm going to create a constants.ts and I'm going to prepare the following.
Default conversation title, I'm going to make it new conversation. That's what I'm going to do. So just save that and nothing more. All right. And now I'm going to go ahead and build the UI.
So conversations dash sidebar conversation that sidebar. And we're going to start by giving this div a class name flex flex column height full. And background sidebar going to open a new div here with a class name, height of 35 pixels and we've already learned we can write that as 8.75. Maybe I will learn at the end of the tutorial. Flex items center, justify between, border bottom and that's it.
And another div inside in which we are going to simply render the default conversation title which we can import from convex constants. So default conversation title. And this will have a class name text small truncate and PL of three. Let's go ahead and start actually taking a look here. This is the final product, not that.
So now You can see I have a text which says new conversation at the top. I'm going to snap this and I'm going to expand this so I can focus on this as much as possible. So there we go. New conversation is now written at the top because that is right here the constant we are exporting. Great.
And below this let's open a new div with a class name flex items center px1 and gap1. And in here we're going to render a button. This button will be a history icon renderer, like so. Again, I'm just going to snap this, expand this so I can focus on that. And let's start giving this button some props.
So it will have size icon extra small and the variant of highlight and the history icon will have a class name size 3.5. So That's what it should look like. Then I'm going to duplicate that and the bottom one is going to have a plus icon from Lucid React and then after that we are, let's see, Let's keep it like that. Okay. So yes, we're going to have two of them.
This is kind of the history and this is the plus button. This this creates the new one. So now outside of this div right here in the place of the text let's add the conversation component. Let's give it a class name flex one and inside let's add conversation content And then inside of here we have to basically render the messages. So how do we render the messages?
Well before we can do that we have to display the prompt input that will allow us to create new messages, right? So the only thing we also have to add within the conversation composition is conversation scroll button and it's a self-closing tag like this and then outside of conversation create a div with a class name padding 3 and in here we're going to add prompt input like so the prompt input will have on submit and it's just going to be an empty arrow function. And it will also have a class name. MarginTopTwoRoundedFool. Let's go ahead and add this.
Actually this rounded full doesn't do anything I think. When I say I think it's exactly the same. Besides that, let's also prepare a value of empty. Can I do that? Okay.
That's not where I do that. Instead inside of prompt input we render prompt input body. And then I do prompt input text area and this is where I can add a placeholder such as ask Polaris anything. Let me go ahead and expand this a bit. Let me see if I did the composition correctly.
We have prompt input prompt input body and then prompt input text area. OK. We are here also going to have on change which for now is just going to be an empty arrow function value which is going to be an empty string and disabled which will be explicitly false and this is actually a self-closing tab. There we go. Closing tab.
There we go. Then within outside of prompt input body we will add prompt input footer and prompt input tools which is a self-closing tag simply because usually you can open it and put things inside, but in this case I'm just using it to fill the area, the empty area. And prompt input submit is also a self-closing one. And you can leave it as the default, but for now let's just go ahead and make sure disable this false and status is ready. Simply so we are aware that it can have different props here.
Okay, So we just developed this. Now let's go ahead and let's create a function that will help us create new conversations. So I'm going to go ahead and add our use create conversation. So const create conversation, conversation use create conversation hook. And then I will also prepare use state here, something called selected conversation ID and set selected conversation ID.
And by default, I'm going to make it null. So since these are long words I'm just going to collapse the state like this. And the type will be basically an ID of conversations or null. Let's go ahead and keep that. Okay.
And now let's go ahead and implement constant handle create conversation. It's going to be an asynchronous method. We're going to open a try and catch. Let's go ahead and grab the conversation ID from await create conversation. Inside of create conversation, let me go ahead and expand this a little bit.
We're going to pass in the project ID and the title, which will be default conversation title. There we go. And then immediately after it has been created, set selected conversation ID to be the new conversation ID. Let's call this new conversation ID and then we can pass that here. There we go.
And return new conversation ID from this method as well. In the catch we can just do toast error unable to create new conversation and return null. All right, so that's a method handle create conversation and that's why I was telling you that it is important that in API conversations create we return the conversation ID because this is where we are using it right. We need it here so we can set the selected conversation ID to the newest one. All right.
So let's go ahead and now add this to our button which has the plus icon. So in here I'm just going to do on click handle create conversation. So the UI actually won't be too noticeable right now. So I would rather we go to dashboard convex.dev and head into our project database. In here you can see I have conversations and the table is completely empty.
So if I go ahead and click on the plus button right here, as I said, nothing much changes here, but you can see something obviously changes here. And to prove that in the UI too, let's go ahead and go up here where we render the default conversation title and do the following. Try and load active conversation. Actually let's see we don't have. OK.
One thing I forgot I thought I could do it from this but I can't. So now that we have selected conversation ID, what we can do is compute the active conversation ID and we can make that either selected conversation ID or we can get all conversations using use conversations for this project ID. So we already have that hook and then grab the first conversation. So question mark first in the array and then grab its id or fall back to null as the last resort. So why what is this?
If the user manually selects a conversation, we're going to compute that. If the user just refreshed the page, we're going to fall back to picking this project's latest conversation. And if that doesn't exist, we're going to fall back to null. So make sure you've added useConversations for this project ID hook. UseConversation which accepts project ID and let's go ahead and see.
It calls a function getByProject. So that's the one we are calling here so we get an array of conversations. Now that we have active conversation ID we can get the actual active conversation using use conversation and passing the active conversation ID. Let me go ahead and active conversation ID. There we go.
And now By using active conversation, we can go ahead and try and use active conversation.title or the default conversation title like this. So let's recap what we just did. We are now computing an active conversation ID from three sources. The priority is the selected conversation ID. This means the user directly used the history dialog to select a conversation.
But if the user never did that, meaning they just jumped on this project's page, we're going to fall back to loading all conversations for this project and simply picking the latest conversation that the user engaged into. And if that doesn't exist, we are going to fall back to null, no conversations available. Once we get a computed active conversation ID, we use it as a param for use conversation hook, which fetches by ID. And then we finally have active conversation in here and then if it exists we render its title otherwise we render the default conversation title to create an illusion that a new conversation is already created but we actually save some space in our database. So it seems like this was just created but it actually wasn't right.
But now if you go ahead and do the following so do a hard refresh okay and Okay, there's an easy way to test. Change this to empty. Like this. And let's refresh. Why does it still say...
Oh, okay. Create a new project. And go into that project. You can see it says empty right now. So this is the default state, right?
Not a single conversation for this project. But when I click on the plus button, it says new conversation because that is the newest created one. So if I go ahead and say a convo about things and save, It immediately reflects here a convo about things. Great. And you can see that the next time you visit, so I refresh, will immediately, you can see for a brief second it said empty and then it fell back to this.
So it will always speak the recent conversation that just existed. So now you can bring this to default conversation title. So we create an illusion that every time the user creates a new project and goes into it, we already have a conversation for them. We actually don't, we didn't create any third conversation as you can see here, but we give the user an illusion like, oh yeah, we did it, right? But we save time and it looks good.
It's a good user experience. All right. So now that we have the active conversation, we can actually do more things. For example, we can load all messages. So down here, how about we add all conversation messages using use messages, active conversation ID.
And now that we have conversation messages, let's go ahead and do another computed value. Is any message currently processing? So is processing will amount to true if any of the current conversations messages have a status of processing. If it does, if they do, we're simply going to not allow the user to send any more messages until that is resolved. And the user will be able to cancel a request.
So if it is in processing status, the user will always be able to manually stop that status so it's never kind of locked in that state. So since we already are here, we can compute isProcessing even though we're not going to use it right now. Great. Now that we have the conversation messages, we can actually go ahead and develop the conversation content because we stopped here, right? Because we didn't have anything to load.
So conversation messages question mark dot map get the message message index and in here render the message composition. Let's go ahead and give it a key of message underscore ID and from message dot role. Then a message content. So we're just continuing with the composition. If message status is equal to processing in that case we're going to go ahead and open a thernary and we're going to add a div.
Oops. A loader icon and span thinking. As in we are loading this message, right? The AI is still processing. It still hasn't given us the correct answer.
And for this div we're going to give it flex items center gap to and text muted foreground. And for the loader icon class name size for and animate spin. And now to finish the ternary, in the alternative way we're going to use message response to simply render message.content. The message response actually uses streamdown so it will automatically handle markdown or anything else that AI throws at us. So it's such little work for a great experience.
Great. And now Let's go ahead outside of message content and let's check if message.role is assistant. In that case, let's also check if message.role is assistant and message.status is completed and if message.index is equal to conversation messages?length or fallback to 0 minus 1 and Let me expand further so you can see how it looks like. Okay. Render message actions.
Actions. Then a single message action with a copy icon. So if the AI has responded, let's go ahead and give the user an ability to copy the answer. This is a common thing we can see, right? So navigator, clipboard, write text, message, content.
And give it a label of copy. Great, there we go. So right now this is ready to render but we are not actually creating any messages. So let's go ahead and see what we can do right now. I'm trying to make the most out of the things we've already built.
Let's go ahead and use this is processing to see it behave visually. So in the prompt input submit, if isProcessing disabled will be false, otherwise it's gonna be true. And the status will check if isProcessing streaming mode on, otherwise undefined. So now, if I go ahead, let's go ahead and just focus on this button here. If I go ahead and find is processing constant and if I manually change it Let me go ahead and change it to true.
You can see it will kind of have a button that indicates to the user they can click to stop this. Right. But if you reverted obviously it's not going to have anything Great. Now let's create an ability to actually submit a message. So in order to do that we need to add a new state here.
So input, set input, use state and empty string. And let's go ahead and allow the user to fill that input in the prompt input text area. So set input, get the event, oops, event target value. The value will then be input, disabled will be is processing here. Great.
And now that we have input, we're going to modify the disabled mode. So if we are processing, it's gonna be false. Otherwise, it's not just gonna fall back to true. Only if input is not here, it's going to fall back to true. You can see now it's disabled.
But if I start typing, it's enabled. So that's the logic we are trying to achieve. Now that we have that, we have to develop handleSubmit method. So change this to handleSubmit. And now let's go ahead and implement that.
So I'm going to do that right below our handleCreateConversation, const handleSubmit, asynchronous method. And in here I will accept a message to be a type of prompt input message. Let's go ahead and do actually no need to do this right now. So if processing and no new message, this is just a stop function. So yes, if the user just sent a message and we are processing and the user attempts to click on the stop sign here, that will trigger handle submit.
So the message will actually be empty. So we can detect that by checking if is processing and if there is no message dot text. In that scenario we know to do await handle cancel. We don't yet have this function but that's what we're supposed to do. And then we are going to reset the input to an empty string and return.
So that's a scenario for later. It's going to make more sense then. Now let's go ahead and see. To which conversation should we submit this message? We're defining a conversation ID here.
And by default we assume it's going to be the active conversation ID. But the active conversation ID can be null. So if there is no conversation ID, we're going to create a new one. So conversation ID will be await handle conversation handle create conversation a function from above and if even then there is no conversation ID we're simply going to break this method. So this is a scenario if the user just got here in a new project and doesn't click create a new conversation.
So right now it's sending a message to nowhere. So we just go ahead and create a new conversation for them. And this handle conversation method will automatically select it as the active one. So it kind of does a lot of job for us. Now finally, once we have that, we can go ahead and trigger ingest function via API.
So now we open our try and catch method with toast error message failed to send and in here we do await ky.post API messages which we have to develop and the payload we're going to send is the conversation ID and the message of message.text. Like this. So now we have to develop API messages. So now we have to implement this API route. Let's go ahead and do source app API and I'm going to open a new folder called messages and inside of messages create a route dot TS.
And let's start by importing Zod next response and out from clerk next JS server. Let's go ahead and quickly define our request schema using Zod. So we expect conversation ID and the message. If you take a look at conversation sidebar, you can see that that's what we send. Conversation ID and message.
So since we are not doing any Zod validation on the front end, let's this time do it on the back end. If you remember during our extension creation, we did the opposite. So I just want to show you how you can do it on the back end here. This is the request schema. So now when we define our post method here, asynchronous post request, and we go ahead and grab our user ID using clerks await out.
We throw an error, unauthorized, if user ID does not exist. We can go ahead and extract body from await request JSON and then we can go ahead and parse request schema dot parse body. And in here we get almost certain conversation ID and message because this will throw if it doesn't pass the validation. So if this isn't a string and this isn't a string, it's going to break. Great.
So now we kind of have a problem because what we have to do in this API POST request is somehow call convex mutation and convex query and also invoke ingest background jobs Because ingest background jobs are going to serve the purpose of being the agent who is going to call tools within a loop to give us an answer. And convex is our database. So how do we do this? Because we are now in an API route of Next.js and we know that usually we access convex through their set of hooks. Well convex actually offers something called convex client.
So if you go inside of source, lib and create a new file called convex-client.ts, you can import convex HTTP client from convex forward slash browser. And then in here all you have to do is export convex new convex HTTP client. And the only prop it accepts is next public convex URL. So just make sure that inside of your dot environment dot local you have next public convex URL. Perfect.
And now for example let's attempt to load the conversation using this conversation ID. So let's call a convex query. How would we do that? Const conversation will be await convex, which we can now import from lib convex client dot query. And in here, we can call our API as usual.
The problem is what do we call? Well, the way I like to do this is by creating another set of convex functions specifically designed for calling convex from a third-party source, right, from not using their hooks. So I'm going to go ahead inside of convex here and I will call those system functions. And now these system functions are a bit tricky. For example, I want to implement get conversation by ID.
So I'm going to import query. I'm going to define the arguments. I'm going to prepare an asynchronous handler here. The arguments will be conversation ID, which is convex values ID conversations. Right, so nothing unusual so far, We accept context and arguments.
And then from here, return await context database get arguments conversation ID. And that seems simple enough, right? So I saved this file. I'm going to check here. I can see all of my convex functions are ready.
So nothing unusual, right? And in here, I should now fully be able to do API.system.getConversationById. And in here, I can pass Conversation ID to be Conversation ID from above as we have to cast the type ID of conversations. So is there anything wrong with this? Well, in theory, no, right?
Just make sure you import this ID. But here's the thing. I'm not comfortable with the idea of there being what's essentially an API route. You can think of convex functions as API routes. I'm not saying it's exactly the same thing.
I'm just personally not comfortable with the idea of having a fully unprotected route like this. And you might think, okay, so we just do our verify out, right? Well, not exactly, because we will be calling these system functions from various things. Some will be API routes, some will be background jobs, right? And even if we are able to extract the user token and then we pass it along here, still think of this as something an attacker can get access to.
Imagine your attacker getting access to this API. It's obviously not that simple because we initialize the convex with our environment keys and everything, right? But just always think about it like that. We shouldn't allow them to just enter a user token here. And even if we did, user tokens can expire, change, and things like that.
So the solution I have thought of is to implement something called an internal key. And we are going to use that as simple as this. After we check one authentication, we're going to check for the internal key. Do we have process.environment.convexInternalKey? And if we don't, we're going to throw internal key is not configured I'm not gonna allow you to make requests to this API.system functions, right?
So now let's go inside of .environment.local here and let's define the convex internal key. So I'm gonna go to the top here and I'm gonna add convex underscore internal underscore key and I'm gonna give it your secret key here value. Please change this to something else especially in production. This is just for a tutorial. In fact, just smash your keyboard instead of writing your secret key here.
Yes, this will work simply because it's a string, but please don't forget and then publish this, okay? And now that you have that, copy it, save your environment local, go inside of your convex, go inside of your settings, environment variable, add and just add it here too and click save. And make sure it's the same. Okay, and now let's go ahead inside of our system.ts and let's implement a simple helper function called validateInternalKey again, internal key using process.environment.convexInternalKey like that and If the key that we pass to this function doesn't match what we have in our dot environment throw an error And now that we have validate internal key, we can finally use it inside of our mutations right here. So now I'm going to add internal key here to be a type of required string.
And now whoever attempts to call my getConversationById which is in my system here so I'm purposely separating this from all others because others are using normal ALF checks. This one is using a special internal key validation check and you can call this key whatever you want. If you want you can give it a little prefix like P as in Polaris internal key. So you know this is not something that you should expect. In fact, that might be a good idea, like Polaris convex internal key.
I'm purposely going to change it to this and I want you to do the same simply so you see all the places it's being used. Okay, so change it here in route. We changed it in system. We are now going to change it in the design of dot environment dot local. So Polaris convex internal key.
And then last thing, go ahead and change it in your environment variables. Click on edit and change this to Polaris convex internal key. There we go. This way, even if convex decides to add a variable like that in the future, it will not conflict because this one has a prefix of our own project here so we know this is something we use something that's important for us so this is no longer just a random unauthenticated function and now we can safely in my opinion safely call and query convex through various third party apps. So let's pass in the internal key.
There we go. And now we actually have access to this conversation. Great. And let's go ahead and do the usual checks now. So if there is no conversation, I'm going to return next response.
Jason, conversation was not found. And now that I have the conversation, I can get the project ID. So what I want to do now is I want to create the user message. I'm going to do to do check for processing messages. We're going to do that in a moment.
But I just want to show you like hey let's go ahead and create a user message. To create a message we again need to visit our system here and let's go ahead and create a new mutation createMessage. It's going to accept a whole bunch of arguments. Internal key, conversation ID, project ID, role of the message, content and the status with all the options it accepts. Make sure this matches exactly what you have in your schema define table so the status needs to match otherwise your function will be able to create an error in the database which is not something we want of course.
And then let's go ahead and create a handler. Let's go ahead and validate the internal key. And once we do that, let's simply create a new message using await context database insert into messages, conversation ID, project ID, role, arguments content, arguments status, and created at. Do we actually have created at? We don't, yeah, no need for that.
Okay, Do not pass that. And then let's also update conversations updated at. If you want to, right? Remember how we did this for projects. I personally think that, yeah, every time you send a new message, the conversation should be considered updated.
We currently don't have much purpose for showing updated ad of a conversation, but well, actually we do because every time you refresh, the conversation we load is the latest updated conversation. So maybe it makes sense? Yeah. And remember to return the message ID. Very important.
Alright. So now we are able to create the conversation and let me just see... Oh, I didn't import mutation. Make sure you import mutation from generated server. There we go.
And now we can go back instead of our route here and we can first go ahead and create a user message. Await convex, which comes from our convex client lib. There we go. Convex mutation. API.
System. Create message. Pass the internal key. The conversation ID. The project ID, which we fetched from above.
So we don't have to cast it because it's already a correct type. The role, this is the user and the content, this is the message. And then let's go ahead and immediately create the assistant message placeholder, right so we are sending to a weight convex mutation let me go ahead and maybe move it like this it's easier to look at convex mutation api system create message, internal key, conversation ID, project ID, role, content and status processing. Like that. There we go.
I'm gonna add to do, invoke ingest to process the message. All right. And let's return next response.json success true. Event ID for now is going to be zero. Let's add a comment to do later use ingest event ID and message ID will be assistant message ID.
And let's add a comma here so we can actually go further. All right so the to do's we have to do are check for any processing messages and stop them if there are and invoke invoke in just to process the message which right now isn't happening because we are not going to work on that now. We just want to work on the UI and the database storage, right? So let's go back inside of our conversation sidebar. Make sure you actually develop this inside of app API messages, route dot ts.
So this route works. And let's try it out. Let's see if this will work or not. So if I say we can refresh, we can do whatever. Perhaps it's best to test if everything works by creating a new project, clicking on it and then let's do hello world.
Let's go ahead and press enter and there we go. Hello world has been sent and you can see that in here it's immediately set to thinking and if you look at our data we should have two messages one from the user and one from the assistant with the status of processing right here. Perfect. And you can see that while the message is processing, the user is not allowed to send any new messages. They will only be allowed to stop, right?
So they can cancel the current request. This is kind of a protection so not too many costs occur. Now let's go ahead and create another system function. So I'm going to go inside of system here and I'm going to implement a mutation called update message content. Here it is.
Update message content is a mutation which accepts the internal key, the message ID and the new content. We do the usual. We validate the internal key and we do await context database dot batch. And now let's create the ingest background job to process a message. So we're not going to do any AI processing.
I just want to stimulate how it's going to look like. And we're going to need this mutation for that. So let's go ahead inside of source features, conversations, and I will create an ingest folder here and then inside process message.ts. Let's create an interface message event which is going to define what is the payload of this background job. A message ID, conversation ID, project ID, and the message in a form of a string.
:23 So let's export const process message, which will use the ingest client and the create function. In the first argument we're going to define the ID to be process message and then let's go ahead and add let me just see like this, ok. Event message forward slash sent. And more importantly, how about we add an ability, we do this here actually, called cancel on. Cancel on is actually a super cool feature, which allows you to cancel a function, a running background job simply by invoking a very specific event which in this case will be message cancel.
:15 But only if we match data message ID. So we can simply trigger this from Ingest client. And if the message ID is a match, then it's going to cancel whatever processing of that message ID we had. So super cool built-in feature from Ingest. And I just took a peek and looks like it's deprecated.
:45 So let's use if instead. And I believe it works the same. Let's see if asynchronous data user equals event data user ID. All right, not 100% sure. So I'm just going to go ahead and research just a bit.
:03 So if is definitely the new one and it uses a common expression language where this is how you would do it. Event.data.messageid equals asynchronous.data.messageId. Event refers to the incoming cancel event and async refers to the original event that triggered the function. So a much more explicit language. Alright, now let's go ahead and do the following.
:38 So we have to open Async, Event and Step, Like so. And let's start by extracting from event.data which we can cast as message event. Let's extract message ID, conversation ID, project ID and message. Like that. Let's get the internal key.
:08 And we are just going to do a slight modification here so we know that this is Polaris convex internal key. Then if there is no internal key let's throw a non-retriable error which you can import from ingest and just do polaris convex internal key is not configured And now what I want to do here is simply do await step.sleep and let me actually find the proper syntax for this, just a moment. Basically I'm just using sleep to pretend some AI processing is happening like let's wait five seconds for example. And then what we are going to do is we're going to create a step update assistant message. And in here we're going to call await convex which we have to import.
:06 So from lib convex client. Dot mutation API which we have to import from generated API dot system update message content. Passing the internal key the message ID and the content in here will be a I processed this message to do Like that. So a super simple step and we're not using these so we can remove them from now. Let's just go ahead and get rid of all of these which we're not using so it's not confusing us here.
:45 And I guess we can also remove them from the message event as well. So super simple step which we can cancel if the message ID matches and which fails immediately if it's non-retriable and It pretends to do some AI processing. So now let's go ahead and register that method inside of API ingest route. So I'm going to add process message from features conversations ingest process message. Let's go ahead and do npx ingest cli latest dev.
:26 Let me just try and expand this. Let's make sure this is running. Let's go ahead and visit localhost 8282 just to confirm all is well and so that we have that new function. Okay, looks Like I have a bunch of them here. I think it's because I have my other app opened.
:50 Just a second. All right, here we go. So now I have demo error, demo generate and process message. Make sure you are running your app with this npx ignore scripts false in just cli latest dev since that's the official instructions yeah great and we now have that method here process message and now that we have it Let's go back inside of our API messages route. And in here, after we create the user message, after we create the assistant message ID, we can go ahead and Invoke ingest to process the message.
:33 So we need to know the event's name which is message.sent. So that's what we're going to call here. So let's do const event. Await ingest. Make sure to import that from ingest client I'm going to move it here ingest.send name message forward slash sent data and simply pass in the message ID which is assistant message ID.
:05 Later we're going to have more info but for now that's the only one we are working with. So now we can go ahead and pass the event.ids and then the first one in the array here. I mean we're not really using that but you know just more info doesn't hurt. So what's supposed to happen now is that when you send a message, it shouldn't get stuck in the thinking phase. Let me open a new conversation.
:30 Hey, how are you? So now I should send it. It should think for four seconds And then it should update the message content There we go. AI processed this message in Parenthesis to do so that is the general idea of how this is going to work. We are mixing convex database access and ingest and we are doing it all very securely with the internal key here.
:58 Amazing. We can also very easily implement a native failure message. So instead of process message in just function, if we go ahead and add on failure, we can make this an asynchronous method, get the event and the step and in here we can extract message ID from event.data.event.data as message event so we have some type safety and then we can again attempt to get the internal key here. Polaris convex internal key and we can update the message with the error content. So for example if we have the internal key call step dot run update message on failure and call convex mutation API system update message content with internal key message ID and content and then some error message.
:01 My apologies, I encountered an error while processing your request. How do we test this? Well let's purposely throw an error here. So I'm gonna go ahead and do a wait step dot run throw error async throw non retriable error purposely through this. Or maybe I can just do it like this.
:30 How does this work. Non retryable error. Oh Oh, throw new non-retriable error. So await step.run, throw on purpose. And let's go ahead and add that here.
:48 So if we try this again now, let me go ahead and refresh. And if I say this will not work after four seconds, it should throw on purpose and it should update the contents of this message with this. My apologies I encountered an error while processing your request let me know if you need anything else. And if you haven't noticed already this method, ok make sure to remove this throw on purpose it was just to test it out. When we call this update message content we also set the status to be completed right.
:30 I don't think I brought your attention to that, right? That's what makes this display a message instead of infinitely spinning. And let's try one more time. This will now work. So the only thing that doesn't work well is this set input doesn't reset.
:46 So let's quickly go inside of the conversation sidebar and in here set input to an empty string. So I'm talking about the handle submit method right here. So Let's go ahead and refresh and let's do another try. There we go. Now it clears it.
:07 The cancel is not yet working. We will implement that. It's a little bit more complicated but I want to keep that in the next chapter since we are already more than an hour in. So let's go ahead and merge all of these changes. There's a lot of them.
:21 So let's see. I'm going to shut down all of these files and I will create git add, git commit 12 conversation add, git commit 12 conversation system, git checkout b12 conversation system, git push u origin 12 conversation system. There we go. You can see I am on conversation system and let's go ahead and open a pull request and review our changes. There's a lot of them so if CodeRabbit comments on our components file we are mostly going to ignore those.
:03 We are just going to focus on if there are some, any serious issues in what we've currently implemented that isn't in a temporary state because we will be working further on this system. We still have to connect it with AI, of course. So as I suspected, the release notes are mostly skewed due to the fact that we've added 40 components from AI elements. So let's take a look at the new features, but do understand that it took context of all the new components we added. So perhaps some things might not make sense.
:37 We added conversation and messaging system for multi-turn interactions. We introduced comprehensive AI UI components for content visualization, code blocks, stations, reasoning panels, and image display. We added message processing with Ingest integration for asynchronous handling. We implemented prompt input with file attachment and speech-to-text support. So this is the functionality inside of the prompt input component.
:02 We are not using it but it exists. The code is there. That's why it's adding these things which seem like we didn't do this but we actually did by adding all of those components. Majority of the comments here are actually referring to those. A few of them are for our things like this route which is missing error handling.
:22 We can use safe bars here I think so it throws the error or we can just implement try catch to fix this but you can see majority of the comments are for this. I think the only one I actually found is at the end here, which is simply warning us about the fact that we are sending this placeholder message to the public and to confirm that this will be changed later, as well as the status should transition to something like completed. Let's see. Well, update message content actually already does that. It's just not clear in the name.
:58 And in here it's telling us to do a runtime check on this missing environment variable if it's missing. So mostly nothing serious and majority of the comments are about all the components we've added. Amazing, amazing job! So let's go ahead and sync git checkout main, git pull origin main so we are up to date. And as always, I like to confirm by opening my source here, graph, and I've checked out for 12 and merged back in.
:29 Amazing, amazing job. We've set up conversation and messages. We can send both user and receive assistant messages. We didn't do the past conversations history dialogue. We're going to do that in the next chapter.
:42 We handled message sending, but we kind of prepared cancellation, but not fully. And we did display the thinking indicators. So good job. Some things will be left for the next chapter and then we will be able to immediately combine all of that with AI functionality. Amazing, amazing job and see you in the next one.