· ai

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.

Building an AI Chat App with .NET, Azure OpenAI and Vue.js - Part 3

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.

Vue 3 CLI setup

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:

Docker UI showing the docker containers running

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.

A screenshot of how the final application looks like

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! 🚀