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.
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 the 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 consists 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 start loading and update 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 full stack text translation application. I hope you like the content and continue to follow my learning experience in Angular, NestJS, and other technologies.
Top comments (0)