Nazar Mammedov

Software Engineer

How to build a chatbot for your website? Part 3: Frontend Coding

11 min read
|

Introduction

Welcome to Part 3 of my article series, "How to build a chatbot for your website?". In Part 2 we created the visual layout and did some CSS coding. In this part, I’ll discuss the process of building the frontend application. My articles primarily aim to share my thought process rather than instruct on coding. Furthermore, my discussion is confined to a single feature - chatbot, not an entire web application. The source code for this article can be found in my Git repository.

chatbot window
Figure 1. A screenshot of my chatbot on my website

Frontend Coding Requirements for the Chatbot

The functionality of this chatbot window is straightforward and includes basic features suitable for a simple website:

  • The chat window opens when the small bubble is clicked.
  • The chat window minimizes when the close button (cross icon) is clicked.
  • Messages are sent when the send icon is clicked.
  • The message list automatically scrolls to the latest message if the number of messages exceeds the window's capacity.
  • Messages are sent to the server, and responses are automatically added to the message list.
  • Message text is displayed gradually to imitate the streaming of data or the chatbot's thinking process. This is purely for aesthetics; the message is actually received immediately and available at once.
  • The time of the message is displayed in an easy-to-read datetime format.
  • There is a function that prevents too many submissions to the chatbot as a safety measure.

Let's start coding

In this article, we will be using AngularJS, a powerful frontend framework equipped with all the necessary tools to build a web application. While I won’t cover everything related to Angular app development in this short article, I will focus on the parts relevant to our project. I assume you have Node.js installed and are familiar with npm commands.

Let’s begin by installing the Angular Command Line Interface (CLI), which will enable us to run Angular app development commands.

$> npm install -g @angular/cli

Once the above process completes successfully, navigate to an empty directory on your computer where you want to create the app, and run the following command:

$> ng new chatbot-app

You will see something similar to the following.

✔ Packages installed successfully.
 Successfully initialized git.

You are not currently in the newly created directory. Navigate to the directory and run the npm start command.

$> cd chatbot-app
# This will start the app
$> npm start

In your browser, navigate to localhost:4200. If you see the Angular app running with the message “Hello, chatbot-app,” congratulations! You have successfully created your first Angular app.

Next, we need to add TailwindCSS libraries to style our chatbot app with Tailwind. Run the following command:

$> npm install -D tailwindcss postcss autoprefixer
$> npx tailwindcss init

Add the basic Tailwind configuration to the tailwind.config.js file. This setup instructs the system to process files with .html and .ts extensions, converting TailwindCSS into standard CSS.

/* tailwind.config.js */
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{html,ts}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Add the @tailwind directives for each of Tailwind’s layers to your src/styles.css file:

/* src/styles.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

We already have a main page in our app, represented by the app component in the /src/app folder. Let’s create a new component called chatbox for our chatbot window, which will generate a new folder in /src/app/chatbox.

$> ng generate component chatbox

Our first goal is to properly display the chat window in the app. Modify the src/app/app.component.ts and src/app/app.component.html files as shown below. These steps will integrate the chatbox component into the webpage.

// src/app/app.component.ts
import { ChatboxComponent } from './chatbox/chatbox.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, ChatboxComponent],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent {
  title = 'chatbot-app';
}

This should be added towards the end of the file.

// src/app/app.component.html
//...
<app-chatbox/>
<router-outlet />

Adding visual components

After running the ng serve command, you should see the bubble in the bottom right corner of the page. We need additional visual and service components for our chatbox to function properly. One of these is the visual three dots component, which indicates waiting. The second visual component is the typewriter component, which types text slowly to imitate AI-style thinking. Let's create them.

$> ng generate component loading-dots
$> ng generate component typewriter

The above commands create the respective component folders. You can remove the .css, .spec.ts, and .html files, as they are not needed for this demo project.

Add the following code to the src/loading-dots/loading-dots.component.ts file. The main animation is achieved using the @keyframes rule in CSS.

/* src/loading-dots/loading-dots.component.ts */
import { Component } from '@angular/core';

