DEV Community

paulmojicatech
paulmojicatech

Posted on

Integrate OpenAI Inside of Angular App

Overview

Generative AI is all the rage today. It seems we can't go a day without hearing about AI, especially in the tech industry. OpenAI is a big reason why. OpenAI provides an API to leverage their trained models. One of the most popular access they provide is access to their LLMs (large language models).

What are we doing?

In this article, we will show how we can utilize OpenAI to create a chat bot that you can ask up to 3 questions and it will generate an answer for each question. We call the application Chat Genie.

The code for this is located on Github.

What are we NOT doing?

This app will use Angular and NgRx. We will show and explain the code, but we will not go deep into explaining NgRx.

OpenAI Mechanics

OpenAI allows you to call an API for their text generation service. The documentation is located here. With this API, you can string together questions to continue to build context. In our application, we allow up to 3 questions to be strung together.

Please note: OpenAI bills based on tokens. Tokens are the words and / or phrases that are part of the request.

Let's Get To It

  • src/app/app.component.ts
import { NgClass } from '@angular/common';
import { Component, computed, ElementRef, inject, ViewChild } from '@angular/core';
import { Store } from '@ngrx/store';
import { v4 } from 'uuid';
import { OpenAIHttpPostRequest } from './model/message.interface';
import { addMessage, resetMessages } from './ngrx/actions/messages.action';
import { selectMessages, selectRequest } from './ngrx/selector/messages.selector';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [NgClass],
  template: `
    <section class="full-height">
      <header>
        <span>Chat Genie</span>
      </header>
      <div class="messages-container">
        @for (message of messagesS(); track $index) {
          <div class="messages" [ngClass]="{'user': message.role === 'user', 'openAI': message.role === 'openAI'}">
            {{message.content}}
          </div>
        }
      </div>
      <footer>
        <div class="action-container">
          <input #messageInput type="text" placeholder="Enter your message"/>
          <button [disabled]="isSendDisabled()" class="primary" (click)="addMessage(messageInput.value)">Send</button>
          <button class="default" (click)="resetQuestions()">Reset</button>
        </div>
      </footer>
    </section>
  `,
  styles: `
  .full-height {
      height: 100vh;
    }

    header {
      height: 3rem;
      background-color: #2563eb;
      color: white;
      display: flex;
      justify-content: center;
      align-items: center;
      font-size: 2rem;
    }

    button {
      padding: .5rem;
      text-align: center;
      margin-left: .5rem;
      border-radius: 8px;
      color: white;
      border: none;

      &:disabled {
        opacity: 0.5;
      }
      &.primary {
        background-color: #2563eb;
      }
      &.default {
        background-color: #a8a29e;
        color: #111827;
      }
    }

    .messages-container {
      height: calc(100vh - 9rem); // header + footer + margin-top
      overflow-y: auto;
      display: flex;
      flex-direction: column;
      margin: 1rem 1rem 0 1rem;
      .messages {
        border-radius: 8px;
        padding: 1.5rem;
        width: 20rem;
        margin-bottom: 1rem;
        text-align: center;

        &.user {
          align-self: flex-start;
          background-color: #fcd34d;
        }
        &.openAI {
          align-self: flex-end;
          background-color: #bbf7d0;
        }
      }
    }

    footer {
      position: sticky;
      bottom: 0;
      height: 5rem;
      justify-self: center;
      .action-container {
        display: inline-flex;
        height: 50%;
        justify-content: center;
        input {
          width: 50vw;
          border-radius: 8px;
        }
      }
    }
  `
})
export class AppComponent {

  @ViewChild('messageInput') messageInput!: ElementRef<HTMLInputElement>;

  private _store = inject(Store);

  messagesS = this._store.selectSignal(selectMessages);

  isSendDisabled = computed(() => {
    const messages = this._store.selectSignal(selectMessages)();
    return messages.some(message => message.isProcessing) || messages.length > 6;
  });

  addMessage(message: string) {
    const storeRequest = this._store.selectSignal(selectRequest)();
    let messages: {
      role: string;
      content: string;
    }[] = [];
    if (!storeRequest) {
      messages = [{ role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: message }]
    } else {
      messages = [...storeRequest.messages, { role: 'user', content: message }];

    }
    const requestBody: OpenAIHttpPostRequest = {
      appId: v4(),
      model: 'gpt-3.5-turbo',
      messages,
      temperature: 0.7
    };
    this._store.dispatch(addMessage(requestBody));
    this.messageInput.nativeElement.value = '';
  }

  resetQuestions(): void {
    this._store.dispatch(resetMessages());
  }
}

Enter fullscreen mode Exit fullscreen mode

This is the component the shows messages. When a user hits the Send button, an action is dispatched that will kick off the request.

  • src/app/ngrx/messages.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { OpenAiHttpService } from '../../services/open-ai-http.service';
