Built text summarization application to summarize a web page with Angular

RMAG news

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

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 {}

// 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

// 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

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

// 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

// 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

// 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

// summarization-result.interface.ts

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

// line-break.pipe.ts

@Pipe({
name: lineBreak,
standalone: true
})
export class LineBreakPipe implements PipeTransform {
transform(value: string): string {
return value.replace(/(?:rn|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.

// 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

// 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

// 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

// .dockerignore

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

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”]

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-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.

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