@Component({
    standalone: true,
    selector: 'app-loading-dots',
    template: `
    <div class="loading-dots">
        <div class="dot"></div>
        <div class="dot"></div>
        <div class="dot"></div>
    </div>
    `,
    styles: `
        .loading-dots {
        display: flex;
        justify-content: center;
        align-items: center;
        }

        .dot {
        width: 8px;
        height: 8px;
        margin: 0 4px;
        background-color: #333;
        border-radius: 50%;
        animation: dot-blink 1.4s infinite both;
        }

        .dot:nth-child(1) {
        animation-delay: -0.32s;
        }

        .dot:nth-child(2) {
        animation-delay: -0.16s;
        }

        @keyframes dot-blink {
        0%, 80%, 100% {
            opacity: 0;
        }
        40% {
            opacity: 1;
        }
        }
    `,
})
export class LoadingDots {
    /* Component behavior is defined in here */
}

For the typewriter component, we can use the code below. The main idea is to use JavaScript's setTimeout function to add the next character in the string to the end of the displayed string. If the interval is small, the typewriting happens faster. The ngOnInit() function runs once when the component is loaded. When typewriting finishes, this component emits a message with @Output() messageEvent = new EventEmitter<number>(); to notify our application that typing of this message is complete.

/* src/typewriter/typewriter.component.ts */
import { Component, OnInit, Input, Output, EventEmitter, ViewEncapsulation } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Component({
    standalone: true,
    selector: 'app-typewriter',
    template: `<div [innerHTML]="displayedText"></div>`
})

export class TypewriterComponent implements OnInit {
    @Input() content!: string; // Takes input "content" as string
    @Input() id!: number; // Takes message "id" as input 
    @Output() messageEvent = new EventEmitter<number>(); // Issues an event when typing is over
    readonly speedFactor = 6000; //A subjective constant to adjust speed initially. It is number of milliseconds
    safeContent!: SafeHtml;
    displayedText: string = ''; // This is the text displayed in the component
    currentIndex: number = 0; // This variable tracks the character that's being typed at the moment
    interval!: number; // This is the interval at which the next character of the input string is printed.

    constructor(private sanitizer: DomSanitizer) { }

    ngOnInit(): void {
        this.interval = Math.ceil(this.speedFactor / (this.content.length + 1)); // One is a subjective choice to prevent division by 0.
        if(this.interval>50) this.interval = 50;
        this.typeWriter();
    }

    typeWriter(): void {
        if (this.currentIndex < this.content.length) {
            this.displayedText += this.content.charAt(this.currentIndex);
            this.currentIndex++;
            setTimeout(() => this.typeWriter(), this.interval); // Adjust the delay as needed
        } else {
            this.messageEvent.emit(this.id);
        }
    }
}

Adding types

We need another structure for our project that defines the appearance of our chat messages. It doesn't have to be in a separate file, but if we want to reuse it in multiple components, it should be in its own file.

$> ng generate interface ChatBotMessage

Our ChatBotMessage structure includes the following fields:

  • source: Indicates whether the message is from the 'server' or 'client'.
  • text: Contains the content of the message.
  • created: Records the time when the message is received.
  • additionalQuestions: A list of questions proposed by the chatbot to the client, which can contain multiple items.
/* src/chat-bot-message.ts */
export interface ChatBotMessage {
    source: string;
    text: string,
    created: string;
    additionalQuestions: string[];
}

Adding services

In applications, functionality shared across components is referred to as a service. In this particular project, the chatbot service is very simple and is not shared by any other components. However, to demonstrate the concepts, I have still created it as a separate service. For instance, I could use the chat service not only for the chatbot component but also for a real-time chat component with a real person in the future. In such cases, it is important to have the service functions in a separate file. Let's create the service with ng generate service ChatBot.

$> ng generate service ChatBot

Put the following code in the chat-bot.service.ts file.

/* src/chat-bot.service.ts */
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, catchError, of } from 'rxjs';
import { ChatBotMessage } from './chat-bot-message';

@Injectable({ providedIn: 'root' })
export class ChatBotService {
  readonly chatbotAPI = "chatbot.json"; //for example http://yoursite.com/api/chatbot


