ngx-transformers 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,51 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, signal, computed, inject, DestroyRef, input, ChangeDetectionStrategy, Component } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, DestroyRef, signal, computed, Injector, resource, untracked, effect, input, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { takeOnToken, createWorkerPipelineFactory } from 'ngx-transformers/worker';
3
4
 
4
- const defaultPipelineFactory = async (task, model, options) => {
5
- const { pipeline } = await import('@huggingface/transformers');
6
- const pipe = await pipeline(task, model, options);
7
- return pipe;
8
- };
5
+ const importTransformers = () => import('@huggingface/transformers');
6
+ /**
7
+ * Wraps a pipeline so the `onToken` run option (TextGenerator streaming)
8
+ * becomes a Transformers.js TextStreamer on the pipeline's tokenizer.
9
+ */
10
+ function withTokenStreaming(pipe, module) {
11
+ const streaming = (async (input, ...args) => {
12
+ const { args: rest, onToken } = takeOnToken(args);
13
+ if (onToken && module.TextStreamer) {
14
+ const options = { ...rest[rest.length - 1] };
15
+ options['streamer'] = new module.TextStreamer(pipe.tokenizer, { skip_prompt: true, skip_special_tokens: true, callback_function: onToken });
16
+ rest[rest.length - 1] = options;
17
+ }
18
+ return pipe(input, ...rest);
19
+ });
20
+ streaming.dispose = () => pipe.dispose?.() ?? Promise.resolve();
21
+ return streaming;
22
+ }
23
+ /**
24
+ * The factory behind PIPELINE_FACTORY. It imports @huggingface/transformers
25
+ * lazily, on the first pipeline, so the library adds nothing to the initial
26
+ * bundle. Wrap it to add options or logging while keeping the lazy import:
27
+ *
28
+ * ```ts
29
+ * const base = createDefaultPipelineFactory();
30
+ * const factory: PipelineFactory = (task, model, options) =>
31
+ * base(task, model, { ...options, revision: 'v2' });
32
+ * providers: [{ provide: PIPELINE_FACTORY, useValue: factory }]
33
+ * ```
34
+ *
35
+ * `load` is the importer. Tests pass a stub module; apps that need to
36
+ * configure Transformers.js (`env.allowRemoteModels`, `env.localModelPath`)
37
+ * do it there, before returning the module.
38
+ */
39
+ function createDefaultPipelineFactory(load = importTransformers) {
40
+ return async (task, model, options) => {
41
+ const module = await load();
42
+ const pipe = (await module.pipeline(task, model, options));
43
+ return withTokenStreaming(pipe, module);
44
+ };
45
+ }
9
46
  const PIPELINE_FACTORY = new InjectionToken('ngx-transformers.pipeline-factory', {
10
47
  providedIn: 'root',
11
- factory: () => defaultPipelineFactory,
48
+ factory: () => createDefaultPipelineFactory(),
12
49
  });
