@nyaruka/temba-components 0.156.12 → 0.156.13

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.156.12",
3
+ "version": "0.156.13",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -53,16 +53,20 @@ export class Button extends LitElement {
53
53
  background: rgba(0, 0, 0, 0.05);
54
54
  }
55
55
 
56
+ .button-mask:active {
57
+ background: rgba(0, 0, 0, 0.12);
58
+ }
59
+
56
60
  .button-container:focus {
57
61
  outline: none;
58
- margin: 0;
59
62
  }
60
63
 
61
- .button-container:focus {
64
+ /* Only show the focus ring for keyboard navigation, never on click. */
65
+ .button-container:focus-visible {
62
66
  box-shadow: var(--widget-box-shadow-focused);
63
67
  }
64
68
 
65
- .button-container.secondary-button:focus .button-mask {
69
+ .button-container.secondary-button:focus-visible .button-mask {
66
70
  background: transparent;
67
71
  }
68
72
 
@@ -103,7 +107,8 @@ export class Button extends LitElement {
103
107
  border: 1px solid transparent;
104
108
  }
105
109
 
106
- .button-container.secondary-button.active-button:focus .button-mask {
110
+ .button-container.secondary-button.active-button:focus-visible
111
+ .button-mask {
107
112
  background: transparent;
108
113
  box-shadow: none;
109
114
  }
@@ -0,0 +1,630 @@
1
+ import { html, TemplateResult } from 'lit-html';
2
+ import { css, PropertyValues } from 'lit';
3
+ import { property, state } from 'lit/decorators.js';
4
+ import { RapidElement } from '../RapidElement';
5
+ import { getStore } from '../store/Store';
6
+ import { zustand } from '../store/AppState';
7
+ import { FlowDefinition } from '../store/flow-definition';
8
+ import { TranslationEntry, buildTranslationBundles } from './flow-translations';
9
+ import { getLanguageDisplayName } from './utils';
10
+
11
+ interface TranslationModel {
12
+ uuid: string;
13
+ name: string;
14
+ }
15
+
16
+ const MODELS_ENDPOINT = '/api/internal/llms.json';
17
+ const ADD_MODEL_URL = '/ai/';
18
+
19
+ // Max size of the serialized JSON payload per translate request. Keeps the
20
+ // LLM's output comfortably below its token limit. Measured against the full
21
+ // payload (uuids, keys, JSON structural chars, and source strings).
22
+ const TRANSLATION_BATCH_CHAR_LIMIT = 10000;
23
+
24
+ export class AutoTranslate extends RapidElement {
25
+ static get styles() {
26
+ return css`
27
+ :host {
28
+ display: contents;
29
+ }
30
+
31
+ .auto-translate-body {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 12px;
35
+ padding: 20px;
36
+ font-size: 14px;
37
+ color: #374151;
38
+ }
39
+
40
+ .auto-translate-body p {
41
+ margin: 0;
42
+ }
43
+
44
+ .auto-translate-loading {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 8px;
48
+ font-size: 13px;
49
+ color: #6b7280;
50
+ }
51
+
52
+ .auto-translate-empty {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 8px;
56
+ font-size: 13px;
57
+ }
58
+
59
+ .auto-translate-empty a {
60
+ color: var(--color-link);
61
+ text-decoration: none;
62
+ }
63
+
64
+ .auto-translate-empty a:hover {
65
+ text-decoration: underline;
66
+ }
67
+
68
+ .auto-translate-single-model {
69
+ font-size: 13px;
70
+ }
71
+
72
+ .auto-translate-status {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 6px;
76
+ /* align with the progress bar in the body (body has 20px padding,
77
+ dialog-footer has 10px) */
78
+ padding-left: 10px;
79
+ font-size: 12px;
80
+ color: #6b7280;
81
+ }
82
+
83
+ .auto-translate-status-spinner {
84
+ --icon-color: #6b7280;
85
+ }
86
+
87
+ .auto-translate-error-block {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 6px;
91
+ padding: 12px 14px;
92
+ background: #fef2f2;
93
+ border: 1px solid #fecaca;
94
+ border-radius: 6px;
95
+ overflow: hidden;
96
+ font-size: 13px;
97
+ color: #7f1d1d;
98
+ }
99
+
100
+ .auto-translate-error-block p {
101
+ margin: 0;
102
+ }
103
+
104
+ .auto-translate-error-help {
105
+ color: inherit;
106
+ }
107
+
108
+ .auto-translate-error-toggle {
109
+ align-self: flex-start;
110
+ margin-top: 2px;
111
+ padding: 0;
112
+ background: none;
113
+ border: none;
114
+ color: inherit;
115
+ font: inherit;
116
+ font-size: 12px;
117
+ text-decoration: underline;
118
+ cursor: pointer;
119
+ }
120
+
121
+ .auto-translate-error-details {
122
+ margin: 6px -14px -12px;
123
+ padding: 10px 14px;
124
+ background: #fff;
125
+ border-top: 1px solid #fecaca;
126
+ font-size: 12px;
127
+ color: inherit;
128
+ white-space: pre-wrap;
129
+ word-break: break-word;
130
+ }
131
+ `;
132
+ }
133
+
134
+ @property({ attribute: false })
135
+ definition: FlowDefinition | null = null;
136
+
137
+ @property({ type: String, attribute: 'language-code' })
138
+ languageCode = '';
139
+
140
+ @property({ type: Boolean })
141
+ disabled = false;
142
+
143
+ // Reactive flag the host can read to show "translating" state in UI
144
+ // adjacent to this component (e.g. the toolbar button).
145
+ @property({ type: Boolean, reflect: true })
146
+ running = false;
147
+
148
+ @state()
149
+ dialogOpen = false;
150
+
151
+ @state()
152
+ models: TranslationModel[] = [];
153
+
154
+ @state()
155
+ modelsLoading = false;
156
+
157
+ @state()
158
+ selectedModel: TranslationModel | null = null;
159
+
160
+ @state()
161
+ progress: { done: number; total: number } = { done: 0, total: 0 };
162
+
163
+ @state()
164
+ error: string | null = null;
165
+
166
+ @state()
167
+ errorExpanded = false;
168
+
169
+ @state()
170
+ interrupt = false;
171
+
172
+ // Tracks whether the dialog has ever opened so we can keep it mounted
173
+ // afterwards (so it sees its own close transition) without paying
174
+ // for an empty hidden dialog before that.
175
+ private everOpened = false;
176
+
177
+ /**
178
+ * Public entry point. If a translation is in flight, sets the interrupt
179
+ * flag; otherwise opens the model picker.
180
+ */
181
+ public async start(): Promise<void> {
182
+ if (this.disabled) {
183
+ return;
184
+ }
185
+
186
+ if (this.running) {
187
+ this.interrupt = true;
188
+ return;
189
+ }
190
+
191
+ this.error = null;
192
+ this.errorExpanded = false;
193
+ this.selectedModel = null;
194
+ this.dialogOpen = true;
195
+ await this.loadModels();
196
+
197
+ if (this.models.length === 1) {
198
+ this.selectedModel = this.models[0];
199
+ }
200
+ }
201
+
202
+ private async loadModels(): Promise<void> {
203
+ this.modelsLoading = true;
204
+ try {
205
+ const store = getStore();
206
+ const results = store
207
+ ? await store.getResults(MODELS_ENDPOINT, { force: true })
208
+ : [];
209
+ this.models = (results || []).map((r: any) => ({
210
+ uuid: r.uuid,
211
+ name: r.name
212
+ }));
213
+ } catch (err) {
214
+ console.error('Failed to load AI models', err);
215
+ this.models = [];
216
+ this.error = 'Unable to load AI models.';
217
+ } finally {
218
+ this.modelsLoading = false;
219
+ }
220
+ }
221
+
222
+ private handleModelChange(event: Event): void {
223
+ const select = event.target as any;
224
+ const next = select?.values?.[0];
225
+ this.selectedModel = next ? { uuid: next.uuid, name: next.name } : null;
226
+ }
227
+
228
+ private confirmTranslate(): void {
229
+ if (!this.selectedModel) {
230
+ return;
231
+ }
232
+ this.dialogOpen = false;
233
+ this.runAutoTranslation().catch((err) => {
234
+ console.error('Auto translation failed', err);
235
+ this.error = 'Auto translation failed. Please try again.';
236
+ this.running = false;
237
+ });
238
+ }
239
+
240
+ private cancelDialog(): void {
241
+ this.dialogOpen = false;
242
+ }
243
+
244
+ private dismissError(): void {
245
+ this.error = null;
246
+ this.errorExpanded = false;
247
+ this.progress = { done: 0, total: 0 };
248
+ }
249
+
250
+ /**
251
+ * Builds batches of translation requests from all untranslated entries,
252
+ * grouping so each request's serialized payload stays under
253
+ * TRANSLATION_BATCH_CHAR_LIMIT.
254
+ */
255
+ private buildTranslationBatches(): {
256
+ keyToEntries: Map<string, TranslationEntry[]>;
257
+ batches: { items: Record<string, string[]> }[];
258
+ } {
259
+ const keyToEntries = new Map<string, TranslationEntry[]>();
260
+ const batches: { items: Record<string, string[]> }[] = [];
261
+
262
+ if (!this.definition) {
263
+ return { keyToEntries, batches };
264
+ }
265
+
266
+ const bundles = buildTranslationBundles(this.definition, this.languageCode);
267
+ const pending: { key: string; entry: TranslationEntry }[] = [];
268
+
269
+ for (const bundle of bundles) {
270
+ for (const entry of bundle.translations) {
271
+ if (entry.to && entry.to.trim().length > 0) {
272
+ continue;
273
+ }
274
+ if (!entry.from || entry.from.trim().length === 0) {
275
+ continue;
276
+ }
277
+ const key = `${entry.uuid}:${entry.attribute}`;
278
+ pending.push({ key, entry });
279
+ const list = keyToEntries.get(key) || [];
280
+ list.push(entry);
281
+ keyToEntries.set(key, list);
282
+ }
283
+ }
284
+
285
+ const source = this.definition.language;
286
+ const target = this.languageCode;
287
+ const measurePayload = (items: Record<string, string[]>): number =>
288
+ JSON.stringify({ source, target, items }).length;
289
+
290
+ let current: Record<string, string[]> = {};
291
+
292
+ for (const { key, entry } of pending) {
293
+ const prevList = current[key];
294
+ const nextList = prevList ? [...prevList, entry.from] : [entry.from];
295
+ const tentative = { ...current, [key]: nextList };
296
+
297
+ const tentativeSize = measurePayload(tentative);
298
+ const batchHasItems = Object.keys(current).length > 0;
299
+
300
+ if (tentativeSize > TRANSLATION_BATCH_CHAR_LIMIT && batchHasItems) {
301
+ // a single oversized entry still ships on its own
302
+ batches.push({ items: current });
303
+ current = { [key]: [entry.from] };
304
+ } else {
305
+ current = tentative;
306
+ }
307
+ }
308
+
309
+ if (Object.keys(current).length > 0) {
310
+ batches.push({ items: current });
311
+ }
312
+
313
+ return { keyToEntries, batches };
314
+ }
315
+
316
+ /**
317
+ * Drives the batch loop. Public so tests can invoke directly with a
318
+ * pre-set selectedModel.
319
+ */
320
+ public async runAutoTranslation(): Promise<void> {
321
+ if (
322
+ !this.definition ||
323
+ !this.selectedModel ||
324
+ this.languageCode === this.definition.language
325
+ ) {
326
+ return;
327
+ }
328
+
329
+ const { keyToEntries, batches } = this.buildTranslationBatches();
330
+ if (batches.length === 0) {
331
+ return;
332
+ }
333
+
334
+ this.running = true;
335
+ this.interrupt = false;
336
+ this.progress = { done: 0, total: batches.length };
337
+
338
+ const source = this.definition.language;
339
+ const target = this.languageCode;
340
+ const url = `/llm/translate/${this.selectedModel.uuid}/`;
341
+ const store = getStore();
342
+ if (!store) {
343
+ this.running = false;
344
+ return;
345
+ }
346
+
347
+ for (let i = 0; i < batches.length; i++) {
348
+ if (this.interrupt) {
349
+ break;
350
+ }
351
+
352
+ try {
353
+ const response = await store.postJSON(url, {
354
+ source,
355
+ target,
356
+ items: batches[i].items
357
+ });
358
+
359
+ if (response.status >= 200 && response.status < 300) {
360
+ const returned: Record<string, string[]> = response.json?.items || {};
361
+ this.applyBatchTranslations(returned, keyToEntries);
362
+ } else {
363
+ this.error =
364
+ response.json?.error ||
365
+ `Translate request failed (${response.status}).`;
366
+ break;
367
+ }
368
+ } catch (err) {
369
+ console.error('Translate request failed', err);
370
+ this.error = 'Translate request failed.';
371
+ break;
372
+ }
373
+
374
+ this.progress = { done: i + 1, total: batches.length };
375
+ }
376
+
377
+ this.running = false;
378
+ this.interrupt = false;
379
+ }
380
+
381
+ private applyBatchTranslations(
382
+ returned: Record<string, string[]>,
383
+ keyToEntries: Map<string, TranslationEntry[]>
384
+ ): void {
385
+ const store = getStore();
386
+ if (!store || !this.definition) {
387
+ return;
388
+ }
389
+
390
+ // group updates by uuid so multiple attributes merge into one write
391
+ const updatesByUuid = new Map<string, Record<string, any>>();
392
+
393
+ for (const [key, values] of Object.entries(returned)) {
394
+ const entries = keyToEntries.get(key);
395
+ if (!entries || entries.length === 0) {
396
+ continue;
397
+ }
398
+ const [firstEntry] = entries;
399
+ const uuid = firstEntry.uuid;
400
+ const attribute = firstEntry.attribute;
401
+
402
+ keyToEntries.delete(key);
403
+
404
+ if (!values || values.length === 0) {
405
+ continue;
406
+ }
407
+
408
+ // read from the live store rather than this.definition, which can
409
+ // lag behind across batches and cause earlier attributes to be
410
+ // overwritten when multiple attrs on the same uuid land in
411
+ // different batches
412
+ const liveDefinition = zustand.getState().flowDefinition;
413
+ const existing =
414
+ liveDefinition?.localization?.[this.languageCode]?.[uuid] || {};
415
+ const merged = updatesByUuid.get(uuid) || { ...existing };
416
+ merged[attribute] = values;
417
+ updatesByUuid.set(uuid, merged);
418
+ }
419
+
420
+ for (const [uuid, merged] of updatesByUuid.entries()) {
421
+ store.getState().updateLocalization(this.languageCode, uuid, merged);
422
+ zustand.getState().markAutoTranslated(
423
+ this.languageCode,
424
+ uuid,
425
+ Object.keys(merged).filter(
426
+ (k) => merged[k] && (merged[k] as any[]).length > 0
427
+ )
428
+ );
429
+ }
430
+ }
431
+
432
+ protected updated(changed: PropertyValues): void {
433
+ if (changed.has('running')) {
434
+ // emit a state-change event so the host can update adjacent UI
435
+ // (e.g. toolbar button) without polling
436
+ this.dispatchEvent(
437
+ new CustomEvent('temba-auto-translate-changed', {
438
+ detail: { running: this.running },
439
+ bubbles: true,
440
+ composed: true
441
+ })
442
+ );
443
+ }
444
+ }
445
+
446
+ private handleDialogButton(event: CustomEvent): void {
447
+ const name = event.detail?.button?.name;
448
+ if (this.error) {
449
+ // only the Dismiss button shows in the error state
450
+ this.dismissError();
451
+ return;
452
+ }
453
+ if (this.running) {
454
+ // only the Stop button shows while running
455
+ if (!this.interrupt) {
456
+ this.interrupt = true;
457
+ }
458
+ return;
459
+ }
460
+ if (name === 'Translate') {
461
+ this.confirmTranslate();
462
+ } else {
463
+ this.cancelDialog();
464
+ }
465
+ }
466
+
467
+ public render(): TemplateResult | string {
468
+ const showPicker = this.dialogOpen;
469
+ const showProgress = this.running || !!this.error;
470
+ const open = showPicker || showProgress;
471
+
472
+ // Skip rendering until the dialog has been opened at least once so we
473
+ // don't pay for an empty hidden dialog on every editor instance.
474
+ if (!open && !this.everOpened) {
475
+ return '';
476
+ }
477
+ if (open) {
478
+ this.everOpened = true;
479
+ }
480
+
481
+ let header = 'Auto Translation';
482
+ let body: TemplateResult = html``;
483
+ let gutter: TemplateResult | string = '';
484
+ let primary = '';
485
+ let cancel = '';
486
+ let disabled = false;
487
+
488
+ if (this.error) {
489
+ header = 'Problem with AI Model';
490
+ body = this.renderErrorBody();
491
+ cancel = 'Dismiss';
492
+ } else if (this.running) {
493
+ body = this.renderRunningBody();
494
+ gutter = this.renderRunningGutter();
495
+ // Stop is the primary so the dialog does NOT auto-close on click;
496
+ // we close it ourselves once the in-flight batch returns
497
+ primary = 'Stop';
498
+ disabled = this.interrupt;
499
+ } else if (showPicker) {
500
+ const noModels = !this.modelsLoading && this.models.length === 0;
501
+ body = this.renderPickerBody();
502
+ cancel = noModels ? 'Close' : 'Cancel';
503
+ primary = noModels ? '' : 'Translate';
504
+ disabled = this.modelsLoading || noModels || !this.selectedModel;
505
+ }
506
+
507
+ // We always render the dialog (after first open) so it sees the
508
+ // open: true -> false transition and runs its body scroll/unlock
509
+ // cleanup; otherwise body styles can stay stuck after auto-translate
510
+ // completes.
511
+ return html`
512
+ <temba-dialog
513
+ header=${header}
514
+ .open=${open}
515
+ size="small"
516
+ primaryButtonName=${primary}
517
+ cancelButtonName=${cancel}
518
+ ?disabled=${disabled}
519
+ variant="flat"
520
+ @temba-button-clicked=${this.handleDialogButton}
521
+ >
522
+ <div class="auto-translate-body">${body}</div>
523
+ ${gutter ? html`<div slot="gutter">${gutter}</div>` : ''}
524
+ </temba-dialog>
525
+ `;
526
+ }
527
+
528
+ private renderPickerBody(): TemplateResult {
529
+ if (this.modelsLoading) {
530
+ return html`<div class="auto-translate-loading">
531
+ <temba-loading units="3" size="8"></temba-loading>
532
+ </div>`;
533
+ }
534
+
535
+ if (this.models.length === 0) {
536
+ return html`
537
+ <div class="auto-translate-empty">
538
+ <p>You need to add an AI model before you can auto translate.</p>
539
+ <p>
540
+ <a href="${ADD_MODEL_URL}" target="_blank" rel="noopener"
541
+ >Manage AI models</a
542
+ >
543
+ </p>
544
+ </div>
545
+ `;
546
+ }
547
+
548
+ const selected = this.selectedModel ? [this.selectedModel] : [];
549
+ const languageName = getLanguageDisplayName(this.languageCode);
550
+ return html`
551
+ <p>
552
+ All remaining text for <strong>${languageName}</strong> will be
553
+ translated automatically. Remember, AI models can make mistakes so it is important to review all of your translations to verify they are correct.
554
+ </p>
555
+ ${this.models.length > 1
556
+ ? html`<temba-select
557
+ class="auto-translate-model-select"
558
+ endpoint="${MODELS_ENDPOINT}"
559
+ valueKey="uuid"
560
+ .values=${selected}
561
+ ?searchable=${true}
562
+ placeholder="Select an AI model"
563
+ @change=${this.handleModelChange}
564
+ ></temba-select>`
565
+ : html`<div class="auto-translate-single-model">
566
+ Using <strong>${this.models[0]?.name}</strong>
567
+ </div>`}
568
+ `;
569
+ }
570
+
571
+ private renderRunningBody(): TemplateResult {
572
+ const { done, total } = this.progress;
573
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
574
+
575
+ return html`
576
+ <temba-progress
577
+ .total=${total}
578
+ .current=${done}
579
+ .pct=${pct}
580
+ showPercentage
581
+ ></temba-progress>
582
+ `;
583
+ }
584
+
585
+ private renderRunningGutter(): TemplateResult {
586
+ const { done, total } = this.progress;
587
+ // current batch is 1-indexed: while batch 0 is in flight, done is still
588
+ // 0, so we display "1 of N"
589
+ const currentBatch = Math.min(done + 1, total);
590
+ const statusText = this.interrupt
591
+ ? 'Stopping...'
592
+ : total > 1
593
+ ? `Translating ${currentBatch} of ${total}`
594
+ : 'Translating';
595
+
596
+ return html`
597
+ <div class="auto-translate-status">
598
+ <temba-icon
599
+ class="auto-translate-status-spinner"
600
+ name="progress_spinner"
601
+ size="0.9"
602
+ spin
603
+ ></temba-icon>
604
+ <span>${statusText}</span>
605
+ </div>
606
+ `;
607
+ }
608
+
609
+ private renderErrorBody(): TemplateResult {
610
+ return html`
611
+ <div class="auto-translate-error-block">
612
+ <p class="auto-translate-error-help">
613
+ Any translations already applied have been kept. You can try again,
614
+ or check the AI model's settings if the problem persists.
615
+ </p>
616
+ ${this.errorExpanded
617
+ ? html`<pre class="auto-translate-error-details">
618
+ ${this.error}</pre
619
+ >`
620
+ : html`<button
621
+ class="auto-translate-error-toggle"
622
+ type="button"
623
+ @click=${() => (this.errorExpanded = true)}
624
+ >
625
+ Show details
626
+ </button>`}
627
+ </div>
628
+ `;
629
+ }
630
+ }