  constructor(private http: HttpClient) {}

  getResponse(question: string): Observable<ChatBotMessage[]> {
    const errorMessage: ChatBotMessage = {
      source: 'server',
      text: 'I apologize for the inconvenience but I cannot access the server at the moment. \
      You can contact me at myemail@domain.com if you have any questions.',
      created: (new Date()).toISOString(),
      additionalQuestions: []
    };

    const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
    const questionMessage = { text: question };
    return this.http.post<ChatBotMessage[]>(this.chatbotAPI, questionMessage, { headers })
      .pipe(
        catchError(error => {
          console.error('Error occurred:', error);
          return of([errorMessage]);
        }));
  }
}

In the code above, the @Injectable({ providedIn: 'root' }) decorator marks ChatBotService as available for dependency injection. It informs Angular’s dependency injection system that this class can be instantiated and injected into other classes. There is only one instance of this service across the entire application, known as a singleton service. If the service is not used by any components, Angular removes it from the final application bundle through a process called tree shaking. Thanks to this simple statement, we don't have to manually inject the service into every component.

The constructor constructor(private http: HttpClient){} injects an HttpClient into the service, allowing us to send HTTP requests to a REST API. There is a standard error message for cases when the service cannot access the online service. The getResponse(question: string) method takes a question as a string input and sends a POST request to the backend server. If the response is successful, it is returned as a result of the ChatBotMessage type we defined earlier. If it fails, the error message defined in this file is returned with return of([errorMessage]);.

Important

To enable these types of injectables, we need to add a few lines to the src/app.config.ts file. The provideHttpClient and withFetch functions are required to use the HttpClient in the application. The provideAnimations function is necessary for the visual animation effects used by our chatbot window.

// src/app.config.ts 
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http'; //ADD THIS LINE
import { provideAnimations } from '@angular/platform-browser/animations'; //ADD THIS LINE

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideAnimations(), //ADD THIS LINE
    provideHttpClient(withFetch()), //ADD THIS LINE
    provideRouter(routes), provideClientHydration()]
};

Adding assets

In the chatbot-app folder, there is a folder called public. Everything in that folder is copied to the distribution folder. Therefore, we need to place the chatbot icon image and other assets in that folder. In our case, I put the icon in the public/assets/chatbot.png path. I also placed the chat message JSON file in the public/chatbot.json path.

Bringing Everything Together in the Chatbox Component

The source code for chatbox.component.ts and chatbox.component.html is available in the repository. I will not replicate them here. Instead, I want to explain the important parts of the file.

Toggling the Window

Switching the chat window on and off is achieved by controlling the visibility of the div element using the @ngIf structural directive in the HTML template.

<div *ngIf="!chatWindowVisible"
    class="fixed z-10 justify-end bottom-4 right-4 overflow-hidden fill-cyan-500">
<!-- ... -->
<div [@openClose]="chatWindowVisible ? 'open':'closed'"
    class="fixed bg-white bottom-4 right-4 w-80 shadow-lg rounded-lg overflow-hidden">

The chatWindowVisible is a boolean variable in the ChatBoxComponent. If it is true, the window is visible; if false, the window is hidden. The visibility of the chatbot bubble also changes according to the state of this variable. It triggers an animation from an open state to a closed state, which lasts 0.3 seconds. These animations are defined in the animations field of the component.

The actual change of the chatWindowVisible variable is achieved by the following piece of code. You can assign click events in Angular like this. The same is done for the chat window's close icon (cross icon).

<svg (click)="(toggleChat())" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
            class="size-16 fill-lime-500 hover:fill-cyan-700 hover:shadow-lg"></svg>
<!-- ... -->
toggleChat() {
    this.chatWindowVisible = !this.chatWindowVisible;
  }

Displaying the messages

Next, we list the chat messages in the chat container div. The *ngFor directive creates a loop to iterate through the conversationHistory variable, which is an array of ChatBotMessage type messages. As we loop through the messages, we apply different styles depending on whether the message.source value is server or client. These messages are aligned to different sides of the container accordingly.

