@nyaruka/temba-components 0.156.12 → 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.
@@ -0,0 +1,726 @@
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
+ import { LLMModel, hasLLMRole } from './flow-utils';
11
+
12
+ interface TranslationModel {
13
+ uuid: string;
14
+ name: string;
15
+ }
16
+
17
+ const MODELS_ENDPOINT = '/api/internal/llms.json';
18
+ const ADD_MODEL_URL = '/ai/';
19
+
20
+ // Max size of the serialized JSON payload per translate request. Keeps the
21
+ // LLM's output comfortably below its token limit. Measured against the full
22
+ // payload (uuids, keys, JSON structural chars, and source strings).
23
+ const TRANSLATION_BATCH_CHAR_LIMIT = 10000;
24
+
25
+ export class AutoTranslate extends RapidElement {
26
+ static get styles() {
27
+ return css`
28
+ :host {
29
+ display: contents;
30
+ }
31
+
32
+ .auto-translate-body {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 12px;
36
+ padding: 20px;
37
+ font-size: 14px;
38
+ color: #374151;
39
+ }
40
+
41
+ .auto-translate-body p {
42
+ margin: 0;
43
+ }
44
+
45
+ .auto-translate-loading {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 8px;
49
+ font-size: 13px;
50
+ color: #6b7280;
51
+ }
52
+
53
+ .auto-translate-empty {
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 8px;
57
+ font-size: 13px;
58
+ }
59
+
60
+ .auto-translate-empty a {
61
+ color: var(--color-link);
62
+ text-decoration: none;
63
+ }
64
+
65
+ .auto-translate-empty a:hover {
66
+ text-decoration: underline;
67
+ }
68
+
69
+ .auto-translate-single-model {
70
+ font-size: 13px;
71
+ }
72
+
73
+ .auto-translate-status {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 6px;
77
+ /* align with the progress bar in the body (body has 20px padding,
78
+ dialog-footer has 10px) */
79
+ padding-left: 10px;
80
+ font-size: 12px;
81
+ color: #6b7280;
82
+ }
83
+
84
+ .auto-translate-status-spinner {
85
+ --icon-color: #6b7280;
86
+ }
87
+
88
+ .auto-translate-error-block {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 6px;
92
+ padding: 12px 14px;
93
+ background: #fef2f2;
94
+ border: 1px solid #fecaca;
95
+ border-radius: 6px;
96
+ overflow: hidden;
97
+ font-size: 13px;
98
+ color: #7f1d1d;
99
+ }
100
+
101
+ .auto-translate-error-block p {
102
+ margin: 0;
103
+ }
104
+
105
+ .auto-translate-error-help {
106
+ color: inherit;
107
+ }
108
+
109
+ .auto-translate-error-toggle {
110
+ align-self: flex-start;
111
+ margin-top: 2px;
112
+ padding: 0;
113
+ background: none;
114
+ border: none;
115
+ color: inherit;
116
+ font: inherit;
117
+ font-size: 12px;
118
+ text-decoration: underline;
119
+ cursor: pointer;
120
+ }
121
+
122
+ .auto-translate-error-details {
123
+ margin: 6px -14px -12px;
124
+ padding: 10px 14px;
125
+ background: #fff;
126
+ border-top: 1px solid #fecaca;
127
+ font-size: 12px;
128
+ color: inherit;
129
+ white-space: pre-wrap;
130
+ word-break: break-word;
131
+ }
132
+ `;
133
+ }
134
+
135
+ @property({ attribute: false })
136
+ definition: FlowDefinition | null = null;
137
+
138
+ @property({ type: String, attribute: 'language-code' })
139
+ languageCode = '';
140
+
141
+ @property({ type: Boolean })
142
+ disabled = false;
143
+
144
+ // Reactive flag the host can read to show "translating" state in UI
145
+ // adjacent to this component (e.g. the toolbar button).
146
+ @property({ type: Boolean, reflect: true })
147
+ running = false;
148
+
149
+ @state()
150
+ dialogOpen = false;
151
+
152
+ @state()
153
+ models: TranslationModel[] = [];
154
+
155
+ @state()
156
+ modelsLoading = false;
157
+
158
+ @state()
159
+ selectedModel: TranslationModel | null = null;
160
+
161
+ @state()
162
+ progress: { done: number; total: number } = { done: 0, total: 0 };
163
+
164
+ @state()
165
+ error: string | null = null;
166
+
167
+ @state()
168
+ errorExpanded = false;
169
+
170
+ @state()
171
+ interrupt = false;
172
+
173
+ // Tracks whether the dialog has ever opened so we can keep it mounted
174
+ // afterwards (so it sees its own close transition) without paying
175
+ // for an empty hidden dialog before that.
176
+ private everOpened = false;
177
+
178
+ /**
179
+ * Public entry point. If a translation is in flight, sets the interrupt
180
+ * flag; otherwise opens the model picker.
181
+ */
182
+ public async start(): Promise<void> {
183
+ if (this.disabled) {
184
+ return;
185
+ }
186
+
187
+ if (this.running) {
188
+ this.interrupt = true;
189
+ return;
190
+ }
191
+
192
+ this.error = null;
193
+ this.errorExpanded = false;
194
+ this.selectedModel = null;
195
+ this.dialogOpen = true;
196
+ await this.loadModels();
197
+
198
+ if (this.models.length === 1) {
199
+ this.selectedModel = this.models[0];
200
+ }
201
+ }
202
+
203
+ private async loadModels(): Promise<void> {
204
+ this.modelsLoading = true;
205
+ try {
206
+ const store = getStore();
207
+ const results: LLMModel[] = store
208
+ ? ((await store.getResults(MODELS_ENDPOINT, { force: true })) ?? [])
209
+ : [];
210
+ this.models = results
211
+ .filter((r) => hasLLMRole(r, 'editing'))
212
+ .map((r) => ({
213
+ uuid: r.uuid,
214
+ name: r.name
215
+ }));
216
+ } catch (err) {
217
+ console.error('Failed to load AI models', err);
218
+ this.models = [];
219
+ this.error = 'Unable to load AI models.';
220
+ } finally {
221
+ this.modelsLoading = false;
222
+ }
223
+ }
224
+
225
+ private handleModelChange(event: Event): void {
226
+ const select = event.target as any;
227
+ const next = select?.values?.[0];
228
+ this.selectedModel = next ? { uuid: next.uuid, name: next.name } : null;
229
+ }
230
+
231
+ private confirmTranslate(): void {
232
+ if (!this.selectedModel) {
233
+ return;
234
+ }
235
+ this.dialogOpen = false;
236
+ this.runAutoTranslation().catch((err) => {
237
+ console.error('Auto translation failed', err);
238
+ this.error = 'Auto translation failed. Please try again.';
239
+ this.running = false;
240
+ });
241
+ }
242
+
243
+ private cancelDialog(): void {
244
+ this.dialogOpen = false;
245
+ }
246
+
247
+ private dismissError(): void {
248
+ this.error = null;
249
+ this.errorExpanded = false;
250
+ this.progress = { done: 0, total: 0 };
251
+ }
252
+
253
+ /**
254
+ * Builds batches of translation requests from all untranslated entries,
255
+ * grouping so each request's serialized payload stays under
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).
262
+ */
263
+ private buildTranslationBatches(): {
264
+ keyToEntries: Map<string, TranslationEntry[]>;
265
+ batches: { items: Record<string, string[]> }[];
266
+ preTranslated: Map<string, string[]>;
267
+ duplicateKeyToCanonical: Map<string, string>;
268
+ } {
269
+ const keyToEntries = new Map<string, TranslationEntry[]>();
270
+ const batches: { items: Record<string, string[]> }[] = [];
271
+ const preTranslated = new Map<string, string[]>();
272
+ const duplicateKeyToCanonical = new Map<string, string>();
273
+
274
+ if (!this.definition) {
275
+ return { keyToEntries, batches, preTranslated, duplicateKeyToCanonical };
276
+ }
277
+
278
+ const bundles = buildTranslationBundles(this.definition, this.languageCode);
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[]>();
310
+
311
+ for (const bundle of bundles) {
312
+ for (const entry of bundle.translations) {
313
+ if (entry.to && entry.to.trim().length > 0) {
314
+ continue;
315
+ }
316
+ if (!entry.from || entry.from.trim().length === 0) {
317
+ continue;
318
+ }
319
+ const key = `${entry.uuid}:${entry.attribute}`;
320
+ const list = keyToEntries.get(key) || [];
321
+ list.push(entry);
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;
348
+ }
349
+
350
+ canonicalByContent.set(contentKey, key);
351
+ canonicalKeysWithValues.push({ key, values });
352
+ }
353
+
354
+ const source = this.definition.language;
355
+ const target = this.languageCode;
356
+ const measurePayload = (items: Record<string, string[]>): number =>
357
+ JSON.stringify({ source, target, items }).length;
358
+
359
+ let current: Record<string, string[]> = {};
360
+
361
+ for (const { key, values } of canonicalKeysWithValues) {
362
+ const tentative = { ...current, [key]: values };
363
+ const tentativeSize = measurePayload(tentative);
364
+ const batchHasItems = Object.keys(current).length > 0;
365
+
366
+ if (tentativeSize > TRANSLATION_BATCH_CHAR_LIMIT && batchHasItems) {
367
+ // a single oversized entry still ships on its own
368
+ batches.push({ items: current });
369
+ current = { [key]: values };
370
+ } else {
371
+ current = tentative;
372
+ }
373
+ }
374
+
375
+ if (Object.keys(current).length > 0) {
376
+ batches.push({ items: current });
377
+ }
378
+
379
+ return { keyToEntries, batches, preTranslated, duplicateKeyToCanonical };
380
+ }
381
+
382
+ /**
383
+ * Drives the batch loop. Public so tests can invoke directly with a
384
+ * pre-set selectedModel.
385
+ */
386
+ public async runAutoTranslation(): Promise<void> {
387
+ if (
388
+ !this.definition ||
389
+ !this.selectedModel ||
390
+ this.languageCode === this.definition.language
391
+ ) {
392
+ return;
393
+ }
394
+
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
+
407
+ if (batches.length === 0) {
408
+ return;
409
+ }
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
+
420
+ this.running = true;
421
+ this.interrupt = false;
422
+ this.progress = { done: 0, total: batches.length };
423
+
424
+ const source = this.definition.language;
425
+ const target = this.languageCode;
426
+ const url = `/llm/translate/${this.selectedModel.uuid}/`;
427
+ const store = getStore();
428
+ if (!store) {
429
+ this.running = false;
430
+ return;
431
+ }
432
+
433
+ for (let i = 0; i < batches.length; i++) {
434
+ if (this.interrupt) {
435
+ break;
436
+ }
437
+
438
+ try {
439
+ const response = await store.postJSON(url, {
440
+ source,
441
+ target,
442
+ items: batches[i].items
443
+ });
444
+
445
+ if (response.status >= 200 && response.status < 300) {
446
+ const returned: Record<string, string[]> = response.json?.items || {};
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);
457
+ } else {
458
+ this.error =
459
+ response.json?.error ||
460
+ `Translate request failed (${response.status}).`;
461
+ break;
462
+ }
463
+ } catch (err) {
464
+ console.error('Translate request failed', err);
465
+ this.error = 'Translate request failed.';
466
+ break;
467
+ }
468
+
469
+ this.progress = { done: i + 1, total: batches.length };
470
+ }
471
+
472
+ this.running = false;
473
+ this.interrupt = false;
474
+ }
475
+
476
+ private applyBatchTranslations(
477
+ returned: Record<string, string[]>,
478
+ keyToEntries: Map<string, TranslationEntry[]>
479
+ ): void {
480
+ const store = getStore();
481
+ if (!store || !this.definition) {
482
+ return;
483
+ }
484
+
485
+ // group updates by uuid so multiple attributes merge into one write
486
+ const updatesByUuid = new Map<string, Record<string, any>>();
487
+
488
+ for (const [key, values] of Object.entries(returned)) {
489
+ const entries = keyToEntries.get(key);
490
+ if (!entries || entries.length === 0) {
491
+ continue;
492
+ }
493
+ const [firstEntry] = entries;
494
+ const uuid = firstEntry.uuid;
495
+ const attribute = firstEntry.attribute;
496
+
497
+ keyToEntries.delete(key);
498
+
499
+ if (!values || values.length === 0) {
500
+ continue;
501
+ }
502
+
503
+ // read from the live store rather than this.definition, which can
504
+ // lag behind across batches and cause earlier attributes to be
505
+ // overwritten when multiple attrs on the same uuid land in
506
+ // different batches
507
+ const liveDefinition = zustand.getState().flowDefinition;
508
+ const existing =
509
+ liveDefinition?.localization?.[this.languageCode]?.[uuid] || {};
510
+ const merged = updatesByUuid.get(uuid) || { ...existing };
511
+ merged[attribute] = values;
512
+ updatesByUuid.set(uuid, merged);
513
+ }
514
+
515
+ for (const [uuid, merged] of updatesByUuid.entries()) {
516
+ store.getState().updateLocalization(this.languageCode, uuid, merged);
517
+ zustand.getState().markAutoTranslated(
518
+ this.languageCode,
519
+ uuid,
520
+ Object.keys(merged).filter(
521
+ (k) => merged[k] && (merged[k] as any[]).length > 0
522
+ )
523
+ );
524
+ }
525
+ }
526
+
527
+ protected updated(changed: PropertyValues): void {
528
+ if (changed.has('running')) {
529
+ // emit a state-change event so the host can update adjacent UI
530
+ // (e.g. toolbar button) without polling
531
+ this.dispatchEvent(
532
+ new CustomEvent('temba-auto-translate-changed', {
533
+ detail: { running: this.running },
534
+ bubbles: true,
535
+ composed: true
536
+ })
537
+ );
538
+ }
539
+ }
540
+
541
+ private handleDialogButton(event: CustomEvent): void {
542
+ const name = event.detail?.button?.name;
543
+ if (this.error) {
544
+ // only the Dismiss button shows in the error state
545
+ this.dismissError();
546
+ return;
547
+ }
548
+ if (this.running) {
549
+ // only the Stop button shows while running
550
+ if (!this.interrupt) {
551
+ this.interrupt = true;
552
+ }
553
+ return;
554
+ }
555
+ if (name === 'Translate') {
556
+ this.confirmTranslate();
557
+ } else {
558
+ this.cancelDialog();
559
+ }
560
+ }
561
+
562
+ public render(): TemplateResult | string {
563
+ const showPicker = this.dialogOpen;
564
+ const showProgress = this.running || !!this.error;
565
+ const open = showPicker || showProgress;
566
+
567
+ // Skip rendering until the dialog has been opened at least once so we
568
+ // don't pay for an empty hidden dialog on every editor instance.
569
+ if (!open && !this.everOpened) {
570
+ return '';
571
+ }
572
+ if (open) {
573
+ this.everOpened = true;
574
+ }
575
+
576
+ let header = 'Auto Translation';
577
+ let body: TemplateResult = html``;
578
+ let gutter: TemplateResult | string = '';
579
+ let primary = '';
580
+ let cancel = '';
581
+ let disabled = false;
582
+
583
+ if (this.error) {
584
+ header = 'Problem with AI Model';
585
+ body = this.renderErrorBody();
586
+ cancel = 'Dismiss';
587
+ } else if (this.running) {
588
+ body = this.renderRunningBody();
589
+ gutter = this.renderRunningGutter();
590
+ // Stop is the primary so the dialog does NOT auto-close on click;
591
+ // we close it ourselves once the in-flight batch returns
592
+ primary = 'Stop';
593
+ disabled = this.interrupt;
594
+ } else if (showPicker) {
595
+ const noModels = !this.modelsLoading && this.models.length === 0;
596
+ body = this.renderPickerBody();
597
+ cancel = noModels ? 'Close' : 'Cancel';
598
+ primary = noModels ? '' : 'Translate';
599
+ disabled = this.modelsLoading || noModels || !this.selectedModel;
600
+ }
601
+
602
+ // We always render the dialog (after first open) so it sees the
603
+ // open: true -> false transition and runs its body scroll/unlock
604
+ // cleanup; otherwise body styles can stay stuck after auto-translate
605
+ // completes.
606
+ return html`
607
+ <temba-dialog
608
+ header=${header}
609
+ .open=${open}
610
+ size="small"
611
+ primaryButtonName=${primary}
612
+ cancelButtonName=${cancel}
613
+ ?disabled=${disabled}
614
+ variant="flat"
615
+ @temba-button-clicked=${this.handleDialogButton}
616
+ >
617
+ <div class="auto-translate-body">${body}</div>
618
+ ${gutter ? html`<div slot="gutter">${gutter}</div>` : ''}
619
+ </temba-dialog>
620
+ `;
621
+ }
622
+
623
+ private renderPickerBody(): TemplateResult {
624
+ if (this.modelsLoading) {
625
+ return html`<div class="auto-translate-loading">
626
+ <temba-loading units="3" size="8"></temba-loading>
627
+ </div>`;
628
+ }
629
+
630
+ if (this.models.length === 0) {
631
+ return html`
632
+ <div class="auto-translate-empty">
633
+ <p>You need to add an AI model before you can auto translate.</p>
634
+ <p>
635
+ <a href="${ADD_MODEL_URL}" target="_blank" rel="noopener"
636
+ >Manage AI models</a
637
+ >
638
+ </p>
639
+ </div>
640
+ `;
641
+ }
642
+
643
+ const selected = this.selectedModel ? [this.selectedModel] : [];
644
+ const languageName = getLanguageDisplayName(this.languageCode);
645
+ return html`
646
+ <p>
647
+ All remaining text for <strong>${languageName}</strong> will be
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.
650
+ </p>
651
+ ${this.models.length > 1
652
+ ? html`<temba-select
653
+ class="auto-translate-model-select"
654
+ endpoint="${MODELS_ENDPOINT}"
655
+ valueKey="uuid"
656
+ .values=${selected}
657
+ .shouldExclude=${(option: LLMModel) =>
658
+ !hasLLMRole(option, 'editing')}
659
+ ?searchable=${true}
660
+ placeholder="Select an AI model"
661
+ @change=${this.handleModelChange}
662
+ ></temba-select>`
663
+ : html`<div class="auto-translate-single-model">
664
+ Using <strong>${this.models[0]?.name}</strong>
665
+ </div>`}
666
+ `;
667
+ }
668
+
669
+ private renderRunningBody(): TemplateResult {
670
+ const { done, total } = this.progress;
671
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
672
+
673
+ return html`
674
+ <temba-progress
675
+ .total=${total}
676
+ .current=${done}
677
+ .pct=${pct}
678
+ showPercentage
679
+ ></temba-progress>
680
+ `;
681
+ }
682
+
683
+ private renderRunningGutter(): TemplateResult {
684
+ const { done, total } = this.progress;
685
+ // current batch is 1-indexed: while batch 0 is in flight, done is still
686
+ // 0, so we display "1 of N"
687
+ const currentBatch = Math.min(done + 1, total);
688
+ const statusText = this.interrupt
689
+ ? 'Stopping...'
690
+ : total > 1
691
+ ? `Translating ${currentBatch} of ${total}`
692
+ : 'Translating';
693
+
694
+ return html`
695
+ <div class="auto-translate-status">
696
+ <temba-icon
697
+ class="auto-translate-status-spinner"
698
+ name="progress_spinner"
699
+ size="0.9"
700
+ spin
701
+ ></temba-icon>
702
+ <span>${statusText}</span>
703
+ </div>
704
+ `;
705
+ }
706
+
707
+ private renderErrorBody(): TemplateResult {
708
+ return html`
709
+ <div class="auto-translate-error-block">
710
+ <p class="auto-translate-error-help">
711
+ Any translations already applied have been kept. You can try again, or
712
+ check the AI model's settings if the problem persists.
713
+ </p>
714
+ ${this.errorExpanded
715
+ ? html`<pre class="auto-translate-error-details">${this.error}</pre>`
716
+ : html`<button
717
+ class="auto-translate-error-toggle"
718
+ type="button"
719
+ @click=${() => (this.errorExpanded = true)}
720
+ >
721
+ Show details
722
+ </button>`}
723
+ </div>
724
+ `;
725
+ }
726
+ }