@nyaruka/temba-components 0.156.13 → 0.156.14

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.13",
3
+ "version": "0.156.14",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -562,6 +562,19 @@ export class Options extends RapidElement {
562
562
  return;
563
563
  }
564
564
 
565
+ // Only intercept keys when the event originated within our owning component.
566
+ // Without this, any temba-options with populated options would swallow arrow
567
+ // keys document-wide, breaking cursor movement in unrelated text editors.
568
+ // All current usages render temba-options inside another custom element's
569
+ // shadow root, so scoping by host correctly distinguishes between editors.
570
+ if (!this.block) {
571
+ const root = this.getRootNode();
572
+ const host = root instanceof ShadowRoot ? root.host : null;
573
+ if (host && !evt.composedPath().includes(host)) {
574
+ return;
575
+ }
576
+ }
577
+
565
578
  if (this.options && this.options.length > 0) {
566
579
  if ((evt.ctrlKey && evt.key === 'n') || evt.key === 'ArrowDown') {
567
580
  this.moveCursor(1);
@@ -7,6 +7,7 @@ import { zustand } from '../store/AppState';
7
7
  import { FlowDefinition } from '../store/flow-definition';
8
8
  import { TranslationEntry, buildTranslationBundles } from './flow-translations';
9
9
  import { getLanguageDisplayName } from './utils';
10
+ import { LLMModel, hasLLMRole } from './flow-utils';
10
11
 
11
12
  interface TranslationModel {
12
13
  uuid: string;
@@ -203,13 +204,15 @@ export class AutoTranslate extends RapidElement {
203
204
  this.modelsLoading = true;
204
205
  try {
205
206
  const store = getStore();
206
- const results = store
207
- ? await store.getResults(MODELS_ENDPOINT, { force: true })
207
+ const results: LLMModel[] = store
208
+ ? ((await store.getResults(MODELS_ENDPOINT, { force: true })) ?? [])
208
209
  : [];
209
- this.models = (results || []).map((r: any) => ({
210
- uuid: r.uuid,
211
- name: r.name
212
- }));
210
+ this.models = results
211
+ .filter((r) => hasLLMRole(r, 'editing'))
212
+ .map((r) => ({
213
+ uuid: r.uuid,
214
+ name: r.name
215
+ }));
213
216
  } catch (err) {
214
217
  console.error('Failed to load AI models', err);
215
218
  this.models = [];
@@ -250,21 +253,60 @@ export class AutoTranslate extends RapidElement {
250
253
  /**
251
254
  * Builds batches of translation requests from all untranslated entries,
252
255
  * grouping so each request's serialized payload stays under
253
- * TRANSLATION_BATCH_CHAR_LIMIT.
256
+ * TRANSLATION_BATCH_CHAR_LIMIT. Skips entries whose source matches a
257
+ * translation already present in the flow (those are returned in
258
+ * preTranslated for direct application), and dedupes pending entries
259
+ * sharing identical source arrays so each unique array is sent at most
260
+ * once (duplicates are returned in duplicateKeyToCanonical for
261
+ * propagation when the canonical key's translation lands).
254
262
  */
255
263
  private buildTranslationBatches(): {
256
264
  keyToEntries: Map<string, TranslationEntry[]>;
257
265
  batches: { items: Record<string, string[]> }[];
266
+ preTranslated: Map<string, string[]>;
267
+ duplicateKeyToCanonical: Map<string, string>;
258
268
  } {
259
269
  const keyToEntries = new Map<string, TranslationEntry[]>();
260
270
  const batches: { items: Record<string, string[]> }[] = [];
271
+ const preTranslated = new Map<string, string[]>();
272
+ const duplicateKeyToCanonical = new Map<string, string>();
261
273
 
262
274
  if (!this.definition) {
263
- return { keyToEntries, batches };
275
+ return { keyToEntries, batches, preTranslated, duplicateKeyToCanonical };
264
276
  }
265
277
 
266
278
  const bundles = buildTranslationBundles(this.definition, this.languageCode);
267
- const pending: { key: string; entry: TranslationEntry }[] = [];
279
+
280
+ // Map source-content -> already-stored translation array, built from
281
+ // entries that have a translation in the live localization map. Reading
282
+ // the stored array (not entry.to) preserves the original shape.
283
+ const existingByContent = new Map<string, string[]>();
284
+ const localization =
285
+ this.definition.localization?.[this.languageCode] || {};
286
+
287
+ for (const bundle of bundles) {
288
+ for (const entry of bundle.translations) {
289
+ if (!entry.to || entry.to.trim().length === 0) {
290
+ continue;
291
+ }
292
+ if (!entry.from || entry.from.trim().length === 0) {
293
+ continue;
294
+ }
295
+ const stored = localization[entry.uuid]?.[entry.attribute];
296
+ if (!Array.isArray(stored) || stored.length === 0) {
297
+ continue;
298
+ }
299
+ const contentKey = JSON.stringify([entry.from]);
300
+ if (!existingByContent.has(contentKey)) {
301
+ existingByContent.set(contentKey, stored);
302
+ }
303
+ }
304
+ }
305
+
306
+ // Collect pending entries (no translation yet) preserving order, and
307
+ // group source values per key in case the same key yields multiple
308
+ // entries.
309
+ const valuesByKey = new Map<string, string[]>();
268
310
 
269
311
  for (const bundle of bundles) {
270
312
  for (const entry of bundle.translations) {
@@ -275,11 +317,38 @@ export class AutoTranslate extends RapidElement {
275
317
  continue;
276
318
  }
277
319
  const key = `${entry.uuid}:${entry.attribute}`;
278
- pending.push({ key, entry });
279
320
  const list = keyToEntries.get(key) || [];
280
321
  list.push(entry);
281
322
  keyToEntries.set(key, list);
323
+ const values = valuesByKey.get(key) || [];
324
+ values.push(entry.from);
325
+ valuesByKey.set(key, values);
326
+ }
327
+ }
328
+
329
+ // Split pending keys into pre-translated (matching an existing
330
+ // translation), duplicates (sharing content with an earlier pending
331
+ // key), and unique canonical keys that need to be sent to the LLM.
332
+ const canonicalByContent = new Map<string, string>();
333
+ const canonicalKeysWithValues: { key: string; values: string[] }[] = [];
334
+
335
+ for (const [key, values] of valuesByKey) {
336
+ const contentKey = JSON.stringify(values);
337
+
338
+ const existing = existingByContent.get(contentKey);
339
+ if (existing && existing.length === values.length) {
340
+ preTranslated.set(key, existing);
341
+ continue;
342
+ }
343
+
344
+ const canonical = canonicalByContent.get(contentKey);
345
+ if (canonical) {
346
+ duplicateKeyToCanonical.set(key, canonical);
347
+ continue;
282
348
  }
349
+
350
+ canonicalByContent.set(contentKey, key);
351
+ canonicalKeysWithValues.push({ key, values });
283
352
  }
284
353
 
285
354
  const source = this.definition.language;
@@ -289,18 +358,15 @@ export class AutoTranslate extends RapidElement {
289
358
 
290
359
  let current: Record<string, string[]> = {};
291
360
 
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
-
361
+ for (const { key, values } of canonicalKeysWithValues) {
362
+ const tentative = { ...current, [key]: values };
297
363
  const tentativeSize = measurePayload(tentative);
298
364
  const batchHasItems = Object.keys(current).length > 0;
299
365
 
300
366
  if (tentativeSize > TRANSLATION_BATCH_CHAR_LIMIT && batchHasItems) {
301
367
  // a single oversized entry still ships on its own
302
368
  batches.push({ items: current });
303
- current = { [key]: [entry.from] };
369
+ current = { [key]: values };
304
370
  } else {
305
371
  current = tentative;
306
372
  }
@@ -310,7 +376,7 @@ export class AutoTranslate extends RapidElement {
310
376
  batches.push({ items: current });
311
377
  }
312
378
 
313
- return { keyToEntries, batches };
379
+ return { keyToEntries, batches, preTranslated, duplicateKeyToCanonical };
314
380
  }
315
381
 
316
382
  /**
@@ -326,11 +392,31 @@ export class AutoTranslate extends RapidElement {
326
392
  return;
327
393
  }
328
394
 
329
- const { keyToEntries, batches } = this.buildTranslationBatches();
395
+ const { keyToEntries, batches, preTranslated, duplicateKeyToCanonical } =
396
+ this.buildTranslationBatches();
397
+
398
+ // Apply entries that match an existing translation immediately so we
399
+ // don't need an LLM round-trip for them.
400
+ if (preTranslated.size > 0) {
401
+ this.applyBatchTranslations(
402
+ Object.fromEntries(preTranslated),
403
+ keyToEntries
404
+ );
405
+ }
406
+
330
407
  if (batches.length === 0) {
331
408
  return;
332
409
  }
333
410
 
411
+ // Inverse of duplicateKeyToCanonical so we can quickly find which keys
412
+ // need to be back-filled when a canonical key's translation lands.
413
+ const duplicatesByCanonical = new Map<string, string[]>();
414
+ for (const [dup, canonical] of duplicateKeyToCanonical) {
415
+ const list = duplicatesByCanonical.get(canonical) || [];
416
+ list.push(dup);
417
+ duplicatesByCanonical.set(canonical, list);
418
+ }
419
+
334
420
  this.running = true;
335
421
  this.interrupt = false;
336
422
  this.progress = { done: 0, total: batches.length };
@@ -358,7 +444,16 @@ export class AutoTranslate extends RapidElement {
358
444
 
359
445
  if (response.status >= 200 && response.status < 300) {
360
446
  const returned: Record<string, string[]> = response.json?.items || {};
361
- this.applyBatchTranslations(returned, keyToEntries);
447
+ const expanded: Record<string, string[]> = { ...returned };
448
+ for (const canonical of Object.keys(returned)) {
449
+ const dups = duplicatesByCanonical.get(canonical);
450
+ if (dups) {
451
+ for (const dup of dups) {
452
+ expanded[dup] = returned[canonical];
453
+ }
454
+ }
455
+ }
456
+ this.applyBatchTranslations(expanded, keyToEntries);
362
457
  } else {
363
458
  this.error =
364
459
  response.json?.error ||
@@ -550,7 +645,8 @@ export class AutoTranslate extends RapidElement {
550
645
  return html`
551
646
  <p>
552
647
  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.
648
+ translated automatically. Remember, AI models can make mistakes so it is
649
+ important to review all of your translations to verify they are correct.
554
650
  </p>
555
651
  ${this.models.length > 1
556
652
  ? html`<temba-select
@@ -558,6 +654,8 @@ export class AutoTranslate extends RapidElement {
558
654
  endpoint="${MODELS_ENDPOINT}"
559
655
  valueKey="uuid"
560
656
  .values=${selected}
657
+ .shouldExclude=${(option: LLMModel) =>
658
+ !hasLLMRole(option, 'editing')}
561
659
  ?searchable=${true}
562
660
  placeholder="Select an AI model"
563
661
  @change=${this.handleModelChange}
@@ -610,13 +708,11 @@ export class AutoTranslate extends RapidElement {
610
708
  return html`
611
709
  <div class="auto-translate-error-block">
612
710
  <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.
711
+ Any translations already applied have been kept. You can try again, or
712
+ check the AI model's settings if the problem persists.
615
713
  </p>
616
714
  ${this.errorExpanded
617
- ? html`<pre class="auto-translate-error-details">
618
- ${this.error}</pre
619
- >`
715
+ ? html`<pre class="auto-translate-error-details">${this.error}</pre>`
620
716
  : html`<button
621
717
  class="auto-translate-error-toggle"
622
718
  type="button"
@@ -15,3 +15,20 @@ export function shouldExcludeFlow(flow: any): boolean {
15
15
 
16
16
  return false;
17
17
  }
18
+
19
+ export type LLMRole = 'engine' | 'editing';
20
+
21
+ export interface LLMModel {
22
+ uuid: string;
23
+ name: string;
24
+ type?: string;
25
+ description?: string;
26
+ roles?: LLMRole[];
27
+ }
28
+
29
+ export function hasLLMRole(
30
+ model: { roles?: string[] } | null | undefined,
31
+ role: LLMRole
32
+ ): boolean {
33
+ return model?.roles?.includes(role) ?? false;
34
+ }
@@ -8,6 +8,7 @@ import {
8
8
  renderLineItem,
9
9
  getLlmIcon
10
10
  } from '../utils';
11
+ import { LLMModel, hasLLMRole } from '../flow-utils';
11
12
 
12
13
  export const split_by_llm: NodeConfig = {
13
14
  type: 'split_by_llm',
@@ -47,7 +48,8 @@ export const split_by_llm: NodeConfig = {
47
48
  searchable: true,
48
49
  valueKey: 'uuid',
49
50
  nameKey: 'name',
50
- placeholder: 'Select an LLM...'
51
+ placeholder: 'Select an LLM...',
52
+ shouldExclude: (option: LLMModel) => !hasLLMRole(option, 'engine')
51
53
  },
52
54
  input: {
53
55
  type: 'text',
@@ -3,6 +3,7 @@ import { CallLLM, Node } from '../../store/flow-definition';
3
3
  import { generateUUID, createMultiCategoryRouter } from '../../utils';
4
4
  import { html } from 'lit';
5
5
  import { validateWith } from '../utils';
6
+ import { LLMModel, hasLLMRole } from '../flow-utils';
6
7
 
7
8
  export const split_by_llm_categorize: NodeConfig = {
8
9
  type: 'split_by_llm_categorize',
@@ -19,7 +20,8 @@ export const split_by_llm_categorize: NodeConfig = {
19
20
  endpoint: '/api/internal/llms.json',
20
21
  valueKey: 'uuid',
21
22
  nameKey: 'name',
22
- placeholder: 'Select an LLM...'
23
+ placeholder: 'Select an LLM...',
24
+ shouldExclude: (option: LLMModel) => !hasLLMRole(option, 'engine')
23
25
  },
24
26
  input: {
25
27
  type: 'text',
@@ -434,7 +434,10 @@ export class RichEditor extends FieldElement {
434
434
  }
435
435
 
436
436
  private handleKeydown(e: KeyboardEvent): void {
437
- const mod = e.metaKey || e.ctrlKey;
437
+ // On macOS, Cmd is the undo/redo modifier; leave Ctrl alone so Cocoa
438
+ // text bindings like Ctrl+K (kill) and Ctrl+Y (yank) keep working.
439
+ const isMac = /Mac|iPhone|iPad/.test(navigator.userAgent);
440
+ const mod = isMac ? e.metaKey : e.ctrlKey;
438
441
 
439
442
  // Undo/redo via keyboard: preventDefault here suppresses the subsequent
440
443
  // beforeinput event, avoiding double-handling
@@ -6,13 +6,22 @@
6
6
  "uuid": "2399e7d6-fcdf-4e47-a835-f3bdb7f80938",
7
7
  "name": "GPT 4.1",
8
8
  "type": "openai",
9
- "description": "General-purpose reasoning model"
9
+ "description": "General-purpose reasoning model",
10
+ "roles": ["engine", "editing"]
10
11
  },
11
12
  {
12
13
  "uuid": "4399e7d6-fcdf-4e47-a835-f3bdb7f80938",
13
14
  "name": "GPT 5",
14
15
  "type": "openai",
15
- "description": "Latest experimental model"
16
+ "description": "Latest experimental model",
17
+ "roles": ["engine"]
18
+ },
19
+ {
20
+ "uuid": "5499e7d6-fcdf-4e47-a835-f3bdb7f80938",
21
+ "name": "GPT 4.1 Mini",
22
+ "type": "openai",
23
+ "description": "Lightweight model for editing tasks",
24
+ "roles": ["editing"]
16
25
  }
17
26
  ]
18
27
  }