<li *ngFor="let message of conversationHistory; let i = index">
    @if (message.source=='server')
    {
        
    } else {

    }

Typewriter effect

The typewriter effect is achieved using the typewriter component we defined earlier. This component accepts content and id as inputs. It emits a message when the typewriting finishes via the (messageEvent)="receiveMessageFromChild($event)" code. This message is necessary to keep the chatbot's suggested question hidden until the typing is complete. Otherwise, proposing a question before the thinking is over doesn't seem logical. The typing state of each message is stored in an array of boolean values. For example, typing[2] refers to the status of the third message. There is no need to do this for the client side, as the client's question can appear instantaneously.

<app-typewriter [content]="message.text" (messageEvent)="receiveMessageFromChild($event)" [id]="i" />

Scrolling to the last message

When the number of messages increases, we still want to see the latest ones. We achieve this by using the ngAfterViewChecked lifecycle hook in Angular, which is called after the contents of the chat container change.

// This creates a reference to the chat container
@ViewChild('chatContainer') private chatContainer!: ElementRef;

// This function does the scrolling
private scrollToBottom(): void {
    this.chatContainer.nativeElement.scrollTop = this.chatContainer.nativeElement.scrollHeight;
} 

Displaying the message date

We receive messages in a computer-friendly format (Date()).toISOString()). We convert it to a simpler, human-friendly format using a pipe. A pipe is a functionality where we input something at one end and receive a transformed result at the other end. In some templating frameworks, such as Symfony, this functionality is called a filter. You apply a pipe to a variable using the | character. Our pipe performs simple formatting, but it could also be used to achieve more complex features, such as displaying messages like '15 seconds ago' or '5 minutes ago'.

<div class="flex justify-end text-xs px-2 text-gray-400">
{{message.created | shortenDate }}
</div>
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'shortenDate',
  standalone: true,
})

export class ShortenDatePipe implements PipeTransform {
  transform(dateString: string, args?: any): any {
    const convertedDate = new Date(dateString);
    return new Intl.DateTimeFormat('en-US', {
      hour: 'numeric',
      minute: 'numeric',
      hour12: true
    }).format(convertedDate);
  }
}

Displaying the loading dots

Three animated dots are displayed when the request status is loading, a variable in the ChatboxComponent. Once the request is finished, loading changes to false, and the loading indicator is hidden. The loading indicator is always positioned at the bottom of the chat container. We use our previously created loading-dots component for this functionality.

<li *ngIf="loading==true">
    <div class="flex mt-2 gap-x-2 justify-end">
        <div class="">
            <div class="w-full rounded-lg bg-blue-50 p-3"><app-loading-dots /></div>
        </div>
        <div><img src="./assets/images/chatbot.png" width="50"></div>
    </div>
</li>

Sending and receiving the message

The message can be sent by clicking the send icon or pressing the Enter key.

<textarea #chatMessage type="text" placeholder="Ask me something..."
            class="flex-1 align-text-top p-2 h-18 border border-gray-300 rounded-lg bg-gray-100 text-sm"
            (keyup.enter)="sendMessage()"></textarea>

The sendMessage function retrieves the text value from the chatMessage textarea element and checks if the submission is empty.

sendMessage(): void {
    const message = this.chatMessage.nativeElement.value;
    if (message) {
      this.chatSubmit(this.chatMessage.nativeElement.value);
      this.chatMessage.nativeElement.value = '';
      this.emptySubmissionCount = 0;
    } else {
      if (this.emptySubmissionCount < this.emptySubmissionLimit) {
        this.addServerMessage("Please try to write a question and then send it", []);
        this.emptySubmissionCount++;
      }
    }
  }