import { addMessage, addMessageSuccess, openAIError } from '../actions/messages.action';
import { catchError, filter, map, switchMap, withLatestFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { selectMessages } from '../selector/messages.selector';
import { OpenAIResponse } from '../../model/message.interface';

@Injectable()
export class MessagesEffects {
  private _actions$ = inject(Actions);
  private _openAiHttpService = inject(OpenAiHttpService);
  private _store = inject(Store);

  addMessage$ = createEffect(
    () => this._actions$.pipe(
      ofType(addMessage),
      withLatestFrom(this._store.select(selectMessages)),
      filter(([action, messages]) => {
        return messages.length <= 6;
      }),
      switchMap(([action]) => this._openAiHttpService.getOpenAiResponse(action.requestBody).pipe(
        map(response => addMessageSuccess({ response })),
        catchError(error => ([openAIError({ error })]))
      ))
    )
  );

  addMessagesLimit$ = createEffect(
    () => this._actions$.pipe(
      ofType(addMessage),
      withLatestFrom(this._store.select(selectMessages)),
      filter(([action, messages]) => messages.length > 6),
      map(([action]) => {
        const updatedMessage: OpenAIResponse = {
          model: action.requestBody.model,
          appId: action.requestBody.appId,
          choices: [
            {
              index: 0,
              message: {
                content: 'The Great Chat Genien only answers 3 questions!',
                role: 'openAI'
              }
            }
          ]
        };
        return addMessageSuccess({ response: updatedMessage });
      })
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

This file shows 2 effects. One is to call the service that makes the HTTP request to OpenAI. This effect is called only when the number of messages in the store is 6 or less. The reason for 6 is because we allow 3 questions and each question is comprised of 2 messages, one is the user question and the second is the response from OpenAI. The second effect adds a new message that lets the user know that the limit has been hit instead of making this HTTP call to OpenAI.

  • src/app/services/open-ai-http.service.ts
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { OpenAIHttpPostRequest, OpenAIResponse } from '../model/message.interface';
import { Observable, catchError, map, throwError } from 'rxjs';
import { OPEN_AI_API_KEY } from '../secrets';

@Injectable({
  providedIn: 'root'
})
export class OpenAiHttpService {

  private _httpClient = inject(HttpClient);


  private readonly _OPEN_AI_API_URL = 'https://api.openai.com/v1/chat/completions';
  private readonly _OPEN_AI_API_KEY = OPEN_AI_API_KEY;

  getOpenAiResponse(reqBody: OpenAIHttpPostRequest): Observable<OpenAIResponse> {
    let headers = new HttpHeaders();
    headers = headers.append('Authorization', `Bearer ${this._OPEN_AI_API_KEY}`);
    headers = headers.append('Content-Type', 'application/json');
    const clonedBody = {...reqBody};
    delete clonedBody.appId;

    return this._httpClient.post<OpenAIResponse>(this._OPEN_AI_API_URL, clonedBody, {headers}).pipe(
      map(response => ({...response, appId: reqBody.appId!})),
      catchError((error: any) => {
        console.error('Error in OpenAiHttpService:', error);
        return throwError(() => error);
      })
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

The one thing to notice here is that we delete the property appId from the request body. This property is used in the reducer to map the pending request in the store to the response after the API request is successful.

  • src/app/model/message.interface.ts
export interface OpenAIHttpPostRequest {
  appId?: string;
  model: string;
  messages: {
    role: string;
    content: string;
  }[];
  temperature: number;
}

export interface OpenAIResponse {
 appId?: string;
 id?: string;
 object?: string;
 created?: Date;
 model: string;
 messages?: {role: string; content: string}[];
 choices: {
  index: number;
  message: {
    content: string;
    role: string;
  },
  logprobs?: string;
 }[];
 usage?: OpenAIUsage;
}

export interface OpenAIUsage {
  prompt_tokens: number;
  completion_tokens: number;
  total_tokens: number;
}
Enter fullscreen mode Exit fullscreen mode

This is the interface for the request and response from the OpenAI contract.

  • src/app/ngrx/reducer.messages.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { OpenAIHttpPostRequest, OpenAIResponse } from '../../model/message.interface';
import { addMessage, addMessageSuccess, resetMessages } from '../actions/messages.action';
export interface MessageState {
  request: OpenAIHttpPostRequest | undefined;
  messages: {appId?: string, role: string, content: string, isProcessing: boolean}[];
}

export const initialState: MessageState = {
  request: undefined,
  messages: []
};

export const messageReducer = createReducer(
  initialState,
  on(
    addMessage,
    (state, { requestBody }) => {
      const lastQuestionIndex = requestBody.messages.length - 1;
      const newMessage: {role: string, content: string, isProcessing: boolean} = {
        role: requestBody.messages[lastQuestionIndex].role,
        content: requestBody.messages[lastQuestionIndex].content,
        isProcessing: false
      };
      return {
        ...state,
        messages: [...state.messages, newMessage, {appId: requestBody.appId!, role: 'openAI', content: '...', isProcessing: true}],
        request: requestBody
      }
    }
  ),
  on(
    addMessageSuccess,
    (state, { response }) => {
      const foundQuestionIndex = state.messages.findIndex((message) => message.appId === response.appId);
      const newMessage: {appId: string, role: string, content: string, isProcessing: boolean} = {
        appId: response.appId!,
        role: 'openAI',
        content: response.choices[0].message.content,
        isProcessing: false
      };
      const updatedMessages = structuredClone(state.messages);
      updatedMessages[foundQuestionIndex] = newMessage;
      return {
        ...state,
        messages: updatedMessages
      };
    }
  ),
  on(
    resetMessages,
    (state) => {
      return initialState;
    }
  )
);
Enter fullscreen mode Exit fullscreen mode

This is what updates the store that the app.component.ts listens to. When the addMessage action is dispatched, we update the messages value to append the new request with the 'user' role item and set the temporary 'openAI' role with a ... value. After the success message, we switch the ... value to the response from OpenAI.

That's All Folks

I hope this article was helpful. All the code is posted on my Github. The only thing needed is to create src/app/secrets.ts to include your own OPEN_AI_API_KEY.

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Explore a sea of insights with this enlightening post, highly esteemed within the nurturing DEV Community. Coders of all stripes are invited to participate and contribute to our shared knowledge.

Expressing gratitude with a simple "thank you" can make a big impact. Leave your thanks in the comments!

On DEV, exchanging ideas smooths our way and strengthens our community bonds. Found this useful? A quick note of thanks to the author can mean a lot.

Okay