Building an AI Chat App with .NET, Azure OpenAI and Vue.js - Part 3
In part 3 of this tutorial, we'll create the Vue.js frontend for our chat application.
Introduction
Since we have completed all the backend related tasks in the previous two parts, we can now move on to the frontend of the project. In part 1 we created a .NET Core API that serves as the backend for our chat application. In part 2 we integrated Azure’s OpenAI service to generate responses for our chatbot.
In this part, we will create a Vue.js frontend that will interact with the backend API to send and receive messages.
Loading graph...
Scaffolding the Vue.js Project
Looking at the folder structure, we have the following setup:
📂ChatApp/
├── 📁backend/ # contains our backend from part 1 & 2
└── 📁frontend/
First, inside the 📁frontend
folder, let’s create a new empty Vue 3 project using the following command:
# 🖥️CLI
pnpm create vue@latest
I’m using pnpm
as my package manager, but you can use npm
or yarn
if you prefer.
The CLI will ask you a few questions about the project setup. For this tutorial, we choose a project name e.g. ChatClient
and select TypeScript. All other options can be left as default.
From inside the ChatClient
folder, we can now run the project using the following commands:
# 🖥️CLI
pnpm install # or npm install or yarn install
pnpm dev # or npm run dev or yarn dev
All dependencies will be installed and the development server will start.
During the scaffolding process, demo components and assets are created. We will remove these and create our own components and composable to build the chat application.
For this we remove all the files from the 📁src/components
and 📁src/assets
folders (except for the 📄style.css
file) and delete all the content/code from the 📄src/App.vue
file.
Structure
An important step in frontend development is to componentize your application. This means breaking down your application into smaller, reusable parts that can be combined to create the final product.
In our case, we’ll create the following components:
- Prompt input field with a submit button
- A single Chat message
- The list of chat messages
- The chat application itself
Furthermore we will create a - in Vue terms - composable to hold the chat messages and handle the communication with the backend API.
Composables
We will start by writing this exact composable. Composables are a way to encapsulate logic and state in a reusable way. They are a great way to share code between components and keep your codebase clean and organized.
In the 📁src
folder, we create a new folder named 📁composables
and inside it a new file named 📄useChat.ts
.
// 📄src/composables/useChat.ts
import { ref } from 'vue';
export interface ChatMessage {
id: number;
text: string;
sender: 'User' | 'Bot';
timestamp: string;
}
export function useChat() {
const url: string = "http://localhost:8080/messages";
const messages = ref<Array<ChatMessage>>([]);
async function sendMessage(text: string) {
addMessage({
id: messages.value.length + 1,
text: text,
sender: 'User',
timestamp: new Date().toLocaleTimeString(),
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: text }),
})
const data = await response.text()
addMessage({
id: messages.value.length + 1,
text: data,
sender: 'Bot',
timestamp: new Date().toLocaleTimeString(),
});
}
function addMessage(message: ChatMessage) {
messages.value = [...messages.value, message];
}
return {
messages,
sendMessage,
};
}
This composable contains the useChat
function which returns an object with the messages
reactive variable and the sendMessage
function. The messages
variable holds an array of ChatMessage
objects. The sendMessage
function sends a message to the backend API and adds the user’s message to the messages
array. Once the response is received, the bot’s message is added to the messages
array as well.
It also contains the ChatMessage
interface which defines the structure of a chat message. Each message has an id
, text
, sender
, and timestamp
property and the sender
property will be used to differentiate between user and bot messages. This will help us style the messages differently in the frontend.
Components
With the composable and the ChatMessage
interface in place , let’s continue by creating the necessary components for our chat application.
PromptInput.vue
In the 📁src/components
folder, we create a new file named 📄PromptInput.vue
. This component contains a form element with an input field and its corresponding submit button.
<!--📄src/components/PromptInput.vue-->
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'prompt-entered', text: string): any
}>()
const input = ref('')
function promptEntered() {
if (!input.value) return
emit('prompt-entered', input.value)
input.value = ''
}
</script>
<template>
<form @submit.prevent="promptEntered">
<input type="text" v-model="input"
placeholder="Send a message" />
<button type="submit">Submit</button>
</form>
</template>
What is happening here? We are using the script setup syntax to define our component. We tell Vue that the PromptInput
component should emit an event prompt-entered
with the entered text as a payload. The promptEntered
function is called when the form is submitted and emits the event with the input value. The input field is bound to the input
ref and cleared after the form is submitted.
For styling, we append the following <style/>
block to the same file:
<!--📄src/components/PromptInput.vue-->
<style scoped>
form {
display: flex;
justify-content: center;
gap: 1rem;
padding: 2rem 1rem;
}
input, button {
border: 1px solid #ccc;
border-radius: 0.5rem;
padding: 0.5rem 1rem;
color: #fff;
font-size: 1.125rem;
}
input {
background-color: transparent;
}
button {
background-color: #333;
cursor: pointer;
}
</style>
Message.vue
Next, we create a new file named 📄Message.vue
in the 📁src/components
folder. This component displays a single chat message.
<!--📄src/components/Message.vue-->
<script setup lang="ts">
import type { ChatMessage } from '@/composables/useChat'
interface Props {
message: ChatMessage
}
defineProps<Props>()
</script>
<template>
<p>{{ message.text }}</p>
</template>
To have proper TypeScript support, we import the ChatMessage
interface from the useChat
composable and define the Props
interface with a message
property of type ChatMessage
.
In context of Vue, Props are used to pass data from a parent component to a child component. In this case, the Message
component receives a message
prop of type ChatMessage
and displays the message text.
Messages.vue
Lastly, we create a new file named 📄Messages.vue
in the 📁src/components
folder. This component displays our list of chat messages.
<script setup lang="ts">
import type { ChatMessage } from '@/composables/useChat'
import Message from '@/components/Message.vue'
interface Props {
messages: ChatMessage[]
}
defineProps<Props>()
</script>
<template>
<section>
<ol>
<li
v-for="message in messages"
:key="message.id"
:class="{'user': message.sender == 'User'}">
<Message :message="message" />
</li>
</ol>
</section>
</template>
The component receives a messages
prop of type ChatMessage[]
and iterates over the messages to display each message using the Message
component in an ordered list. To be able to use the Message
component, we import it at the top of the file.
The user
class is then added to the list item if the message sender is the user. This class will be used to style the user’s messages differently from the bot’s messages.
The css looks like this:
<style scoped>
section {
flex: 1;
padding: 2rem 4rem;
}
ol {
list-style-type: none;
display: flex;
flex-direction: column;
gap: 2rem;
& .user {
max-width: 65%;
align-self: flex-end;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: #333;
}
}
</style>
App.vue
Now that we have all the necessary components, we can complete the main App.vue
file in the 📄src
folder.
<!--📄src/App.vue-->
<script setup lang="ts">
import Messages from '@/components/Messages.vue'
import PromptInput from '@/components/PromptInput.vue'
import { useChat } from '@/composables/useChat'
const { messages, sendMessage } = useChat()
async function promptEntered(text: string) {
await sendMessage(text)
}
</script>
<template>
<main>
<Messages :messages="messages" />
<PromptInput @prompt-entered="promptEntered" />
</main>
</template>
In the App.vue
component, we import the Messages
and PromptInput
components as well as the useChat
composable. We then call the useChat
function to get the messages
reactive variable and the sendMessage
function.
While creating the PromptInput
component, we defined an event prompt-entered
that is emitted when the form is submitted. We listen to this event in the App.vue
component and call the promptEntered
function when the event is emitted. The promptEntered
function calls the sendMessage
function with the entered text.
Due to the reactive nature of Vue 3, the Messages
component will automatically update when the messages
variable changes. This means that when a new message is added to the messages
array, the chat application will automatically update to display the new message.
For styling, we append the following <style/>
block to the same file:
<style scoped>
main {
display: flex;
flex-direction: column;
min-height: 100vh;
}
</style>
Global Styles
The last thing we need to do is to add some global styles to our application.
While scaffolding the Vue 3 project, a 📄src/style.css
file was created. We can use this file to add global styles to our application.
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
input,
button,
textarea,
select {
font: inherit;
}
body {
background-color: #222;
color: #fff;
font-family: 'Roboto', sans-serif;
}
This file contains some basic styles to reset the default browser styles and set the background color, text color, and font family for the application.
Running the Application
Since we had the development server running, we were able to watch the progress of our frontend application. If you haven’t started the development server yet, you can do so by running the following command from the 📁frontend/ChatClient
(or what name you enetered during running the vue3 create command) folder:
# 🖥️CLI
pnpm dev #or npm run dev or yarn dev
Running in Docker
As in the first part of this tutorial, we can build the frontend application and run it in a Docker container. To do this, we need to create a Dockerfile
in the root of the project:
# 📄frontend/ChatClient/Dockerfile
# build image
FROM node:20-alpine AS build
WORKDIR /source
# install pnpm
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .
RUN pnpm build
# final stage/image
FROM nginx:alpine
COPY --from=build /source/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
This is a two stage build process. In the first stage, we use the official Node.js image to build the Vue.js project. We install pnpm
and copy the package.json
and pnpm-lock.yaml
files to the container. We then install the dependencies and copy the rest of the project files. After that, we build the project using the pnpm build
command.
In the second stage, we copy the built files to the final image which uses the official Nginx image. The built files are put in the /usr/share/nginx/html
directory and we expose port 80. Finally, we start the Nginx server.
With our Dockerfile complete, we can finish our 📄docker-compose.yml
file and start the containers to have a fully containerized application.
# 📄docker-compose.yml
version: '3.8'
services:
backend:
build:
context: ./backend/ChatApi
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}
- AZURE_OPENAI_GPT_NAME=${AZURE_OPENAI_GPT_NAME}
- AZURE_OPENAI_KEY=${AZURE_OPENAI_KEY}
+ frontend:
+ build:
+ context: ./frontend/ChatClient
+ dockerfile: Dockerfile
+ ports:
+ - "80:80"
+ depends_on:
+ - backend
Now we can run the frontend and backend services together using Docker Compose:
# 🖥️CLI
docker-compose up -d
The Docker UI should be showing something like this if everything is running correctly:
Now when you open your browser and navigate to http://localhost
, you should see the chat application running in the browser and should be able to chat with OpenAI’s GPT-3. The bot should also be very keen to talk about Star Wars.
Conclusion
Even though the three parts of this tutorial were quite extensive, we achieved a lot by writing only a few lines of code (comparitively).
We’ve built a complete chat application that uses the Azure OpenAI service to generate responses. We’ve used .NET to build the API and Vue.js to create the frontend. We’ve containerized the API and the frontend using Docker. And we’ve seen how to structure our frontend application using components and composables.
We’ve created a modern chat application that can be extended and improved in many ways. We could add more features like user authentication, message history, or even a chatbot that uses a company’s publicly available data to generate responses. The possibilities are endless.
If you made it this far, congratulations and thank you for following along. I hope you’ve learned something new and had fun building this chat application with me. If you have any questions or feedback, feel free to reach out to me on Twitter or by email.
Until next time, happy coding! 🚀