ngx-transformers
v0.4.0
Published
Run Hugging Face Transformers.js models in Angular - on-device ML with a signals API: text classification, zero-shot classification, embeddings, semantic search, translation, Whisper speech-to-text and small LLMs with streaming, in a Web Worker if you lik
Maintainers
Readme
ngx-transformers
Run Hugging Face Transformers.js models in Angular - on-device ML with a signals API. Text classification, zero-shot classification, sentence embeddings, semantic search, translation, Whisper speech-to-text and small LLMs with streaming, executed entirely in the browser (in a Web Worker if you like): no server, no API key, works offline once the model is cached.
Documentation · Demo app · Storybook - the demos load real models in your browser.
Why
Transformers.js has a React tutorial and hooks ecosystem - Angular has nothing. This library closes that gap with idiomatic Angular: lazily-loaded pipelines wrapped in signals, DI-friendly configuration, automatic cleanup with the owning component, and a drop-in progress component for the model download.
Install
npm i ngx-transformers @huggingface/transformers@huggingface/transformers (v4) is a peer dependency. Angular >= 22.
Quick start
import { Component, signal } from '@angular/core';
import { createTextClassifier, ModelProgressComponent } from 'ngx-transformers';
@Component({
imports: [ModelProgressComponent],
template: `
<textarea #box></textarea>
<button (click)="analyze(box.value)" [disabled]="classifier.busy()">Analyze</button>
<ngx-model-progress [status]="classifier.status()" [progress]="classifier.progress()" />
@if (label(); as l) { <strong>{{ l }}</strong> }
`,
})
export class SentimentComponent {
readonly classifier = createTextClassifier(); // no download yet - lazy
readonly label = signal<string | null>(null);
async analyze(text: string) {
const [top] = await this.classifier.classify(text); // downloads model on first call
this.label.set(`${top.label} ${(top.score * 100).toFixed(1)}%`);
}
}The model downloads on the first classify() call (with progress reported through the progress signal), is cached by the browser, and is disposed automatically when the component is destroyed.
Semantic search
import { createTextEmbedder } from 'ngx-transformers';
readonly embedder = createTextEmbedder(); // all-MiniLM-L6-v2, ~23 MB q8
const ranked = await this.embedder.rank('how do I make my app faster?', docs);
// [{ text: 'Use trackBy and virtual scrolling...', score: 0.28, index: 2 }, ...]
const score = await this.embedder.similarity('car', 'automobile'); // ~0.8
const vectors = await this.embedder.embed(['one', 'two']); // number[][]Speech to text (v0.2)
Whisper, fully in the browser - the audio never leaves the device:
import { createMicRecorder, createSpeechRecognizer } from 'ngx-transformers';
readonly whisper = createSpeechRecognizer(); // whisper-tiny.en, ~41 MB q4
readonly mic = createMicRecorder();
// a URL, File/Blob, ArrayBuffer, or 16 kHz Float32Array:
const { text, chunks } = await this.whisper.transcribe(fileOrUrl, { returnTimestamps: true });
// dictation:
async toggle() {
if (this.mic.recording()) {
const audio = await this.mic.stop(); // encoded Blob
const { text } = await this.whisper.transcribe(audio); // decoded + resampled for you
} else {
await this.mic.start(); // asks for mic permission
}
}MicRecorder exposes recording, seconds, and error signals for the UI. decodeAudio(blob) is exported separately if you want the 16 kHz mono Float32Array yourself.
Note: the recognizer defaults to
dtype: 'q4'- q8 Whisper decoders currently fail on the v4 WASM runtime (transformers.js#1707). Multilingual checkpoints (e.g.onnx-community/whisper-tiny) acceptlanguageandtask: 'translate'options.
Zero-shot classification
Score labels you name against a text, no fine-tuning - an NLI model judges whether "This example is {label}." follows from the text:
import { createZeroShotClassifier } from 'ngx-transformers';
readonly classifier = createZeroShotClassifier(); // mobilebert-uncased-mnli, ~26 MB q8
const scored = await this.classifier.classify(ticketText, ['billing', 'bug report', 'feature request']);
// [{ label: 'bug report', score: 0.91 }, { label: 'billing', score: 0.06 }, ...] - scores sum to 1
// several labels can apply at once - score each on its own:
const tags = await this.classifier.classify(text, ['food', 'repair', 'politics'], {
multiLabel: true,
hypothesisTemplate: 'This text is about {}.',
});Xenova/distilbert-base-uncased-mnli (~80 MB) is the more accurate drop-in: createZeroShotClassifier({ model: '...' }).
Translation
Marian opus-mt checkpoints, one per language pair (~105 MB q8 each), resolved from the pair and loaded on demand:
import { createTranslator } from 'ngx-transformers';
readonly translator = createTranslator({ from: 'en', to: 'ru' }); // Xenova/opus-mt-en-ru
const russian = await this.translator.translate('The model runs entirely in the browser.');
const german = await this.translator.translate('Good morning.', { to: 'de' }); // opus-mt-en-de, its own downloadThe translator's status / progress signals follow the model used by the latest call, so one <ngx-model-progress> covers every pair; handleFor({ to: 'de' }) returns the underlying PipelineHandle when you want one per pair. Override the checkpoint for a pair globally with provideTransformers({ translationModels: { 'en-ru': 'my-org/en-ru-tiny' } }).
Multilingual checkpoints (NLLB, M2M100) serve every pair from one model and take the codes per call:
readonly translator = createTranslator({ model: 'Xenova/nllb-200-distilled-600M' });
await this.translator.translate(text, { from: 'eng_Latn', to: 'tgk_Cyrl' }); // src_lang / tgt_lang forwardedText generation
A small language model, streamed token by token:
import { createTextGenerator } from 'ngx-transformers';
readonly generator = createTextGenerator(); // SmolLM2-135M-Instruct, ~100 MB q4
const reply = await this.generator.generate(
[{ role: 'user', content: 'Explain signals in Angular in two sentences.' }],
{ maxNewTokens: 120, onToken: (piece) => console.log(piece) },
);
// generator.output() holds the text generated so far while the model runsReactive inference
inferenceResource() runs a handle whenever an input signal changes, as an Angular resource with debounce and latest-wins:
readonly text = signal('');
readonly sentiment = inferenceResource({
input: () => this.text().trim() || undefined, // undefined: idle
run: (text) => this.classifier.classify(text),
debounceMs: 300,
});
// template: sentiment.value(), sentiment.isLoading(), sentiment.error()Web Worker
One provider moves every pipeline off the main thread; handles and signals work unchanged:
// transformers.worker.ts
/// <reference lib="webworker" />
import { runTransformersWorker } from 'ngx-transformers/worker';
runTransformersWorker();
// app.config.ts
provideTransformersWorker(
() => new Worker(new URL('./transformers.worker', import.meta.url), { type: 'module' }),
);ngx-transformers/worker has no Angular dependency, so the worker chunk stays tiny; @huggingface/transformers loads inside the worker only.
Any pipeline
createPipeline() exposes the full Transformers.js task surface with the same signal lifecycle:
import { createPipeline } from 'ngx-transformers';
readonly summarizer = createPipeline<string, { summary_text: string }[]>({
task: 'summarization',
model: 'Xenova/distilbart-cnn-6-6',
});
const [out] = await this.summarizer.run(longText);Global configuration
import { provideTransformers } from 'ngx-transformers';
bootstrapApplication(App, {
providers: [provideTransformers({ device: 'webgpu', dtype: 'q8' })],
});Per-pipeline device/dtype/options win over the global config. translationModels maps a "from-to" pair to a checkpoint for createTranslator().
Every handle gets its pipeline from the PIPELINE_FACTORY injection token. Provide your own to add options or logging, route work to a Web Worker, or stub models in tests. createDefaultPipelineFactory() is the default; it lazy-imports @huggingface/transformers on the first pipeline, so wrapping it keeps the initial bundle small:
import { PIPELINE_FACTORY, createDefaultPipelineFactory, type PipelineFactory } from 'ngx-transformers';
const base = createDefaultPipelineFactory();
const logging: PipelineFactory = (task, model, options) => {
console.log('loading', task, model);
return base(task, model, options);
};
bootstrapApplication(App, {
providers: [{ provide: PIPELINE_FACTORY, useValue: logging }],
});API
Handles
| Export | What it is |
|---|---|
| createPipeline(request) | Generic PipelineHandle for any Transformers.js task |
| createTextClassifier(options?) | TextClassifier - sentiment/classification, classify(text, topK?) |
| createTextEmbedder(options?) | TextEmbedder - embed(), similarity(), rank() |
| createSpeechRecognizer(options?) | SpeechRecognizer - transcribe(audio, options?) with timestamps |
| createZeroShotClassifier(options?) | ZeroShotClassifier - classify(text, labels, { multiLabel?, hypothesisTemplate? }) |
| createTranslator(options?) | Translator - translate(text, { from?, to? }), one model per pair, handleFor(pair) |
| createTextGenerator(options?) | TextGenerator - generate(prompt, { maxNewTokens?, onToken?, ... }), streamed into output |
| inferenceResource({ input, run, debounceMs? }) | An Angular resource that re-runs a handle when an input signal changes |
| provideTransformersWorker(createWorker) | Run every pipeline in a Web Worker (ngx-transformers/worker runs the worker side) |
| createMicRecorder(deps?) | MicRecorder - mic capture with recording/seconds/error signals |
| cosineSimilarity(a, b) / decodeAudio(blob) | Standalone helpers |
| PIPELINE_FACTORY / createDefaultPipelineFactory() | Swap or wrap how pipelines are created |
All create* functions must run in an injection context (field initializer, constructor, or runInInjectionContext); handles are destroyed with the surrounding component (the model is released and later calls reject). Call dispose() yourself to free a model early and load it again later.
PipelineHandle signals
| Signal | Type | Meaning |
|---|---|---|
| status | 'idle' \| 'loading' \| 'ready' \| 'busy' \| 'error' | Lifecycle; error only from a failed load, retryable |
| progress | ModelProgress \| null | Download progress: the file reported last plus overall over every file of the model |
| error | unknown | The load error, if any |
| runError | unknown | The error of the most recent run, if it failed |
| ready / busy | boolean (computed) | Convenience for buttons and spinners |
<ngx-model-progress>
Status line + download bar for any handle. Inputs: status (required), progress, labels (override per-status text). Themeable via --nt-accent, --nt-ink, --nt-muted, --nt-track.
Default models
| Wrapper | Model | Size (q8) | License |
|---|---|---|---|
| createTextClassifier | Xenova/distilbert-base-uncased-finetuned-sst-2-english | ~65 MB | Apache-2.0 |
| createTextEmbedder | Xenova/all-MiniLM-L6-v2 | ~23 MB | Apache-2.0 |
| createSpeechRecognizer | onnx-community/whisper-tiny.en | ~41 MB (q4) | Apache-2.0 |
| createZeroShotClassifier | Xenova/mobilebert-uncased-mnli | ~26 MB | unlisted on the Hub (base MobileBERT: Apache-2.0) |
| createTranslator | Xenova/opus-mt-{from}-{to} | ~105 MB per pair | varies per pair (Apache-2.0 or CC-BY-4.0) |
| createTextGenerator | HuggingFaceTB/SmolLM2-135M-Instruct | ~100 MB (q4) | Apache-2.0 |
Swap any compatible checkpoint via { model: '...' }. Check the license of the model you ship.
SSR
Model loading is browser-only (WASM/WebGPU). Creating handles is safe on the server - nothing downloads until load()/run() - but call those only in browser code paths.
Device selection
WebGPU is several times faster than WASM where it works, but navigator.gpu existing is not enough: requestAdapter() can still hand back nothing on a machine without a usable GPU. hasWebGpu() runs that probe once (cached) and detectDevice() turns the answer into a device name; both resolve to the WASM answer on the server.
import { createTextClassifier, detectDevice } from 'ngx-transformers';
const device = await detectDevice(); // 'webgpu' | 'wasm'
const classifier = createTextClassifier({ device });Or let every handle decide for itself:
bootstrapApplication(App, {
providers: [provideTransformers({ autoDevice: true, dtype: 'q8' })],
});With autoDevice, a handle that sets no device (or 'auto') probes WebGPU on its first load and uses it when available, else WASM. An explicit device on the handle or in the global config still wins. resetDeviceDetection() clears the cached answer.
License
MIT (c) Muzaffar Qosimov
