Built text summarization application to summarize a web page with Angular

Connie Leung - May 28 - - Dev Community

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

ng new ng-text-summarization-app
Enter fullscreen mode Exit fullscreen mode

Create a shell component

// 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 {}
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode

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

// 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'
  }
];
Enter fullscreen mode Exit fullscreen mode

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

// 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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>();
}
Enter fullscreen mode Exit fullscreen mode

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

Implement the Paragraph Summary component

// 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());
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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

// 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());
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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

// summarization-result.interface.ts

export interface SummarizationResult {
    url: string;
    text: string;
}
Enter fullscreen mode Exit fullscreen mode
// 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/>');
  }
}
Enter fullscreen mode Exit fullscreen mode

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

// 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[]>();
}
Enter fullscreen mode Exit fullscreen mode

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

Add a new service to call the backend

// config.json

{
    "url": "http://localhost:3000"
}
Enter fullscreen mode Exit fullscreen mode

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

// 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`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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

// .dockerignore

.git
.gitignore
node_modules/
dist/
Dockerfile
.dockerignore
npm-debug.log
Enter fullscreen mode Exit fullscreen mode

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

# 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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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-text-summarization-app
      dockerfile: Dockerfile
    depends_on:
      - backend
    ports:
      - "${WEB_PORT}:${WEB_PORT}"
    networks:
      - ai
    restart: unless-stopped
networks:
  ai:
Enter fullscreen mode Exit fullscreen mode

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.

docker-compose up
Enter fullscreen mode Exit fullscreen mode

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .