@procore/ai-translations 0.8.1 → 0.9.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.
@@ -637,6 +637,7 @@ var TRANSLATION_COMPLETE_EVENT_NAME = "ai-translation-completed";
637
637
  var RERENDER_EVENT_NAME = "ai-translations-component-rerender";
638
638
  var TRANSLATION_PROGRESS_EVENT_NAME = "ai-translations-progress";
639
639
  var MODEL_DOWNLOAD_PROGRESS_EVENT_NAME = "ai-translations-model-download-progress";
640
+ var MODEL_CONSENT_REQUIRED_EVENT_NAME = "ai-translations-model-consent-required";
640
641
 
641
642
  // src/utils/eventHandler.ts
642
643
  var EventHandler = class {
@@ -717,24 +718,51 @@ var EventHandler = class {
717
718
  * Not tool-scoped — a Chrome language model is shared across the entire
718
719
  * browser session, so all Provider instances receive it.
719
720
  */
720
- publishModelDownloadProgressEvent(loaded, total, progress) {
721
+ publishModelDownloadProgressEvent(loaded, total, progress, sourceLanguage, targetLanguage) {
721
722
  this.aiTranslationEvents.publish(MODEL_DOWNLOAD_PROGRESS_EVENT_NAME, {
722
723
  loaded,
723
724
  total,
724
725
  progress,
725
- isComplete: loaded === total
726
+ isComplete: loaded === total,
727
+ sourceLanguage,
728
+ targetLanguage
726
729
  });
727
730
  }
728
731
  subscribeToModelDownloadProgressEvent(callback) {
729
732
  return this.aiTranslationEvents.subscribe(
730
733
  MODEL_DOWNLOAD_PROGRESS_EVENT_NAME,
731
734
  (detail) => {
732
- var _a, _b, _c, _d;
735
+ var _a, _b, _c, _d, _e, _f;
733
736
  return callback({
734
737
  loaded: ((_a = detail.data) == null ? void 0 : _a.loaded) ?? 0,
735
738
  total: ((_b = detail.data) == null ? void 0 : _b.total) ?? 0,
736
739
  progress: ((_c = detail.data) == null ? void 0 : _c.progress) ?? 0,
737
- isComplete: ((_d = detail.data) == null ? void 0 : _d.isComplete) ?? false
740
+ isComplete: ((_d = detail.data) == null ? void 0 : _d.isComplete) ?? false,
741
+ sourceLanguage: ((_e = detail.data) == null ? void 0 : _e.sourceLanguage) ?? "",
742
+ targetLanguage: ((_f = detail.data) == null ? void 0 : _f.targetLanguage) ?? ""
743
+ });
744
+ }
745
+ );
746
+ }
747
+ /**
748
+ * Publishes a request for the user to consent to downloading a specific
749
+ * source -> target language model. Like the download-progress event this is
750
+ * NOT tool-scoped — a language model is shared across the whole session.
751
+ */
752
+ publishModelConsentRequiredEvent(sourceLanguage, targetLanguage) {
753
+ this.aiTranslationEvents.publish(MODEL_CONSENT_REQUIRED_EVENT_NAME, {
754
+ sourceLanguage,
755
+ targetLanguage
756
+ });
757
+ }
758
+ subscribeToModelConsentRequiredEvent(callback) {
759
+ return this.aiTranslationEvents.subscribe(
760
+ MODEL_CONSENT_REQUIRED_EVENT_NAME,
761
+ (detail) => {
762
+ var _a, _b;
763
+ return callback({
764
+ sourceLanguage: ((_a = detail.data) == null ? void 0 : _a.sourceLanguage) ?? "",
765
+ targetLanguage: ((_b = detail.data) == null ? void 0 : _b.targetLanguage) ?? ""
738
766
  });
739
767
  }
740
768
  );
@@ -744,6 +772,113 @@ var EventHandler = class {
744
772
  }
745
773
  };
746
774
 
775
+ // src/utils/modelDownloadGate.ts
776
+ var ModelDownloadGateImpl = class _ModelDownloadGateImpl {
777
+ constructor() {
778
+ __publicField(this, "eventHandler", null);
779
+ __publicField(this, "ready", /* @__PURE__ */ new Set());
780
+ __publicField(this, "pending", /* @__PURE__ */ new Map());
781
+ __publicField(this, "resolvers", /* @__PURE__ */ new Map());
782
+ __publicField(this, "rejecters", /* @__PURE__ */ new Map());
783
+ }
784
+ getEventHandler() {
785
+ if (!this.eventHandler) {
786
+ this.eventHandler = new EventHandler("__chrome_model__");
787
+ }
788
+ return this.eventHandler;
789
+ }
790
+ static key(sourceLanguage, targetLanguage) {
791
+ return `${sourceLanguage}->${targetLanguage}`;
792
+ }
793
+ /**
794
+ * Resolves once the model for `sourceLanguage -> targetLanguage` is downloaded
795
+ * and ready. Pauses (stays pending) until the user confirms the download and
796
+ * it completes. Re-prompts are deduped per model.
797
+ */
798
+ ensureModelReady(sourceLanguage, targetLanguage) {
799
+ const key = _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage);
800
+ if (this.ready.has(key)) {
801
+ return Promise.resolve();
802
+ }
803
+ const existing = this.pending.get(key);
804
+ if (existing) {
805
+ return existing;
806
+ }
807
+ const promise = new Promise((resolve, reject) => {
808
+ this.resolvers.set(key, resolve);
809
+ this.rejecters.set(key, reject);
810
+ });
811
+ this.pending.set(key, promise);
812
+ this.getEventHandler().publishModelConsentRequiredEvent(
813
+ sourceLanguage,
814
+ targetLanguage
815
+ );
816
+ return promise;
817
+ }
818
+ /**
819
+ * Marks a model as ready and resolves any waiter. Resolved exclusively when
820
+ * `downloadModel` resolves (wired in the Provider). This guarantees Chrome's
821
+ * `Translator.create()` has returned and the translator instance is cached in
822
+ * `chromeTranslatorRegistry`, so the resumed translation reuses it instead of
823
+ * calling `Translator.create()` again outside the user gesture (which throws
824
+ * `NotAllowedError`). It is deliberately NOT resolved on the intermediate
825
+ * `MODEL_DOWNLOAD_PROGRESS` `isComplete` event, which fires before
826
+ * `Translator.create()` returns.
827
+ */
828
+ markReady(sourceLanguage, targetLanguage) {
829
+ const key = _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage);
830
+ this.ready.add(key);
831
+ const resolve = this.resolvers.get(key);
832
+ if (resolve) {
833
+ resolve();
834
+ }
835
+ this.clearPending(key);
836
+ }
837
+ /**
838
+ * Marks a model download as failed and rejects any waiter so the paused
839
+ * translation run can move past this text instead of hanging forever. Called
840
+ * when `downloadModel` rejects (e.g. Chrome returns 404 for a pair it
841
+ * reported as downloadable). The pair is NOT cached as ready, so it can be
842
+ * re-attempted later.
843
+ */
844
+ markFailed(sourceLanguage, targetLanguage, error) {
845
+ const key = _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage);
846
+ const reject = this.rejecters.get(key);
847
+ if (reject) {
848
+ reject(
849
+ error instanceof Error ? error : new Error(`Model download failed for ${key}`)
850
+ );
851
+ }
852
+ this.clearPending(key);
853
+ }
854
+ isReady(sourceLanguage, targetLanguage) {
855
+ return this.ready.has(
856
+ _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage)
857
+ );
858
+ }
859
+ /**
860
+ * Clears all gate state. Intended for test teardown so cached "ready" models
861
+ * do not leak between cases.
862
+ */
863
+ reset() {
864
+ this.ready.clear();
865
+ this.pending.clear();
866
+ this.resolvers.clear();
867
+ this.rejecters.clear();
868
+ }
869
+ clearPending(key) {
870
+ this.resolvers.delete(key);
871
+ this.rejecters.delete(key);
872
+ this.pending.delete(key);
873
+ }
874
+ };
875
+ var modelDownloadGate = new ModelDownloadGateImpl();
876
+
877
+ // src/utils/language.ts
878
+ function normalizeLanguage(language) {
879
+ return language.split("-")[0] ?? language;
880
+ }
881
+
747
882
  // src/utils/renderVersionManager.ts
