@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/CHANGELOG.md +8 -0
- package/dist/temba-components.js +144 -144
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Options.ts +13 -0
- package/src/flow/AutoTranslate.ts +121 -25
- package/src/flow/flow-utils.ts +17 -0
- package/src/flow/nodes/split_by_llm.ts +3 -1
- package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
- package/src/form/RichEditor.ts +4 -1
- package/static/api/llms.json +11 -2
package/package.json
CHANGED
package/src/display/Options.ts
CHANGED
|
@@ -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 =
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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,
|
|
293
|
-
const
|
|
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]:
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
package/src/flow/flow-utils.ts
CHANGED
|
@@ -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',
|
package/src/form/RichEditor.ts
CHANGED
|
@@ -434,7 +434,10 @@ export class RichEditor extends FieldElement {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
private handleKeydown(e: KeyboardEvent): void {
|
|
437
|
-
|
|
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
|
package/static/api/llms.json
CHANGED
|
@@ -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
|
}
|