Built text summarization application to summarize a web page with Angular
Tags: angular, tutorial, webdev, generativeai
Published: 2024-05-28 02:23:40

##Introduction

I am an Angular and NestJS developer interested in Angular, Vue, NestJS, and Generate AI. Blog sites such as dev.to and hashnode have many new blog posts daily, and it is difficult for me to pick out the good ones to read and improve my knowledge of web development and generative AI. Therefore, I built a text summarization application to call my NestJS backend to provide a summary of a technology blog post. When the summary sounds interesting, I read the rest of the post. Otherwise, I stop and find other ones to read. The full-stack generative application helps me decide whether to read a technology blog post.

###Create a new Angular Project
```bash
ng new ng-text-summarization-app
```

###Create a shell component
```typescript
// summarization-shell.component.ts

@Component({
  selector: 'app-summarization-shell',
  standalone: true,
  imports: [RouterOutlet, SummarizationNavBarComponent, LargeLanguageModelUsedComponent],
  template: `
    <div class="grid">
      <app-summarization-nav-bar class="nav-bar" />
      <div class="main">
        <router-outlet></router-outlet>
      </div>
      <app-large-language-model-used class="model-used" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummarizationShellComponent {}
```

```typescript
// summarization-nav-bar.component.ts

@Component({
  selector: 'app-summarization-nav-bar',
  standalone: true,
  imports: [RouterLink, RouterLinkActive],
  template: `
    <h3>Main Menu</h3>
    <ul>
      <li>
        <a routerLink="/summarization-page"  routerLinkActive="active-link">Text Summarization</a>
      </li>
      <li>
        <a routerLink="/summarization-as-list"  routerLinkActive="active-link">Bullet Points Summarization</a>
      </li>
    </ul>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummarizationNavBarComponent {}
```

The shell component renders a navigation bar for a user to navigate to different text summarization components. The first component provides a paragraph summary, while the second one returns a bullet point summary.

###Define routes to route different text summarization components

```typescript
// app.route.ts

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'summarization-page',
    loadComponent: () => import('./summarization/summarize-paragraph-page/summarize-paragraph-page.component')
      .then((m) => m.SummarizeParagraphComponent),
    title: 'Text Summarization',
  },
  {
    path: 'summarization-as-list',
    loadComponent: () => import('./summarization/summarize-bullet-point/summarize-bullet-point.component')
      .then((m) => m.SummarizeBulletPointComponent),
    title: 'Bullet Points Summarization',
  },
  {
    path: '',
    pathMatch: 'full',
    redirectTo: 'summarization-page',
  },
  {
    path: '**',
    redirectTo: 'summarization-page'
  }
];
```

When a user visits `/summarization-page`, the page allows the user to enter a web page URL and provides a paragraph summary. When a user visits `/summarization-as-list`, the page allows the user to enter a web page URL and get back a bullet point summary.

###Web Page input box

```typescript
// webpage-input-box.component.ts

@Component({
  selector: 'app-webpage-input-box',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="container">
      <div class="topic">
        <label for="topic">
          <span>Topic: </span>
          <input id="topic" name="topic" type="text" [(ngModel)]="topic" />
        </label>
      </div>
      <div>
        <label for="url">
          <span>Url: </span>
          <input id="url" name="url" type="text" [(ngModel)]="text" />
        </label>
        <button (click)="pageUrl.emit({ url: vm.url, topic: vm.topic })" [disabled]="vm.isLoading">{{ vm.buttonText }}</button>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WebpageInputBoxComponent {
  topic = signal('');
  text = signal('');
  isLoading = input(false);

  viewModel = computed<WebpageInputBoxModel>(() => {
    return {
      topic: this.topic(),
      url: this.text(),
      isLoading: this.isLoading(),
      buttonText: this.isLoading() ? 'Summarizing...' : 'Summarize!',
    }
  });

  pageUrl = output<SubmittedPage>();

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

This is a component that accepts a web page URL and an optional topic hint.  When a prompt includes a topic hint, Gemini provides a more accurate summary than without. 

```typescript
// web-page-input-container.component.ts

@Component({
  selector: 'app-web-page-input-container',
  standalone: true,
  imports: [WebpageInputBoxComponent],
  template: `
    <h2>{{ title }}</h2>
    <div class="summarization">
      <app-webpage-input-box [isLoading]="isLoading()" (pageUrl)="submittedPage.emit($event)" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WebPageInputContainerComponent {
  isLoading = input.required<boolean>();
  title = inject(new HostAttributeToken('title'), { optional: true }) || 'Ng Text Summarization Demo';
  submittedPage = output<SubmittedPage>();
}
```

`WebPageInputContainerComponent` emits the web page URL and topic hint to the enclosing summarization component.

###Implement the Paragraph Summary component

```typescript
// summarize-paragraph-page.component.ts

@Component({
  selector: 'app-summarize-paragraph-page',
  standalone: true,
  imports: [SummarizeResultsComponent, WebPageInputContainerComponent],
  template: `
    <div class="container">
      <app-web-page-input-container title="Ng Text Summarization Demo" [isLoading]="isLoading()"
      />
      <app-summarize-results [results]="summary()" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummarizeParagraphComponent {
  isLoading = signal(false);
  inputContainer = viewChild.required(WebPageInputContainerComponent);
  summarizationService = inject(SummarizationService);

  summary = toSignal(
    this.summarizationService.result$
      .pipe(
        scan((acc, translation) => ([...acc, translation]), [] as SummarizationResult[]),
        tap(() => this.isLoading.set(false)),
      ),
    { initialValue: [] as SummarizationResult[] }
  );

  constructor() {
    effect((cleanUp) => {
      const sub = outputToObservable(this.inputContainer().submittedPage)
        .pipe(filter((parameter) => !!parameter.url))
        .subscribe(({ url, topic = '' }) => {
          this.isLoading.set(true);
          this.summarizationService.summarizeText({
            url,
            topic,
          });
        });

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

`SummarizeParagraphComponent` uses `viewchild` to access the submitted URL and topic hint. Then, the component executes `SummarizationService.summarizeText` to send a request to the backend to ask Gemini to summarize the web page. `SummarizeResultsComponent` is responsible for rendering the summary in a list.

###Implement the Bullet Point Summary component

```typescript
// summarize-bullet-point.component.ts

@Component({
  selector: 'app-summarize-as-list',
  standalone: true,
  imports: [SummarizeResultsComponent, WebPageInputContainerComponent],
  template: `
    <div class="container">
      <app-web-page-input-container title="Ng Bullet Point List Demo" [isLoading]="isLoading()"
      />
      <app-summarize-results [results]="summary()" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummarizeBulletPointComponent {
  isLoading = signal(false);
  inputContainer = viewChild.required(WebPageInputContainerComponent);
  summarizationService = inject(SummarizationService);

  summary = toSignal(
    this.summarizationService.bulletPointList$
      .pipe(
        scan((acc, translation) => ([...acc, translation]), [] as SummarizationResult[]),
        tap(() => this.isLoading.set(false)),
      ),
    { initialValue: [] as SummarizationResult[] }
  );

  constructor() {
    effect((cleanUp) => {
      const sub = outputToObservable(this.inputContainer().submittedPage)
        .pipe(filter((parameter) => !!parameter.url))
        .subscribe(({ url, topic = '' }) => {
          this.isLoading.set(true);
          this.summarizationService.summarizeToBulletPoints({
            url,
            topic,
          });
        });

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

`SummarizeBulletPointComponent` also uses `viewchild` to access the submitted URL and topic hint. Then, the component executes `SummarizationService.summarizeToBulletPoints` to send a request to the backend to ask Gemini to summarize the web page. `SummarizeResultsComponent` is responsible for rendering the bullet point summary.

###List the summary

```typescript
// summarization-result.interface.ts

export interface SummarizationResult {
    url: string;
    text: string;
}
```

```typescript
// 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 &lt;br/&gt; tag. Then, the component can display multiple lines nicely. 

```typescript
// summarize-results.component.ts

@Component({
  selector: 'app-summarize-results',
  standalone: true,
  imports: [LineBreakPipe],
  template: `
    <h3>Text Summarization: </h3>
    @if (results().length > 0) {
    <div class="list">
      @for (item of results(); track item) {
        <div>
          <span>Url: </span>
          <p [innerHTML]="item.url"></p>
        </div>
        <div>
          <span>Result: </span>
          <p [innerHTML]="item.text |  lineBreak"></p>
        </div>
        <hr />
      }
    </div>
    } @else {
      <p>No summarization</p>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummarizeResultsComponent {
  results = input.required<SummarizationResult[]>();
}
```

`SummarizeResultsComponent` is a simple component that lists both the URL and the summary.

###Add a new service to call the backend

```json
// config.json

{
    "url": "http://localhost:3000"
}
```

The JSON file stores the base URL of the NestJS backend. You are freely update it to point to correct backend server

```typescript
// summarization.service.ts

function summarizeWebPage(data: Summarization) {
  return function (source: Observable<SummarizationResult>) {
    return source.pipe(
      retry(3),
      map(({ url='', text }) => ({
        url,
        text
      })),
      catchError((err) => {
        console.error(err);
        return of({
          url: data.url,
          result: 'No summarization due to error',
        });
      })
    )
  }
}

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

  private textSummarization = signal<Summarization>({
    url: '',
    topic: '',
  });

  private bulletPointsSummarization = signal<Summarization>({
    url: '',
    topic: '',
  });

  result$  = toObservable(this.textSummarization)
    .pipe(
      filter((data) => !!data.url),
      switchMap((data) =>
        this.httpService.post<SummarizationResult>(`${config.url}/summarization`, data)
          .pipe(summarizeWebPage(data))
      ),
      map((result) => result as SummarizationResult),
    );

  bulletPointList$  = toObservable(this.bulletPointsSummization)
    .pipe(
      filter((data) => !!data.url),
      switchMap((data) =>
        this.httpService.post<SummarizationResult>(`${config.url}/summarization/bullet-points`, data)
          .pipe(summarizeWebPage(data))
      ),
      map((result) => result as SummarizationResult),
    );  
  
  summarizeText(data: Summarization) {
    this.textSummarization.set(data);
  }

  summarizeToBulletPoints(data: Summarization) {
    this.bulletPointsSummarization.set(data);
  }

  getLargeLanguageModelUsed(): Observable<LargeLanguageModelUsed> {
    return this.httpService.get<LargeLanguageModelUsed>(`${config.url}/summarization/llm`);
  }
}
```

When `textSummarization` signal receives a value, the Observable requests the backend (${config.url}/summarization) to obtain the paragraph summary. The result is assigned to `result$`, which `SummarizeParagraphComponent` can access and append to the summary list subsequently.

When `bulletPointsSummarization` signal receives a value, the Observable requests the backend (${config.url}/summarization/bullet-points) to obtain the bullet point summary. The result is assigned to `bulletPointList$`, which `SummarizeBulletPointComponent` can access and append to the summary list subsequently.

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

###Dockerize the application

```text
// .dockerignore

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

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

```text
# 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.

```text
//  .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.

```text
// docker-compose.yaml

version: '3.8'

services:
  backend:
    ... backend container...
  web:
    build:
      context: ./ng-text-summarization-app
      dockerfile: Dockerfile
    depends_on:
      - backend
    ports:
      - "${WEB_PORT}:${WEB_PORT}"
    networks:
      - ai
    restart: unless-stopped
networks:
  ai:
```

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

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

```bash
docker-compose up
```

The above command starts Angular and NestJS containers, and we can try the application by typing http://localhost:4200 into the browser. 

This concludes my blog post about using Angular and Gemini API to build a full-stack text summarization application. I built the text summarization application to practice the contents of generative AI but I would like to apply the newly gained knowledge in production. I hope you like the content and continue to follow my learning experience in Angular, NestJS, Generative AI, and other technologies.

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