13
50
  const NGX_TRANSFORMERS_CONFIG = new InjectionToken('ngx-transformers.config', {
14
51
  providedIn: 'root',
@@ -26,6 +63,40 @@ const NGX_TRANSFORMERS_CONFIG = new InjectionToken('ngx-transformers.config', {
26
63
  function provideTransformers(config) {
27
64
  return makeEnvironmentProviders([{ provide: NGX_TRANSFORMERS_CONFIG, useValue: config }]);
28
65
  }
66
+ /**
67
+ * Runs every pipeline in a Web Worker so inference never blocks the UI.
68
+ * The worker file imports the worker entry point:
69
+ *
70
+ * ```ts
71
+ * // transformers.worker.ts
72
+ * /// <reference lib="webworker" />
73
+ * import { runTransformersWorker } from 'ngx-transformers/worker';
74
+ * runTransformersWorker();
75
+ *
76
+ * // app.config.ts
77
+ * provideTransformersWorker(
78
+ * () => new Worker(new URL('./transformers.worker', import.meta.url), { type: 'module' }),
79
+ * )
80
+ * ```
81
+ *
82
+ * The worker is created on the first pipeline and terminated with the
83
+ * injector. Handles, signals and the wrappers work unchanged; results must
84
+ * survive structured cloning (plain objects, arrays, typed arrays and
85
+ * tensors do). A worker that fails to start rejects the calls waiting on
86
+ * it, like a failed load in-thread.
87
+ */
88
+ function provideTransformersWorker(createWorker) {
89
+ return makeEnvironmentProviders([
90
+ {
91
+ provide: PIPELINE_FACTORY,
92
+ useFactory: () => {
93
+ const factory = createWorkerPipelineFactory(createWorker);
94
+ inject(DestroyRef).onDestroy(() => factory.terminate());
95
+ return factory;
96
+ },
97
+ },
98
+ ]);
99
+ }
29
100
 
30
101
  let webGpuProbe = null;
31
102
  /**
@@ -58,6 +129,7 @@ async function probeWebGpu() {
58
129
  }
59
130
  }
60
131
 
132
+ const newSession = () => ({ pipe: null, loading: null, inFlight: 0, files: new Map() });
61
133
  /**
62
134
  * A lazily-loaded Transformers.js pipeline wrapped in signals.
63
135
  *
@@ -72,18 +144,24 @@ class PipelineHandle {
72
144
  /** idle -> loading -> ready <-> busy; error only on load failure. */
73
145
  status = signal('idle', /* @ts-ignore */
74
146
  ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
75
- /** Download progress for the file currently transferring, else null. */
147
+ /** Download progress: the file reported last, plus the total over all files. */
76
148
  progress = signal(null, /* @ts-ignore */
77
149
  ...(ngDevMode ? [{ debugName: "progress" }] : /* istanbul ignore next */ []));
150
+ /** The last load error; cleared when a load starts. */
78
151
  error = signal(null, /* @ts-ignore */
79
152
  ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
153
+ /** The error of the most recently started run, if it failed; cleared when a run starts. */
154
+ runError = signal(null, /* @ts-ignore */
155
+ ...(ngDevMode ? [{ debugName: "runError" }] : /* istanbul ignore next */ []));
80
156
  /** True once the model is usable (including while a run is in flight). */
81
157
  ready = computed(() => this.status() === 'ready' || this.status() === 'busy', /* @ts-ignore */
82
158
  ...(ngDevMode ? [{ debugName: "ready" }] : /* istanbul ignore next */ []));
83
159
  busy = computed(() => this.status() === 'busy' || this.status() === 'loading', /* @ts-ignore */
84
160
  ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
85
- pipe = null;
86
- loading = null;
161
+ session = newSession();
162
+ destroyed = false;
163
+ /** Counts runs so only the most recently started one writes runError. */
164
+ runSequence = 0;
87
165
  constructor(request, factory, config) {
88
166
  this.request = request;
89
167
  this.factory = factory;
@@ -91,12 +169,22 @@ class PipelineHandle {
91
169
  }
92
170
  /** Downloads and initializes the model. Idempotent; retries after error. */
93
171
  load() {
94
- if (this.pipe)
172
+ if (this.destroyed)
173
+ return Promise.reject(destroyedError());
174
+ const session = this.session;
175
+ if (session.pipe)
95
176
  return Promise.resolve();
96
- this.loading ??= this.doLoad().finally(() => (this.loading = null));
97
- return this.loading;
177
+ session.loading ??= this.doLoad(session).finally(() => {
178
+ session.loading = null;
179
+ });
180
+ return session.loading;
98
181
  }
99
- /** Runs the pipeline, loading the model first if needed. */
182
+ /**
183
+ * Runs the pipeline, loading the model first if needed. Besides the
184
+ * pipeline's own options, `runOptions.signal` (an AbortSignal) makes the
185
+ * run reject before it starts when the signal has already fired, which
186
+ * spares superseded runs the inference (see inferenceResource()).
187
+ */
100
188
  run(input, runOptions) {
101
189
  return this.runWith(input, runOptions);
102
190
  }
@@ -106,45 +194,96 @@ class PipelineHandle {
106
194
  * classification takes (text, candidateLabels, options).
107
195
  */
108
196
  async runWith(input, ...extraArgs) {
197
+ const { args, signal } = takeSignal(extraArgs);
109
198
  await this.load();
199
+ // The model cannot be interrupted, but a run nobody wants any more
200
+ // (a newer input superseded it while the model loaded) need not start.
201
+ if (signal?.aborted)
202
+ throw abortError();
203
+ const session = this.session;
204
+ const pipe = session.pipe;
205
+ if (!pipe) {
206
+ throw new Error('PipelineHandle: disposed before the model finished loading.');
207
+ }
208
+ const sequence = ++this.runSequence;
209
+ session.inFlight++;
110
210
  this.status.set('busy');
211
+ this.runError.set(null);
111
212
  try {
112
- const pipe = this.pipe;
113
- return (await pipe(input, ...extraArgs));
213
+ return (await pipe(input, ...args));
214
+ }
215
+ catch (err) {
216
+ if (this.session === session && sequence === this.runSequence)
217
+ this.runError.set(err);
218
+ throw err;
114
219
  }
115
220
  finally {
116
- // A failed run leaves the model intact - back to ready either way.
117
- this.status.set('ready');
221
+ // Back to ready once the last overlapping run finishes - a failed run
222
+ // leaves the model intact. A run that outlived dispose() settles its
223
+ // own, retired session and leaves the status alone.
224
+ session.inFlight--;
225
+ if (session.inFlight === 0 && this.session === session)
226
+ this.status.set('ready');
118
227
  }
119
228
  }
120
- /** Frees the model. The handle can be loaded again afterwards. */
229
+ /**
230
+ * Frees the model. A download still in flight is cancelled: its model is
231
+ * released on arrival and the handle stays idle. The handle can be loaded
232
+ * again afterwards; destroy() is the terminal variant.
233
+ */
121
234
  async dispose() {
122
- const pipe = this.pipe;
123
- this.pipe = null;
235
+ const session = this.session;
236
+ this.session = newSession();
124
237
  this.status.set('idle');
125
238
  this.progress.set(null);
126
- await pipe?.dispose?.();
239
+ await session.pipe?.dispose?.();
127
240
  }
128
- async doLoad() {
241
+ /**
242
+ * dispose() for good: later load() and run() calls reject instead of
243
+ * downloading a model nobody would release. create*() registers this with
244
+ * the DestroyRef of the injection context, so a handle declared in a
245
+ * component ends with the component.
246
+ */
247
+ destroy() {
248
+ this.destroyed = true;
249
+ return this.dispose();
250
+ }
251
+ async doLoad(session) {
129
252
  this.status.set('loading');
130
253
  this.error.set(null);
254
+ // A retry after a failed attempt starts its bookkeeping from scratch.
255
+ session.files = new Map();
256
+ this.progress.set(null);
131
257
  const options = {
132
258
  ...this.config.pipelineOptions,
133
259
  ...this.request.options,
134
- progress_callback: (event) => this.onProgress(event),
260
+ progress_callback: (event) => this.onProgress(event, session),
135
261
  };
136
262
  const device = await this.resolveDevice();
263
+ // Disposed while probing the device: do not start the download at all.
264
+ if (this.session !== session)
265
+ return;
137
266
  const dtype = this.request.dtype ?? this.config.dtype;
138
267
  if (device && device !== 'auto')
139
268
  options['device'] = device;
140
269
  if (dtype)
141
270
  options['dtype'] = dtype;
142
271
  try {
143
- this.pipe = await this.factory(this.request.task, this.request.model, options);
272
+ const pipe = await this.factory(this.request.task, this.request.model, options);
273
+ if (this.session !== session) {
274
+ // Disposed while downloading: nobody wants this model any more.
275
+ await pipe.dispose?.();
276
+ return;
277
+ }
278
+ session.pipe = pipe;
144
279
  this.progress.set(null);
145
280
  this.status.set('ready');
146
281
  }
147
282
  catch (err) {
283
+ // A cancelled load is not an error - dispose() already reset the handle.
284
+ if (this.session !== session)
285
+ return;
286
+ this.progress.set(null);
148
287
  this.error.set(err);
149
288
  this.status.set('error');
150
289
  throw err;
@@ -160,25 +299,97 @@ class PipelineHandle {
160
299
  return configured;
161
300
  return this.config.autoDevice ? detectDevice() : configured;
162
301
  }
163
- onProgress(event) {
164
- if (event.status !== 'progress' || !event.file)
302
+ /**
303
+ * Transformers.js reports each file on its own (initiate, download,
304
+ * progress, done). The session records every file it hears about; the
305
+ * signal is published on progress and done events only, so a file that
306
+ * has not transferred a byte never replaces the one that is moving.
307
+ */
308
+ onProgress(event, session) {
309
+ // Events from a load that dispose() cancelled must not resurrect the bar.
310
+ if (this.session !== session || !event.file)
165
311
  return;
312
+ const file = session.files.get(event.file) ?? { loaded: 0, total: 0, done: false };
313
+ session.files.set(event.file, file);
314
+ switch (event.status) {
315
+ case 'progress':
316
+ file.loaded = event.loaded ?? file.loaded;
317
+ file.total = event.total ?? file.total;
318
+ break;
319
+ case 'done':
320
+ file.done = true;
321
+ if (file.total > 0)
322
+ file.loaded = file.total;
323
+ break;
324
+ default:
325
+ return; // initiate / download: recorded, not published
326
+ }
327
+ const percent = event.status === 'done' ? 100 : Math.round(event.progress ?? 0);
328
+ const overall = overallOf(session.files);
166
329
  this.progress.set({
167
330
  file: event.file,
168
- progress: Math.round(event.progress ?? 0),
169
- loadedBytes: event.loaded ?? 0,
170
- totalBytes: event.total ?? 0,
331
+ progress: percent,
332
+ loadedBytes: file.loaded,
333
+ totalBytes: file.total,
334
+ ...(overall ? { overall } : {}),
171
335
  });
172
336
  }
173
337
  }
338
+ /**
339
+ * The total over the files seen so far. Transformers.js fetches the small
340
+ * files (config, tokenizer) before it starts the weights, so a total
341
+ * computed before the weights are known would read 100% and then collapse;
342
+ * it is therefore withheld until a weights file has been seen.
343
+ */
344
+ function overallOf(files) {
345
+ let weightsSeen = false;
346
+ let loadedBytes = 0;
347
+ let totalBytes = 0;
348
+ let filesDone = 0;
349
+ for (const [name, file] of files) {
350
+ if (name.includes('.onnx'))
351
+ weightsSeen = true;
352
+ if (file.total > 0) {
353
+ loadedBytes += Math.min(file.loaded, file.total);
354
+ totalBytes += file.total;
355
+ }
356
+ if (file.done)
357
+ filesDone++;
358
+ }
359
+ if (!weightsSeen)
360
+ return undefined;
361
+ return {
362
+ progress: totalBytes > 0 ? Math.round((loadedBytes / totalBytes) * 100) : 0,
363
+ loadedBytes,
364
+ totalBytes,
365
+ files: files.size,
366
+ filesDone,
367
+ };
368
+ }
369
+ /** Pulls `signal` out of a call's trailing options object. */
370
+ function takeSignal(args) {
371
+ const last = args[args.length - 1];
372
+ if (typeof last !== 'object' || last === null || Array.isArray(last))
373
+ return { args };
374
+ const { signal, ...rest } = last;
375
+ if (!(signal instanceof AbortSignal))
376
+ return { args };
377
+ return { args: [...args.slice(0, -1), rest], signal };
378
+ }
379
+ function abortError() {
380
+ return new DOMException('PipelineHandle: the run was aborted before it started.', 'AbortError');
381
+ }
382
+ function destroyedError() {
383
+ return new Error('PipelineHandle: destroyed with its component; create a new handle.');
384
+ }
174
385
  /**
175
386
  * Creates a PipelineHandle in an injection context (constructor, field
176
- * initializer, or runInInjectionContext). The handle is disposed with the
387
+ * initializer, or runInInjectionContext). The handle is destroyed with the
177
388
  * surrounding component/injector.
178
389
  */
179
390
  function createPipeline(request) {
180
391
  const handle = new PipelineHandle(request, inject(PIPELINE_FACTORY), inject(NGX_TRANSFORMERS_CONFIG));
181
- inject(DestroyRef, { optional: true })?.onDestroy(() => void handle.dispose());
392
+ inject(DestroyRef, { optional: true })?.onDestroy(() => void handle.destroy());
182
393
  return handle;
183
394
  }
184
395
 
@@ -190,8 +401,8 @@ const DEFAULT_TEXT_CLASSIFICATION_MODEL = 'Xenova/distilbert-base-uncased-finetu
190
401
  */
191
402
  class TextClassifier extends PipelineHandle {
192
403
  /** Classifies one text; resolves to labels sorted by score (top first). */
193
- async classify(text, topK = 1) {
194
- const out = await this.run(text, { top_k: topK });
404
+ async classify(text, topK = 1, options = {}) {
405
+ const out = await this.run(text, { top_k: topK, signal: options.signal });
195
406
  // Single input: transformers.js returns a flat array of {label, score}.
196
407
  const flat = (Array.isArray(out[0]) ? out[0] : out);
197
408
  return [...flat].sort((a, b) => b.score - a.score);
@@ -200,7 +411,7 @@ class TextClassifier extends PipelineHandle {
200
411
  /** Creates a TextClassifier in an injection context. */
201
412
  function createTextClassifier(options = {}) {
202
413
  const classifier = new TextClassifier({ task: 'text-classification', model: DEFAULT_TEXT_CLASSIFICATION_MODEL, ...options }, inject(PIPELINE_FACTORY), inject(NGX_TRANSFORMERS_CONFIG));
203
- inject(DestroyRef, { optional: true })?.onDestroy(() => void classifier.dispose());
414
+ inject(DestroyRef, { optional: true })?.onDestroy(() => void classifier.destroy());
204
415
  return classifier;
205
416
  }
206
417
 
@@ -228,25 +439,29 @@ function cosineSimilarity(a, b) {
228
439
  */
229
440
  class TextEmbedder extends PipelineHandle {
230
441
  /** Embeds one or many texts; always resolves to one vector per text. */
231
- async embed(texts) {
442
+ async embed(texts, options = {}) {
232
443
  const input = Array.isArray(texts) ? texts : [texts];
233
444
  if (input.length === 0)
234
445
  return [];
235
- const out = await this.run(input, { pooling: 'mean', normalize: true });
446
+ const out = await this.run(input, { pooling: 'mean', normalize: true, signal: options.signal });
236
447
  return toRows(out);
237
448
  }
238
449
  /** Cosine similarity of two texts in [-1, 1]. */
239
- async similarity(a, b) {
240
- const [va, vb] = await this.embed([a, b]);
450
+ async similarity(a, b, options = {}) {
451
+ const [va, vb] = await this.embed([a, b], options);
241
452
  return cosineSimilarity(va, vb);
242
453
  }
243
454
  /** Ranks documents against a query, most similar first. */
244
- async rank(query, documents) {
455
+ async rank(query, documents, options = {}) {
245
456
  if (documents.length === 0)
246
457
  return [];
247
- const [queryVec, ...docVecs] = await this.embed([query, ...documents]);
458
+ const [queryVec, ...docVecs] = await this.embed([query, ...documents], options);
248
459
  return docVecs
249
- .map((vec, index) => ({ text: documents[index], score: cosineSimilarity(queryVec, vec), index }))
460
+ .map((vec, index) => ({
461
+ text: documents[index],
462
+ score: cosineSimilarity(queryVec, vec),
463
+ index,
464
+ }))
250
465
  .sort((a, b) => b.score - a.score);
251
466
  }
252
467
  }
@@ -264,7 +479,7 @@ function toRows(out) {
264
479
  /** Creates a TextEmbedder in an injection context. */
265
480
  function createTextEmbedder(options = {}) {
266
481
  const embedder = new TextEmbedder({ task: 'feature-extraction', model: DEFAULT_EMBEDDING_MODEL, ...options }, inject(PIPELINE_FACTORY), inject(NGX_TRANSFORMERS_CONFIG));
267
- inject(DestroyRef, { optional: true })?.onDestroy(() => void embedder.dispose());
482
+ inject(DestroyRef, { optional: true })?.onDestroy(() => void embedder.destroy());
268
483
  return embedder;
269
484
  }
270
485
 
@@ -324,6 +539,8 @@ class SpeechRecognizer extends PipelineHandle {
324
539
  runOptions['language'] = options.language;
325
540
  if (options.task !== undefined)
326
541
  runOptions['task'] = options.task;
542
+ if (options.signal !== undefined)
543
+ runOptions['signal'] = options.signal;
327
544
  const raw = await this.run(input, runOptions);
328
545
  const first = (Array.isArray(raw) ? raw[0] : raw) ?? {};
329
546
  const chunks = first.chunks?.map((c) => ({
@@ -343,7 +560,7 @@ class SpeechRecognizer extends PipelineHandle {
343
560
  */
344
561
  function createSpeechRecognizer(options = {}) {
345
562
  const recognizer = new SpeechRecognizer({ task: 'automatic-speech-recognition', model: DEFAULT_ASR_MODEL, dtype: 'q4', ...options }, inject(PIPELINE_FACTORY), inject(NGX_TRANSFORMERS_CONFIG));
346
- inject(DestroyRef, { optional: true })?.onDestroy(() => void recognizer.dispose());
563
+ inject(DestroyRef, { optional: true })?.onDestroy(() => void recognizer.destroy());
347
564
  return recognizer;
348
565
  }
349
566
 
@@ -370,6 +587,8 @@ class ZeroShotClassifier extends PipelineHandle {
370
587
  runOptions['multi_label'] = options.multiLabel;
371
588
  if (options.hypothesisTemplate !== undefined)
372
589
  runOptions['hypothesis_template'] = options.hypothesisTemplate;
590
+ if (options.signal !== undefined)
591
+ runOptions['signal'] = options.signal;
373
592
  const raw = await this.runWith(text, [...labels], runOptions);
374
593
  const first = Array.isArray(raw) ? raw[0] : raw;
375
594
  const scoredLabels = first?.labels ?? [];
@@ -382,7 +601,7 @@ class ZeroShotClassifier extends PipelineHandle {
382
601
  /** Creates a ZeroShotClassifier in an injection context. */
383
602
  function createZeroShotClassifier(options = {}) {
384
603
  const classifier = new ZeroShotClassifier({ task: 'zero-shot-classification', model: DEFAULT_ZERO_SHOT_MODEL, ...options }, inject(PIPELINE_FACTORY), inject(NGX_TRANSFORMERS_CONFIG));
385
- inject(DestroyRef, { optional: true })?.onDestroy(() => void classifier.dispose());
604
+ inject(DestroyRef, { optional: true })?.onDestroy(() => void classifier.destroy());
386
605
  return classifier;
387
606
  }
388
607
 
@@ -418,12 +637,15 @@ class Translator {
418
637
  active = signal(null, /* @ts-ignore */
419
638
  ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
420
639
  handles = new Map();
640
+ destroyed = false;
421
641
  status = computed(() => this.active()?.status() ?? 'idle', /* @ts-ignore */
422
642
  ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
423
643
  progress = computed(() => this.active()?.progress() ?? null, /* @ts-ignore */
424
644
  ...(ngDevMode ? [{ debugName: "progress" }] : /* istanbul ignore next */ []));
425
645
  error = computed(() => this.active()?.error() ?? null, /* @ts-ignore */
426
646
  ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
647
+ runError = computed(() => this.active()?.runError() ?? null, /* @ts-ignore */
648
+ ...(ngDevMode ? [{ debugName: "runError" }] : /* istanbul ignore next */ []));
427
649
  ready = computed(() => this.active()?.ready() ?? false, /* @ts-ignore */
428
650
  ...(ngDevMode ? [{ debugName: "ready" }] : /* istanbul ignore next */ []));
429
651
  busy = computed(() => this.active()?.busy() ?? false, /* @ts-ignore */
@@ -434,13 +656,13 @@ class Translator {
434
656
  this.config = config;
435
657
  }
436
658
  /** Downloads the model for a pair ahead of the first translate() call. */
437
- load(pair = {}) {
659
+ async load(pair = {}) {
438
660
  return this.handleFor(pair).load();
439
661
  }
440
662
  /** Translates one text; the pair defaults to the one given at creation. */
441
663
  async translate(text, pair = {}) {
442
664
  const handle = this.handleFor(pair);
443
- const raw = await handle.run(text, this.languageOptions(pair));
665
+ const raw = await handle.run(text, { ...this.languageOptions(pair), signal: pair.signal });
444
666
  const first = Array.isArray(raw) ? raw[0] : raw;
445
667
  return (first?.translation_text ?? '').trim();
446
668
  }
@@ -460,15 +682,32 @@ class Translator {
460
682
  * keep several pairs warm and want a progress line per model.
461
683
  */
462
684
  handleFor(pair = {}) {
685
+ if (this.destroyed) {
686
+ throw new Error('Translator: destroyed with its component; create a new translator.');
687
+ }
463
688
  const model = this.modelFor(pair);
464
689
  let handle = this.handles.get(model);
465
690
  if (!handle) {
466
- handle = new PipelineHandle({ task: 'translation', model, device: this.options.device, dtype: this.options.dtype, options: this.options.options }, this.factory, this.config);
691
+ handle = new PipelineHandle({
692
+ task: 'translation',
693
+ model,
694
+ device: this.options.device,
695
+ dtype: this.options.dtype,
696
+ options: this.options.options,
697
+ }, this.factory, this.config);
467
698
  this.handles.set(model, handle);
468
699
  }
469
700
  this.active.set(handle);
470
701
  return handle;
471
702
  }
703
+ /**
704
+ * dispose() for good: later calls reject instead of loading a model nobody
705
+ * would release. createTranslator() registers this with the DestroyRef.
706
+ */
707
+ destroy() {
708
+ this.destroyed = true;
709
+ return this.dispose();
710
+ }
472
711
  /** Frees every model. The translator can be used again afterwards. */
473
712
  async dispose() {
474
713
  const handles = [...this.handles.values()];
@@ -493,10 +732,124 @@ class Translator {
493
732
  /** Creates a Translator in an injection context. */
494
733
  function createTranslator(options = {}) {
495
734
  const translator = new Translator(options, inject(PIPELINE_FACTORY), inject(NGX_TRANSFORMERS_CONFIG));
496
- inject(DestroyRef, { optional: true })?.onDestroy(() => void translator.dispose());
735
+ inject(DestroyRef, { optional: true })?.onDestroy(() => void translator.destroy());
497
736
  return translator;
498
737
  }
499
738
 
739
+ // The smallest instruct model that holds a conversation; its repository
740
+ // ships the ONNX variants Transformers.js loads (~100 MB at q4).
741
+ const DEFAULT_TEXT_GENERATION_MODEL = 'HuggingFaceTB/SmolLM2-135M-Instruct';
742
+ /**
743
+ * Text generation with a small language model, streamed token by token.
744
+ * The default checkpoint is SmolLM2-135M-Instruct; swap `model` for any
745
+ * Transformers.js text-generation checkpoint (Qwen2.5, Llama, Phi...).
746
+ */
747
+ class TextGenerator extends PipelineHandle {
748
+ /** The text generated so far by the latest generate() call; reset when a call starts. */
749
+ output = signal('', /* @ts-ignore */
750
+ ...(ngDevMode ? [{ debugName: "output" }] : /* istanbul ignore next */ []));
751
+ /** Only the most recently started call may write output. */
752
+ generation = 0;
753
+ warnedNoStreaming = false;
754
+ /**
755
+ * Generates a reply to a prompt or a chat. Tokens stream into `output`
756
+ * (and `options.onToken`) while the model runs; resolves with the full,
757
+ * trimmed reply.
758
+ */
759
+ async generate(prompt, options = {}) {
760
+ const runOptions = { max_new_tokens: options.maxNewTokens ?? 256 };
761
+ if (options.doSample !== undefined)
762
+ runOptions['do_sample'] = options.doSample;
763
+ if (options.temperature !== undefined)
764
+ runOptions['temperature'] = options.temperature;
765
+ if (options.topP !== undefined)
766
+ runOptions['top_p'] = options.topP;
767
+ if (options.topK !== undefined)
768
+ runOptions['top_k'] = options.topK;
769
+ if (options.repetitionPenalty !== undefined) {
770
+ runOptions['repetition_penalty'] = options.repetitionPenalty;
771
+ }
772
+ // A plain prompt would otherwise come back with the prompt in front.
773
+ if (typeof prompt === 'string')
774
+ runOptions['return_full_text'] = false;
775
+ if (options.signal !== undefined)
776
+ runOptions['signal'] = options.signal;
777
+ const generation = ++this.generation;
778
+ let streamed = 0;
779
+ this.output.set('');
780
+ runOptions['onToken'] = (text) => {
781
+ streamed++;
782
+ if (generation === this.generation)
783
+ this.output.update((current) => current + text);
784
+ options.onToken?.(text);
785
+ };
786
+ const raw = await this.run(prompt, runOptions);
787
+ const first = Array.isArray(raw) ? raw[0] : raw;
788
+ const generated = first?.generated_text;
789
+ const text = Array.isArray(generated) ? (generated.at(-1)?.content ?? '') : (generated ?? '');
790
+ const reply = text.trim();
791
+ if (generation === this.generation)
792
+ this.output.set(reply);
793
+ if (streamed === 0 && reply && !this.warnedNoStreaming) {
794
+ // Streaming relies on the pipeline factory turning the onToken run
795
+ // option into a TextStreamer, which the built-in factories do. A
796
+ // custom factory that returns a raw pipeline drops it silently.
797
+ this.warnedNoStreaming = true;
798
+ console.warn('ngx-transformers: the reply arrived without streamed tokens. A custom PIPELINE_FACTORY must wrap createDefaultPipelineFactory() (or handle the onToken run option) for TextGenerator.output to stream.');
799
+ }
800
+ return reply;
801
+ }
802
+ }
803
+ /**
804
+ * Creates a TextGenerator in an injection context; destroyed with the
805
+ * component. dtype defaults to 'q4', the size/quality sweet spot for small
806
+ * decoders on the WebAssembly runtime.
807
+ */
808
+ function createTextGenerator(options = {}) {
809
+ const generator = new TextGenerator({ task: 'text-generation', model: DEFAULT_TEXT_GENERATION_MODEL, dtype: 'q4', ...options }, inject(PIPELINE_FACTORY), inject(NGX_TRANSFORMERS_CONFIG));
810
+ inject(DestroyRef, { optional: true })?.onDestroy(() => void generator.destroy());
811
+ return generator;
812
+ }
813
+
814
+ /**
815
+ * Runs inference whenever an input signal changes, as an Angular resource:
816
+ * `value()`, `isLoading()`, `error()` and `status()` are signals, and only
817
+ * the result for the latest input is kept.
818
+ *
819
+ * ```ts
820
+ * readonly text = signal('');
821
+ * readonly classifier = createTextClassifier();
822
+ * readonly sentiment = inferenceResource({
823
+ * input: () => this.text().trim() || undefined,
824
+ * run: (text, signal) => this.classifier.classify(text, 1, { signal }),
825
+ * debounceMs: 300,
826
+ * });
827
+ * // template: @if (sentiment.value(); as result) { {{ result[0].label }} }
828
+ * ```
829
+ */
830
+ function inferenceResource(options) {
831
+ const injector = options.injector ?? inject(Injector);
832
+ const input = options.debounceMs
833
+ ? debounced(options.input, options.debounceMs, injector)
834
+ : options.input;
835
+ return resource({
836
+ params: () => input(),
837
+ loader: ({ params, abortSignal }) => options.run(params, abortSignal),
838
+ injector,
839
+ });
840
+ }
841
+ /** A signal that follows `source` once it has been stable for `ms`. */
842
+ function debounced(source, ms, injector) {
843
+ const out = signal(untracked(source), /* @ts-ignore */
844
+ ...(ngDevMode ? [{ debugName: "out" }] : /* istanbul ignore next */ []));
845
+ effect((onCleanup) => {
846
+ const value = source();
847
+ const timer = setTimeout(() => out.set(value), ms);
848
+ onCleanup(() => clearTimeout(timer));
849
+ }, { injector });
850
+ return out.asReadonly();
851
+ }
852
+
500
853
  /**
501
854
  * Microphone capture with a signals API. start() asks for mic permission
502
855
  * and records; stop() resolves with the encoded audio Blob - pass it to
@@ -598,7 +951,8 @@ function createMicRecorder(deps = {}) {
598
951
 
599
952
  /**
600
953
  * Drop-in status line for a PipelineHandle: shows model download progress
601
- * while loading, then the ready/busy/error state. Themeable via CSS custom
954
+ * (over every file of the model) while loading, then the ready/busy/error
955
+ * state. Themeable via CSS custom
602
956
  * properties (--nt-accent, --nt-ink, --nt-muted, --nt-track).
603
957
  *
604
958
  * ```html
@@ -613,6 +967,10 @@ class ModelProgressComponent {
613
967
  /** Labels per status; override to localize. */
614
968
  labels = input({}, /* @ts-ignore */
615
969
  ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
970
+ /** The bar follows the whole download when the handle reports it, else the current file. */
971
+ percent(progress) {
972
+ return progress.overall?.progress ?? progress.progress;
973
+ }
616
974
  label = computed(() => {
617
975
  const defaults = {
618
976
  idle: 'Model not loaded',
@@ -624,22 +982,31 @@ class ModelProgressComponent {
624
982
  return this.labels()[this.status()] ?? defaults[this.status()];
625
983
  }, /* @ts-ignore */
626
984
  ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
627
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: ModelProgressComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
628
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: ModelProgressComponent, isStandalone: true, selector: "ngx-model-progress", inputs: { status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: true, transformFunction: null }, progress: { classPropertyName: "progress", publicName: "progress", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
985
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: ModelProgressComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
986
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: ModelProgressComponent, isStandalone: true, selector: "ngx-model-progress", inputs: { status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: true, transformFunction: null }, progress: { classPropertyName: "progress", publicName: "progress", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
629
987
  <div class="nt-row" [class.nt-error]="status() === 'error'" role="status">
630
988
  <span class="nt-dot" [attr.data-status]="status()"></span>
631
989
  <span class="nt-label">{{ label() }}</span>
632
990
  @if (status() === 'loading' && progress(); as p) {
633
991
  <span class="nt-file">{{ p.file }}</span>
634
- <div class="nt-track" role="progressbar" [attr.aria-valuenow]="p.progress" aria-valuemin="0" aria-valuemax="100">
635
- <div class="nt-fill" [style.width.%]="p.progress"></div>
992
+ @if (p.overall && p.overall.files > 1) {
993
+ <span class="nt-files">{{ p.overall.filesDone }}/{{ p.overall.files }} files</span>
994
+ }
995
+ <div
996
+ class="nt-track"
997
+ role="progressbar"
998
+ [attr.aria-valuenow]="percent(p)"
999
+ aria-valuemin="0"
1000
+ aria-valuemax="100"
1001
+ >
1002
+ <div class="nt-fill" [style.width.%]="percent(p)"></div>
636
1003
  </div>
637
- <span class="nt-pct">{{ p.progress }}%</span>
1004
+ <span class="nt-pct">{{ percent(p) }}%</span>
638
1005
  }
639
1006
  </div>
640
- `, isInline: true, styles: [":host{--nt-accent: #f59e0b;--nt-ink: #1e293b;--nt-muted: #64748b;--nt-track: #e2e8f0;display:block;font-size:12.5px;color:var(--nt-ink)}.nt-row{display:flex;align-items:center;gap:8px}.nt-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--nt-muted)}.nt-dot[data-status=loading]{background:var(--nt-accent);animation:nt-blink 1s ease-in-out infinite}.nt-dot[data-status=ready]{background:#22c55e}.nt-dot[data-status=busy]{background:#3b82f6;animation:nt-blink .7s ease-in-out infinite}.nt-dot[data-status=error]{background:#dc2626}.nt-label{font-weight:600}.nt-file{color:var(--nt-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}.nt-track{flex:1;min-width:60px;height:6px;border-radius:3px;background:var(--nt-track);overflow:hidden}.nt-fill{height:100%;background:var(--nt-accent);border-radius:3px;transition:width .12s linear}.nt-pct{font-variant-numeric:tabular-nums;color:var(--nt-muted)}.nt-error .nt-label{color:#dc2626}@keyframes nt-blink{0%,to{opacity:1}50%{opacity:.35}}@media(prefers-reduced-motion:reduce){.nt-dot{animation:none}.nt-fill{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1007
+ `, isInline: true, styles: [":host{--nt-accent: #f59e0b;--nt-ink: #1e293b;--nt-muted: #64748b;--nt-track: #e2e8f0;display:block;font-size:12.5px;color:var(--nt-ink)}.nt-row{display:flex;align-items:center;gap:8px}.nt-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--nt-muted)}.nt-dot[data-status=loading]{background:var(--nt-accent);animation:nt-blink 1s ease-in-out infinite}.nt-dot[data-status=ready]{background:#22c55e}.nt-dot[data-status=busy]{background:#3b82f6;animation:nt-blink .7s ease-in-out infinite}.nt-dot[data-status=error]{background:#dc2626}.nt-label{font-weight:600}.nt-file{color:var(--nt-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}.nt-track{flex:1;min-width:60px;height:6px;border-radius:3px;background:var(--nt-track);overflow:hidden}.nt-fill{height:100%;background:var(--nt-accent);border-radius:3px;transition:width .12s linear}.nt-files{color:var(--nt-muted);white-space:nowrap}.nt-pct{font-variant-numeric:tabular-nums;color:var(--nt-muted)}.nt-error .nt-label{color:#dc2626}@keyframes nt-blink{0%,to{opacity:1}50%{opacity:.35}}@media(prefers-reduced-motion:reduce){.nt-dot{animation:none}.nt-fill{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
641
1008
  }
642
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: ModelProgressComponent, decorators: [{
1009
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: ModelProgressComponent, decorators: [{
643
1010
  type: Component,
644
1011
  args: [{ selector: 'ngx-model-progress', changeDetection: ChangeDetectionStrategy.OnPush, template: `
645
1012
  <div class="nt-row" [class.nt-error]="status() === 'error'" role="status">
@@ -647,22 +1014,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
647
1014
  <span class="nt-label">{{ label() }}</span>
648
1015
  @if (status() === 'loading' && progress(); as p) {
649
1016
  <span class="nt-file">{{ p.file }}</span>
650
- <div class="nt-track" role="progressbar" [attr.aria-valuenow]="p.progress" aria-valuemin="0" aria-valuemax="100">
651
- <div class="nt-fill" [style.width.%]="p.progress"></div>
1017
+ @if (p.overall && p.overall.files > 1) {
1018
+ <span class="nt-files">{{ p.overall.filesDone }}/{{ p.overall.files }} files</span>
1019
+ }
1020
+ <div
1021
+ class="nt-track"
1022
+ role="progressbar"
1023
+ [attr.aria-valuenow]="percent(p)"
1024
+ aria-valuemin="0"
1025
+ aria-valuemax="100"
1026
+ >
1027
+ <div class="nt-fill" [style.width.%]="percent(p)"></div>
652
1028
  </div>
653
- <span class="nt-pct">{{ p.progress }}%</span>
1029
+ <span class="nt-pct">{{ percent(p) }}%</span>
654
1030
  }
655
1031
  </div>
656
- `, styles: [":host{--nt-accent: #f59e0b;--nt-ink: #1e293b;--nt-muted: #64748b;--nt-track: #e2e8f0;display:block;font-size:12.5px;color:var(--nt-ink)}.nt-row{display:flex;align-items:center;gap:8px}.nt-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--nt-muted)}.nt-dot[data-status=loading]{background:var(--nt-accent);animation:nt-blink 1s ease-in-out infinite}.nt-dot[data-status=ready]{background:#22c55e}.nt-dot[data-status=busy]{background:#3b82f6;animation:nt-blink .7s ease-in-out infinite}.nt-dot[data-status=error]{background:#dc2626}.nt-label{font-weight:600}.nt-file{color:var(--nt-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}.nt-track{flex:1;min-width:60px;height:6px;border-radius:3px;background:var(--nt-track);overflow:hidden}.nt-fill{height:100%;background:var(--nt-accent);border-radius:3px;transition:width .12s linear}.nt-pct{font-variant-numeric:tabular-nums;color:var(--nt-muted)}.nt-error .nt-label{color:#dc2626}@keyframes nt-blink{0%,to{opacity:1}50%{opacity:.35}}@media(prefers-reduced-motion:reduce){.nt-dot{animation:none}.nt-fill{transition:none}}\n"] }]
1032
+ `, styles: [":host{--nt-accent: #f59e0b;--nt-ink: #1e293b;--nt-muted: #64748b;--nt-track: #e2e8f0;display:block;font-size:12.5px;color:var(--nt-ink)}.nt-row{display:flex;align-items:center;gap:8px}.nt-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--nt-muted)}.nt-dot[data-status=loading]{background:var(--nt-accent);animation:nt-blink 1s ease-in-out infinite}.nt-dot[data-status=ready]{background:#22c55e}.nt-dot[data-status=busy]{background:#3b82f6;animation:nt-blink .7s ease-in-out infinite}.nt-dot[data-status=error]{background:#dc2626}.nt-label{font-weight:600}.nt-file{color:var(--nt-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}.nt-track{flex:1;min-width:60px;height:6px;border-radius:3px;background:var(--nt-track);overflow:hidden}.nt-fill{height:100%;background:var(--nt-accent);border-radius:3px;transition:width .12s linear}.nt-files{color:var(--nt-muted);white-space:nowrap}.nt-pct{font-variant-numeric:tabular-nums;color:var(--nt-muted)}.nt-error .nt-label{color:#dc2626}@keyframes nt-blink{0%,to{opacity:1}50%{opacity:.35}}@media(prefers-reduced-motion:reduce){.nt-dot{animation:none}.nt-fill{transition:none}}\n"] }]
657
1033
  }], propDecorators: { status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: true }] }], progress: [{ type: i0.Input, args: [{ isSignal: true, alias: "progress", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }] } });
658
1034
 
659
- /*
660
- * Public API Surface of ngx-transformers
1035
+ /**
1036
+ * Public API surface of ngx-transformers: handles, wrappers, configuration
1037
+ * and the progress component.
1038
+ *
1039
+ * @module ngx-transformers
661
1040
  */
662
1041
 
663
1042
  /**
664
1043
  * Generated bundle index. Do not edit.
665
1044
  */
666
1045
 
667
- export { DEFAULT_ASR_MODEL, DEFAULT_EMBEDDING_MODEL, DEFAULT_TEXT_CLASSIFICATION_MODEL, DEFAULT_ZERO_SHOT_MODEL, MicRecorder, ModelProgressComponent, NGX_TRANSFORMERS_CONFIG, PIPELINE_FACTORY, PipelineHandle, SpeechRecognizer, TextClassifier, TextEmbedder, Translator, WHISPER_SAMPLE_RATE, ZeroShotClassifier, cosineSimilarity, createMicRecorder, createPipeline, createSpeechRecognizer, createTextClassifier, createTextEmbedder, createTranslator, createZeroShotClassifier, decodeAudio, defaultTranslationModel, detectDevice, hasWebGpu, provideTransformers, resetDeviceDetection, resolveTranslationModel };
1046
+ export { DEFAULT_ASR_MODEL, DEFAULT_EMBEDDING_MODEL, DEFAULT_TEXT_CLASSIFICATION_MODEL, DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_ZERO_SHOT_MODEL, MicRecorder, ModelProgressComponent, NGX_TRANSFORMERS_CONFIG, PIPELINE_FACTORY, PipelineHandle, SpeechRecognizer, TextClassifier, TextEmbedder, TextGenerator, Translator, WHISPER_SAMPLE_RATE, ZeroShotClassifier, cosineSimilarity, createDefaultPipelineFactory, createMicRecorder, createPipeline, createSpeechRecognizer, createTextClassifier, createTextEmbedder, createTextGenerator, createTranslator, createZeroShotClassifier, decodeAudio, defaultTranslationModel, detectDevice, hasWebGpu, inferenceResource, provideTransformers, provideTransformersWorker, resetDeviceDetection, resolveTranslationModel };
668
1047
  //# sourceMappingURL=ngx-transformers.mjs.map