Build full stack text translation application with Angular and Generative AI

Reading Time: 5 minutes

Loading

Introduction

I am a language learner who learns Mandarin and Spanish in my spare time. When I discovered that text translation using LLM is possible, I wanted to leverage the strength of LangChain and Gemini 1.0 Pro model in my hobby. Therefore, I built an Angular application to make a backend request to translate texts between two languages through LLM.

let's go

Create a new Angular Project

ng new ng-genai-translation-app

Create the language selectors component

// language-selectors.component.ts

@Component({
  selector: 'app-language-selectors',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="language-selectors">
      <label for="from">
        <span>From: </span>
        <select [(ngModel)]="from">
          @for (language of languages(); track language.code) {
            <option value="{{ language.code }}">{{ language.name }}</option>
          }
        </select>
      </label>
      <label for="to">
        <span>To: </span>
        <select [(ngModel)]="to">
          @for (language of languages(); track language.code) {
            <option value="{{ language.code }}">{{ language.name }}</option>
          }
        </select>
      </label>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class LanguageSelectorsComponent {
  languages = input.required<{ code: string; name: string }[]>();
  from = model('en');
  to = model('en');
}

LanguageSelectorsComponent is a component that allows user to select the from and the to languages for the text translation. The component uses the model function for 2-way binding; therefore, the selected values are available to the signals of the parent component

Create a component to input text for translation

// translation.interface.ts

export interface TranslationBoxModel {
    text: string;
    isLoading: boolean;
    buttonText: string;
}
// translation-box.component.ts

@Component({
  selector: 'app-translation-box',
  standalone: true,
  imports: [FormsModule],
  template: `
    <textarea rows="10" [(ngModel)]="text"></textarea>
    <button (click)="translate.emit(vm.text)" [disabled]="vm.isLoading">{{ vm.buttonText }}</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TranslationBoxComponent {
  text = signal('');
  isLoading = input(false);

  viewModel = computed<TranslationBoxModel>(() => {
    return {
      text: this.text(),
      isLoading: this.isLoading(),
      buttonText: this.isLoading() ? 'Translating...' : 'Translate me!',
    }
  });

  translate = output<string>();

  get vm() {
    return this.viewModel();
  }
}

TranslationBoxModel is a view model that stores the state of the TranslationBoxComponent component. The view model is consisted of the inputted text, the dynamic button text, and the loading state of the button. When the isLoading signal input is false, the button is enabled and the text is “Translate me!”. When the signal input is true, the button is disabled and the text changes to “Translating…”. The output function, translate, emits the text to the parent component after the button click.

List the translation history

// translation-result.interface.ts

export interface TranslationResult {
    source: string;
    result: string;
}
// line-break.pipe.ts

@Pipe({
  name: 'lineBreak',
  standalone: true
})
export class LineBreakPipe implements PipeTransform {
  transform(value: string): string {
    return value.replace(/(?:\r\n|\r|\n)/g, '<br/>');
  }
}

LineBreakPipe is a pure pipe that replaces a new line character with a <br/> tag. Then, the component can display multiple lines nicely.

// translation-list.component.ts

@Component({
  selector: 'app-translation-list',
  standalone: true,
  imports: [LineBreakPipe],
  template: `
    <h3>Translation Results: </h3>
    @if (translationList().length > 0) {
    <div class="list">
      @for (item of translationList(); track item) {
        <div>
          <span>Source: </span>
          <p [innerHTML]="item.source | lineBreak"></p>
        </div>
        <div>
          <span>Result: </span>
          <p [innerHTML]="item.result | lineBreak"></p>
        </div>
        <hr />
      }
    </div>
    } @else {
      <p>No translation</p>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TranslationListComponent {
  translationList = input.required<TranslationResult[]>();
}

TranslationListComponent is a simple component that lists both the original and the translated texts.

Put the components together

// translation.interface.ts

export interface Translate {
    text: string;
    from: string;
    to: string;
    isValid: boolean;
}

export interface TranslationModel {
    from: string;
    to: string;
    isLoading: boolean;
    translationList: TranslationResult[];
}
// translator.component.ts

@Component({
  selector: 'app-translator',
  standalone: true,
  imports: [LanguageSelectorsComponent, TranslationListComponent, TranslationBoxComponent],
  template: `
    <div class="container">
      <h2>Ng Text Translation Demo</h2>
      <div class="translator">
        <app-language-selectors [languages]="languages" [(from)]="fromLanguage" [(to)]="toLanguage" />
        <app-translation-box #box [isLoading]="vm.isLoading" />
      </div>
      <app-translation-list [translationList]="vm.translationList" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TranslatorComponent {
  fromLanguage = signal('en');
  toLanguage = signal('en');
  isLoading = signal(false);
  box = viewChild.required(TranslationBoxComponent);

  translatorService = inject(TranslatorService);
  languages = this.translatorService.getSupportedLanguages();
  translationList = toSignal( 
    this.translatorService.translation$
      .pipe(
        scan((acc, translation) => ([...acc, translation]), [] as TranslationResult[]),
        tap(() => this.isLoading.set(false)),
      ), 
    { initialValue: [] as TranslationResult[] }
  );

  viewModel = computed<TranslationModel>(() => {
    return {
      from: this.fromLanguage(),
      to: this.toLanguage(),
      isLoading: this.isLoading(),
      translationList: this.translationList(),
    }
  });

  get vm() {
    return this.viewModel();
  }

  constructor() {
    effect((cleanUp) => {
      const sub = outputToObservable(this.box().translate)
        .subscribe((text) => {
          this.isLoading.set(true);
          this.translatorService.translateText({
            text,
            from: this.vm.from,
            to: this.vm.to,
            isValid: !!text && !!this.vm.from && !!this.vm.to
          });
        });

      cleanUp(() => sub.unsubscribe());
    });
  }
}

When users click the button and emit the text, the codes in the effect callback starts loading and updates the translateText signal in the TranslationService. After the translation returns, loading stops and the result is appended to the translation list to display.

Add a new service to call the backend

// config.json

{
    "url": "http://localhost:3000",
    "languages": [
        {
          "code": "en",
          "name": "English"
        },
        {
          "code": "es",
          "name": "Spanish"
        },
        {
          "code": "ja",
          "name": "Japanese"
        },
        {
          "code": "vi",
          "name": "Vietnamese"
        },
        {
          "code": "zh-Hant",
          "name": "Tranditional Chinese"
        },
        {
          "code": "zh-Hans",
          "name": "Simplified Chinese"
        }
      ]
}

The JSON files stores the base URL and the supported languages. I chose these languages because I speak Cantonese, English, Mandarin, and Spanish. I threw in Vietnamese and Japanese because I am planning to travel there this year.

// translation.service.ts

import config from '~assets/config.json';
// omit the import statements due to brevity

@Injectable({
  providedIn: 'root'
})
export class TranslatorService {
  private readonly httpService = inject(HttpClient);

  private translate = signal<Translate>({
    text: '',
    from: '',
    to: '',
    isValid: false,
  });

  translation$  = toObservable(this.translate)
    .pipe(
      filter(({ isValid }) => isValid),
      map(({ text, from, to }) => ({ text, srcLanguageCode: from, targetLanguageCode: to })),
      switchMap((data) => 
        this.httpService.post<{ text: string }>(`${config.url}/translator`, data)
          .pipe(
            retry(3),
            map(({ text: result }) => ({ 
              source: data.text,
              result
            })),
            catchError((err) => {
              console.error(err);
              return of({
                source: data.text,
                result: 'No translation due to error',
              });
            })
          )
      ),
      map((result) => result as TranslationResult),
    );

  getSupportedLanguages() {
    return config.languages;
  }

  translateText(data: Translate) {
    this.translate.set(data);
  }
}

When translate signal receives a value, the Observable makes a request to the backend (${config.url}/translator) to obtain the translation. The result is assigned to translation$ that TranslatorComponent can access and append to the translation list subsequently.

Let’s create an Angular docker image and run the Angular application in the docker container.

Dockerize the application

// .dockerignore

.git
.gitignore
node_modules/
dist/
Dockerfile
.dockerignore
npm-debug.log

Create a .dockerignore file for Docker to ignore some files and directories.

// Dockerfile

# Use an official Node.js runtime as the base image
FROM node:20-alpine

# Set the working directory in the container
WORKDIR /usr/src/app

# Copy package.json and package-lock.json to the working directory
COPY package*.json /usr/src/app

RUN npm install -g @angular/cli

# Install the dependencies
RUN npm install

# Copy the rest of the application code to the working directory
COPY . .

# Expose a port (if your application listens on a specific port)
EXPOSE 4200

# Define the command to run your application
CMD [ "ng", "serve", "--host", "0.0.0.0"]

I added the Dockerfile that installs the dependencies and starts the application at port 4200. CMD ["ng", "serve", "--host", "0.0.0.0"] exposes the localhost of the docker to the external machine.

//  .env.docker.example

...NestJS environment variables...
WEB_PORT=4200

.env.docker.example stores the WEB_PORT environment variable that is the port number of the Angular application.

// docker-compose.yaml

version: '3.8'

services:
  backend:
    ... backend container...
  web:
    build:
      context: ./ng-genai-translation-app
      dockerfile: Dockerfile
    depends_on:
      - backend
    ports:
      - "${WEB_PORT}:${WEB_PORT}"
    networks:
      - ai
    restart: always

networks:
  ai:

In the docker compose yaml file, I added a web container that depends on the backend container. The Docker file is in the ng-genai-translation-app repository and Docker Compose uses it to build the Angular image and start the container.

I added the docker-compose.yaml to the root folder, which was responsible for creating the Angular application container.

This concludes my blog post about using Angular and Gemini API to build a fullstack text translation application. I hope you like the content and continue to follow my learning experience in Angular, NestJS, and other technologies.

Resources:

  1. Github Repo: https://github.com/railsstudent/fullstack-genai-translation/tree/main/ng-genai-translation-app
  2. Build Angular app in Docker: https://dev.to/rodrigokamada/creating-and-running-an-angular-application-in-a-docker-container-40mk