The chatSubmit function checks if the question limit has been reached. If not, it packages the message text into a ChatBotMessage and adds it to the conversation history. It then calls the getResponse function, which interacts with the chatbot service we created earlier. The response from the service, also a ChatBotMessage, is added to the conversation history. The addServerMessage function is a helper that adds server messages to the conversation history. It does not interact with the backend server and is mainly used for adding error messages or notifications on the frontend.

  private chatSubmit(message: string) {
    if (this.questionCount < this.questionLimit) {
      const newMessage: ChatBotMessage = {
        source: 'client',
        text: message,
        created: (new Date()).toISOString(),
        additionalQuestions: []
      }
      this.typing.push(false);
      this.conversationHistory.push(newMessage);
      this.getResponse(newMessage);
      this.questionCount++;
    } else {
      this.addServerMessage("You have reached the question limit. Please try again later.", []);
    }
  }

  private getResponse(message: ChatBotMessage): void {
    this.loading = true;
    this.chatBotService.getResponse(message.text)
      .subscribe(responseMessages => {
        this.loading = false;
        this.typing.push(true);
        responseMessages[0].created = (new Date()).toISOString();
        this.conversationHistory.push(responseMessages[0]);
      })
  }

  private addServerMessage(message: string, additional: string[]): void {
    const newDate = new Date();
    const newMessage = {
      source: 'server',
      text: message,
      created: (new Date()).toISOString(),
      additionalQuestions: additional
    }
    this.typing.push(true);
    this.conversationHistory.push(newMessage);
  }

Asking the proposed question

The proposed questions are clickable. When clicked, they are submitted as a question from the client and appear identical to typed questions. This functionality is easily achieved with a simple function.

<div class="flex" *ngIf="typing[i]==false">
    <div (click)="proposedQuestion(question)"
        class="cursor-pointer bg-white hover:bg-slate-100 border rounded-xl p-1 px-2 text-xs"
        *ngFor="let question of message.additionalQuestions">
        {{question}}
    </div>
</div>

We call the proposedQuestion function in the component to submit the question. While we could use the chatSubmit function directly, there might be some processing needed for the proposed question before submission. Therefore, it's advisable to keep it as a separate function, even if it doesn't perform any special tasks. This decision is subjective.

proposedQuestion(message: string) {
    this.chatSubmit(message);
}

With this, we conclude the frontend coding of the chatbot window. This is a highly simplified version of frontend coding. We have excluded comprehensive error handling, testing, and advanced features. For instance, in a larger application, you might want to have a dedicated ConfigurationService to manage configuration variables in a separate file. Additionally, issuing helpful error messages would be beneficial. I intentionally omitted these aspects to keep the article straightforward.

About frontend coding

Here's an improved version:

As we can see from the above, frontend coding is similar to any other kind of software engineering. We design a software architecture, use other software development concepts like dependency injection and event management, and write actual code. The only difference from other types of software engineering is that the code written for the frontend is compiled on the client side, not on the server.

I have met developers who say apologetically, 'I am a frontend developer, but I am planning to do serious programming and switch to backend development.' I find such views surprising. To me, frontend engineering is much harder than working in the backend. In the backend, you are in full control of the runtime environment. However, when you write for the frontend, you need to consider various browser versions, loading times, and other client-machine-related factors. When I think of frontend applications such as three.js and online office editing applications, I can imagine the complexity of those applications. To sum up, frontend coding is real engineering. It can be as complex and serious as we want it to be.

Key takeaways

In conclusion, here are my key recommendations for the frontend coding phase:

  • Apply software design best practices.
  • Follow the DRY (Don’t Repeat Yourself) principle.
  • Design loosely-coupled components.
  • Use a well-established JavaScript framework.
  • Study the features of the chosen framework thoroughly.
  • Avoid writing your own code for functionality that already exists in the framework.
  • Be creative and combine functions to achieve your desired results.
  • Use AI creatively and extensively to assist with coding, liberate yourself from the yoke of syntax memorization.

The full code for this article is available at this repository.

With our frontend code in place, we are now ready to proceed with training our machine learning model. See you in Part 4.

  • #chatbot
  • #frontend
  • #coding
  • #css
  • #tailwindcss
  • #angular
  • #javascript

Hello! How can I help you today?

Virtual Chat
  • Hello! My name is VirtuBot. I am a virtual assistant representing Nazar. You can ask me questions as if I am Nazar.
    11:24 PM
    Tell me about yourself?
Powered by NazarAI