748
883
  var RenderVersionManager = class {
749
884
  constructor() {
@@ -802,27 +937,30 @@ var TranslationManager = class {
802
937
  this.translationProgress.total = queueManager.queueSize(
803
938
  this.currentTranslatorStrategy
804
939
  );
805
- do {
806
- for (const batch of queueManager.generateBatches(
807
- this.translator.getConfig()
808
- )) {
809
- batch.forEach((entry) => this.mfeToBeNotified.add(entry.tool));
810
- const result = await this.translator.processTranslations(
811
- batch.map(
812
- (entry) => this.convertTranslationQueueEntryToTranslationRequest(entry)
813
- )
814
- );
815
- this.setTranslationProgress(batch.length);
816
- await this.updateDatabaseWithTranslations(result);
817
- await this.notifyTranslationCompleted();
940
+ try {
941
+ do {
942
+ for (const batch of queueManager.generateBatches(
943
+ this.translator.getConfig()
944
+ )) {
945
+ batch.forEach((entry) => this.mfeToBeNotified.add(entry.tool));
946
+ const result = await this.translator.processTranslations(
947
+ batch.map(
948
+ (entry) => this.convertTranslationQueueEntryToTranslationRequest(entry)
949
+ )
950
+ );
951
+ this.setTranslationProgress(batch.length);
952
+ await this.updateDatabaseWithTranslations(result);
953
+ await this.notifyTranslationCompleted();
954
+ }
955
+ } while (queueManager.queueSize(this.currentTranslatorStrategy) > 0);
956
+ this.resetTranslationProgress();
957
+ this.publishTranslationProgress();
958
+ } finally {
959
+ if (this.currentTranslatorStrategy === "frontend_translations") {
960
+ globalThis._FRONTEND_AI_TRANSLATION_IN_PROGRESS_ = false;
961
+ } else {
962
+ globalThis._BACKEND_AI_TRANSLATION_IN_PROGRESS_ = false;
818
963
  }
819
- } while (queueManager.queueSize(this.currentTranslatorStrategy) > 0);
820
- this.resetTranslationProgress();
821
- this.publishTranslationProgress();
822
- if (this.currentTranslatorStrategy === "frontend_translations") {
823
- globalThis._FRONTEND_AI_TRANSLATION_IN_PROGRESS_ = false;
824
- } else {
825
- globalThis._BACKEND_AI_TRANSLATION_IN_PROGRESS_ = false;
826
964
  }
827
965
  }
828
966
  async updateDatabaseWithTranslations(translationResult) {
@@ -992,6 +1130,16 @@ var Client = class {
992
1130
  return this.createTranslationErrorResponse(requests, errorMessage);
993
1131
  }
994
1132
  }
1133
+ /**
1134
+ * No-op: backend translation does not manage on-device models, so there is
1135
+ * nothing to download client-side. The parameters are kept to match the
1136
+ * {@link TranslatorClientAdapter.downloadModel} signature and make the no-op
1137
+ * intent explicit to callers.
1138
+ */
1139
+ async downloadModel(sourceLanguage, targetLanguage) {
1140
+ void sourceLanguage;
1141
+ void targetLanguage;
1142
+ }
995
1143
  createTranslationErrorResponse(requests, errorMessage) {
996
1144
  return {
997
1145
  responseBody: requests.map((req) => ({
@@ -1111,6 +1259,7 @@ var _ChromeTranslator = class _ChromeTranslator {
1111
1259
  }
1112
1260
  }
1113
1261
  static async init(sourceLanguage, targetLanguage) {
1262
+ this.ensureRegistry();
1114
1263
  if (globalThis.chromeTranslatorRegistry.has(
1115
1264
  this.generateKey(sourceLanguage, targetLanguage)
1116
1265
  )) {
@@ -1125,7 +1274,30 @@ var _ChromeTranslator = class _ChromeTranslator {
1125
1274
  new _ChromeTranslator(translator)
1126
1275
  );
1127
1276
  }
1128
- static async createTranslator(source, target) {
1277
+ /**
1278
+ * Downloads the on-device model for a source -> target language pair. Must be
1279
+ * invoked from a user gesture (e.g. a click handler), since Chrome requires an
1280
+ * active gesture to start a model download.
1281
+ */
1282
+ static async downloadModel(sourceLanguage, targetLanguage) {
1283
+ await this.init(
1284
+ normalizeLanguage(sourceLanguage),
1285
+ normalizeLanguage(targetLanguage)
1286
+ );
1287
+ }
1288
+ /**
1289
+ * Creates a Chrome translator instance for the pair.
1290
+ *
1291
+ * @param publishProgress - When true, `downloadprogress` events are forwarded
1292
+ * as `MODEL_DOWNLOAD_PROGRESS` events (driving the download-progress popup).
1293
+ * This should only be true for an explicit, consent-driven download
1294
+ * (`init`/`downloadModel`). When creating an already-downloaded
1295
+ * (`available`) model in the translate loop, Chrome still emits
1296
+ * `downloadprogress` while loading the pack from disk into memory; forwarding
1297
+ * those would wrongly show a "downloading" popup for a model that is already
1298
+ * downloaded, so the translate-loop path passes `false`.
1299
+ */
1300
+ static async createTranslator(source, target, publishProgress = true) {
1129
1301
  const key = this.generateKey(source, target);
1130
1302
  let resolveReady;
1131
1303
  const readyPromise = new Promise((resolve) => {
@@ -1142,11 +1314,15 @@ var _ChromeTranslator = class _ChromeTranslator {
1142
1314
  const loaded = e.loaded ?? 0;
1143
1315
  const total = e.total ?? 0;
1144
1316
  const progress = total > 0 ? Math.round(loaded / total * 100) : 0;
1145
- getModelDownloadEventHandler().publishModelDownloadProgressEvent(
1146
- loaded,
1147
- total,
1148
- progress
1149
- );
1317
+ if (publishProgress) {
1318
+ getModelDownloadEventHandler().publishModelDownloadProgressEvent(
1319
+ loaded,
1320
+ total,
1321
+ progress,
1322
+ source,
1323
+ target
1324
+ );
1325
+ }
1150
1326
  if (total > 0 && loaded >= total) {
1151
1327
  resolveReady();
1152
1328
  }
@@ -1164,26 +1340,27 @@ var _ChromeTranslator = class _ChromeTranslator {
1164
1340
  static async translate(text, targetLanguage) {
1165
1341
  var _a;
1166
1342
  this.ensureRegistry();
1343
+ const normalizedTarget = normalizeLanguage(targetLanguage);
1344
+ let sourceLanguage = normalizedTarget;
1167
1345
  try {
1168
1346
  const detector = await ChromeLanguageDetector.getInstance();
1169
- const sourceLanguage = await detector.detectLanguage(text);
1170
- const key = this.generateKey(sourceLanguage, targetLanguage);
1171
- if (!this.isSupportedLanguage(sourceLanguage) || !this.isSupportedLanguage(targetLanguage)) {
1347
+ sourceLanguage = normalizeLanguage(await detector.detectLanguage(text));
1348
+ if (!this.isSupportedLanguage(sourceLanguage) || !this.isSupportedLanguage(normalizedTarget)) {
1172
1349
  return {
1173
1350
  translation: text,
1174
1351
  sourceLanguage,
1175
1352
  success: false,
1176
1353
  retryable: false,
1177
- errorMessage: `Unsupported language pair: ${sourceLanguage} \u2192 ${targetLanguage}`
1354
+ errorMessage: `Unsupported language pair: ${sourceLanguage} \u2192 ${normalizedTarget}`
1178
1355
  };
1179
1356
  }
1180
- const existingInstance = globalThis.chromeTranslatorRegistry.get(key);
1181
- if (!existingInstance) {
1182
- const translatorCapabilities = await Translator.availability({
1357
+ const key = this.generateKey(sourceLanguage, normalizedTarget);
1358
+ if (!globalThis.chromeTranslatorRegistry.has(key)) {
1359
+ const availability = await Translator.availability({
1183
1360
  sourceLanguage,
1184
- targetLanguage
1361
+ targetLanguage: normalizedTarget
1185
1362
  });
1186
- if (translatorCapabilities === "unavailable") {
1363
+ if (availability === "unavailable") {
1187
1364
  return {
1188
1365
  translation: text,
1189
1366
  sourceLanguage,
@@ -1192,47 +1369,94 @@ var _ChromeTranslator = class _ChromeTranslator {
1192
1369
  errorMessage: "Translation not supported"
1193
1370
  };
1194
1371
  }
1195
- const chromeTranslator = await this.createTranslator(
1196
- sourceLanguage,
1197
- targetLanguage
1198
- );
1199
- if (!globalThis.chromeTranslatorRegistry.has(key)) {
1200
- globalThis.chromeTranslatorRegistry.set(
1201
- key,
1202
- new _ChromeTranslator(chromeTranslator)
1203
- );
1372
+ if (availability !== "available") {
1373
+ const isUserActivationActive = typeof navigator !== "undefined" && (((_a = navigator.userActivation) == null ? void 0 : _a.isActive) ?? false);
1374
+ if (isUserActivationActive) {
1375
+ await _ChromeTranslator.downloadModel(
1376
+ sourceLanguage,
1377
+ normalizedTarget
1378
+ );
1379
+ modelDownloadGate.markReady(sourceLanguage, normalizedTarget);
1380
+ } else {
1381
+ await modelDownloadGate.ensureModelReady(
1382
+ sourceLanguage,
1383
+ normalizedTarget
1384
+ );
1385
+ }
1204
1386
  }
1205
1387
  }
1206
- const instance = globalThis.chromeTranslatorRegistry.get(key);
1207
- await this.waitForReady(key);
1208
- this.translatorReadyPromises.delete(key);
1209
- const translation = await ((_a = instance.translator) == null ? void 0 : _a.translate(text));
1210
- if (!translation) {
1211
- return {
1212
- translation: text,
1213
- sourceLanguage,
1214
- success: false,
1215
- errorMessage: "Translation failed",
1216
- retryable: false
1217
- };
1218
- }
1219
- return {
1220
- translation,
1388
+ return await this.createInstanceAndTranslate(
1389
+ text,
1221
1390
  sourceLanguage,
1222
- success: true,
1223
- retryable: true
1224
- };
1391
+ normalizedTarget
1392
+ );
1225
1393
  } catch (error) {
1394
+ const isGestureRequired = error instanceof DOMException && error.name === "NotAllowedError";
1395
+ if (isGestureRequired) {
1396
+ try {
1397
+ await modelDownloadGate.ensureModelReady(
1398
+ sourceLanguage,
1399
+ normalizedTarget
1400
+ );
1401
+ return await this.createInstanceAndTranslate(
1402
+ text,
1403
+ sourceLanguage,
1404
+ normalizedTarget
1405
+ );
1406
+ } catch (retryError) {
1407
+ void retryError;
1408
+ }
1409
+ }
1226
1410
  const message = error instanceof Error ? error.message : "Unknown error";
1227
- const isPermanentFailure = error instanceof DOMException && error.name === "NotSupportedError";
1228
1411
  return {
1229
1412
  translation: text,
1230
- sourceLanguage: targetLanguage,
1413
+ sourceLanguage,
1231
1414
  success: false,
1232
1415
  errorMessage: message,
1233
- retryable: !isPermanentFailure
1416
+ retryable: false,
1417
+ requiresGesture: isGestureRequired
1418
+ };
1419
+ }
1420
+ }
1421
+ /**
1422
+ * Creates the translator instance for the pair if it is not already cached,
1423
+ * waits for the model to be ready, and translates the text. The model is
1424
+ * expected to already be downloaded (or downloadable without a gesture) by
1425
+ * the time this is called.
1426
+ */
1427
+ static async createInstanceAndTranslate(text, sourceLanguage, targetLanguage) {
1428
+ var _a;
1429
+ const key = this.generateKey(sourceLanguage, targetLanguage);
1430
+ if (!globalThis.chromeTranslatorRegistry.has(key)) {
1431
+ const chromeTranslator = await this.createTranslator(
1432
+ sourceLanguage,
1433
+ targetLanguage,
1434
+ false
1435
+ );
1436
+ globalThis.chromeTranslatorRegistry.set(
1437
+ key,
1438
+ new _ChromeTranslator(chromeTranslator)
1439
+ );
1440
+ }
1441
+ const instance = globalThis.chromeTranslatorRegistry.get(key);
1442
+ await this.waitForReady(key);
1443
+ this.translatorReadyPromises.delete(key);
1444
+ const translation = await ((_a = instance.translator) == null ? void 0 : _a.translate(text));
1445
+ if (!translation) {
1446
+ return {
1447
+ translation: text,
1448
+ sourceLanguage,
1449
+ success: false,
1450
+ errorMessage: "Translation failed",
1451
+ retryable: false
1234
1452
  };
1235
1453
  }
1454
+ return {
1455
+ translation,
1456
+ sourceLanguage,
1457
+ success: true,
1458
+ retryable: true
1459
+ };
1236
1460
  }
1237
1461
  static generateKey(sourceLanguage, targetLanguage) {
1238
1462
  return `${sourceLanguage.toUpperCase()}-${targetLanguage.toUpperCase()}`;
@@ -1277,7 +1501,8 @@ var Client2 = class {
1277
1501
  translation: result.translation,
1278
1502
  sourceLanguage: result.sourceLanguage,
1279
1503
  isTranslated: result.success,
1280
- retryable: result.retryable
1504
+ retryable: result.retryable,
1505
+ requiresGesture: result.requiresGesture ?? false
1281
1506
  }
1282
1507
  ],
1283
1508
  targetLanguage: request.targetLanguage,
@@ -1292,6 +1517,9 @@ var Client2 = class {
1292
1517
  errorMessage
1293
1518
  };
1294
1519
  }
1520
+ async downloadModel(sourceLanguage, targetLanguage) {
1521
+ await ChromeTranslator.downloadModel(sourceLanguage, targetLanguage);
1522
+ }
1295
1523
  getConfig() {
1296
1524
  return this.config;
1297
1525
  }
@@ -1332,6 +1560,10 @@ var Client3 = class {
1332
1560
  const translator = this.getFrontendTranslator();
1333
1561
  return await translator.translateBatch(...args);
1334
1562
  }
1563
+ async downloadModel(sourceLanguage, targetLanguage) {
1564
+ const translator = this.getFrontendTranslator();
1565
+ await translator.downloadModel(sourceLanguage, targetLanguage);
1566
+ }
1335
1567
  };
1336
1568
 
1337
1569
  // src/translators/translator.ts
@@ -1346,6 +1578,17 @@ var Translator2 = class {
1346
1578
  const translatorClient = this.getTranslatorClient();
1347
1579
  return await translatorClient.translateBatch(translationRequests);
1348
1580
  }
1581
+ /**
1582
+ * Downloads the on-device model for a source -> target language pair via the
1583
+ * active translator client. Must be invoked from a user gesture for clients
1584
+ * (e.g. Chrome) that require one to start a model download.
1585
+ */
1586
+ async downloadModel(sourceLanguage, targetLanguage) {
1587
+ await this.getTranslatorClient().downloadModel(
1588
+ sourceLanguage,
1589
+ targetLanguage
1590
+ );
1591
+ }
1349
1592
  setTranslatorClient(config2) {
1350
1593
  if (config2.getToolConfig().strategy === "frontend_translations") {
1351
1594
  this.translatorClient = new Client3(config2);
@@ -1464,6 +1707,9 @@ function AITranslationInnerProvider(props) {
1464
1707
  } = props;
1465
1708
  const [translationProgress, setTranslationProgress] = useState(null);
1466
1709
  const [modelDownloadProgress, setModelDownloadProgress] = useState(null);
1710
+ const [modelConsent, setModelConsent] = useState(
1711
+ null
1712
+ );
1467
1713
  const [config2, setConfig] = useState(new Config());
1468
1714
  const translator = useRef(new Translator2(config2));
1469
1715
  const eventHandler = useRef(new EventHandler(tool));
@@ -1515,6 +1761,14 @@ function AITranslationInnerProvider(props) {
1515
1761
  );
1516
1762
  return () => unsubscribe();
1517
1763
  }, []);
1764
+ useEffect(() => {
1765
+ const unsubscribe = eventHandler.current.subscribeToModelConsentRequiredEvent(
1766
+ (request) => {
1767
+ setModelConsent(request);
1768
+ }
1769
+ );
1770
+ return () => unsubscribe();
1771
+ }, []);
1518
1772
  useEffect(() => {
1519
1773
  if (!isFetched || !remoteConfig) return;
1520
1774
  const toolConfig = remoteConfig[tool] ?? remoteConfig;
@@ -1531,9 +1785,23 @@ function AITranslationInnerProvider(props) {
1531
1785
  config2.setCompanyId(companyId);
1532
1786
  config2.setProjectId(projectId);
1533
1787
  const ait = useCallback(
1534
- async (text) => enableAIT ? await aitFunction(text, translationRegistry.current, config2, tool) : await Promise.resolve(text),
1788
+ async (text) => {
1789
+ if (!enableAIT) {
1790
+ return text;
1791
+ }
1792
+ return aitFunction(text, translationRegistry.current, config2, tool);
1793
+ },
1535
1794
  [tool, config2, enableAIT]
1536
1795
  );
1796
+ const confirmModelDownload = useCallback(() => {
1797
+ if (!modelConsent) return;
1798
+ if (config2.getToolConfig().strategy !== "frontend_translations") return;
1799
+ const { sourceLanguage, targetLanguage } = modelConsent;
1800
+ setModelConsent(null);
1801
+ translator.current.downloadModel(sourceLanguage, targetLanguage).then(() => modelDownloadGate.markReady(sourceLanguage, targetLanguage)).catch(
1802
+ (error) => modelDownloadGate.markFailed(sourceLanguage, targetLanguage, error)
1803
+ );
1804
+ }, [modelConsent, config2]);
1537
1805
  const contextValue = {
1538
1806
  ait,
1539
1807
  locale,
@@ -1542,6 +1810,8 @@ function AITranslationInnerProvider(props) {
1542
1810
  renderVersion: renderVersionManager.getVersion(),
1543
1811
  translationProgress,
1544
1812
  modelDownloadProgress,
1813
+ modelConsent,
1814
+ confirmModelDownload,
1545
1815
  tool,
1546
1816
  onTrackAnalyticsEvent,
1547
1817
  page,
@@ -1576,8 +1846,8 @@ var BUTTON_TYPE = {
1576
1846
  var ACTION = {
1577
1847
  CLICKED: "clicked"
1578
1848
  };
1579
- function buildObject(pageContext, buttonType) {
1580
- return `${pageContext}_${buttonType}_button`;
1849
+ function buildObject(buttonType) {
1850
+ return `${buttonType}_button`;
1581
1851
  }
1582
1852
  function buildEventKey(parts) {
1583
1853
  const { scope, tool, object, action } = parts;
@@ -1594,7 +1864,7 @@ function buildAnalyticEvent(params) {
1594
1864
  additionalProperties
1595
1865
  } = params;
1596
1866
  const resolvedAction = action ?? ACTION.CLICKED;
1597
- const object = buildObject(pageContext, buttonType);
1867
+ const object = buildObject(buttonType);
1598
1868
  const key = buildEventKey({
1599
1869
  scope,
1600
1870
  tool,
@@ -1603,6 +1873,7 @@ function buildAnalyticEvent(params) {
1603
1873
  });
1604
1874
  const properties = {
1605
1875
  ...baseProperties,
1876
+ [`${buttonType}_button_location`]: pageContext,
1606
1877
  ...additionalProperties
1607
1878
  };
1608
1879
  return { key, properties };
@@ -64,6 +64,20 @@ interface ModelDownloadProgress {
64
64
  progress: number;
65
65
  /** True once the model has finished downloading */
66
66
  isComplete: boolean;
67
+ /** Source language of the model being downloaded (e.g. `"fr"`) */
68
+ sourceLanguage: string;
69
+ /** Target language of the model being downloaded (e.g. `"en"`) */
70
+ targetLanguage: string;
71
+ }
72
+ /**
73
+ * A pending request for the user to consent to downloading a specific
74
+ * source -> target language model before translation can continue.
75
+ */
76
+ interface ModelConsentRequest {
77
+ /** Source language of the model that needs downloading (e.g. `"fr"`). */
78
+ sourceLanguage: string;
79
+ /** Target language of the model that needs downloading (e.g. `"en"`). */
80
+ targetLanguage: string;
67
81
  }
68
82
  interface AITranslationContextValue {
69
83
  ait: (text: string) => Promise<string>;
@@ -77,6 +91,17 @@ interface AITranslationContextValue {
77
91
  translationProgress: TranslationProgress | null;
78
92
  /** Chrome AI model download progress; `null` until a download begins. */
79
93
  modelDownloadProgress: ModelDownloadProgress | null;
94
+ /**
95
+ * Pending model the translation run is paused on, awaiting the user's consent
96
+ * to download; `null` when no consent is required.
97
+ */
98
+ modelConsent: ModelConsentRequest | null;
99
+ /**
100
+ * Confirms the pending {@link modelConsent} download. Must be called from a
101
+ * user gesture (e.g. a click handler) since Chrome requires a gesture to
102
+ * start a model download.
103
+ */
104
+ confirmModelDownload: () => void;
80
105
  tool: string;
81
106
  /** Called by `useAIAnalytics` with a fully assembled event. The MFE is responsible for sending it. */
82
107
  onTrackAnalyticsEvent?: AIAnalyticsTracker;
@@ -171,7 +196,7 @@ type Action = (typeof ACTION)[keyof typeof ACTION];
171
196
  * Builds the `object` segment: `{pageContext}_{buttonType}_button`
172
197
  * e.g. `buildObject('view', 'translate')` → `'view_translate_button'`
173
198
  */
174
- declare function buildObject(pageContext: string, buttonType: ButtonType): string;
199
+ declare function buildObject(buttonType: ButtonType): string;
175
200
  /**
176
201
  * Builds a fully-qualified event key:
177
202
  * `ux.web.feature.{scope}.{tool}.{object}.{action}`
@@ -335,4 +360,4 @@ declare global {
335
360
  var _BACKEND_AI_TRANSLATION_IN_PROGRESS_: boolean;
336
361
  }
337
362
 
338
- export { ACTION, type AIAnalyticsEventProperties, type AIAnalyticsTracker, AITranslateText, type AITranslateTextProps, AITranslationProvider, AI_TRANSLATION_FEATURE_FLAG_KEY, type Action, type AnalyticEvent, BUTTON_TYPE, type BuildAnalyticEventParams, type ButtonType, CustomizableAITranslateText, type CustomizableAITranslateTextProps, type EventKeyParts, type Scope, type TextSegmenter, type TranslatableSegment, type TranslatedIconProps, type UseAIAnalyticsReturn, type UseConfigOptions, buildAnalyticEvent, buildEventKey, buildObject, getAITranslationLDId, isSupportedBrowser, useAIAnalytics, useAITranslation, useConfig };
363
+ export { ACTION, type AIAnalyticsEventProperties, type AIAnalyticsTracker, AITranslateText, type AITranslateTextProps, AITranslationProvider, AI_TRANSLATION_FEATURE_FLAG_KEY, type Action, type AnalyticEvent, BUTTON_TYPE, type BuildAnalyticEventParams, type ButtonType, CustomizableAITranslateText, type CustomizableAITranslateTextProps, type EventKeyParts, type ModelConsentRequest, type ModelDownloadProgress, type Scope, type TextSegmenter, type TranslatableSegment, type TranslatedIconProps, type UseAIAnalyticsReturn, type UseConfigOptions, buildAnalyticEvent, buildEventKey, buildObject, getAITranslationLDId, isSupportedBrowser, useAIAnalytics, useAITranslation, useConfig };