@procore/ai-translations 0.8.0 → 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,95 @@ 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
1411
  return {
1228
1412
  translation: text,
1229
- sourceLanguage: targetLanguage,
1413
+ sourceLanguage,
1230
1414
  success: false,
1231
1415
  errorMessage: message,
1232
- retryable: true
1416
+ retryable: false,
1417
+ requiresGesture: isGestureRequired
1233
1418
  };
1234
1419
  }
1235
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
1452
+ };
1453
+ }
1454
+ return {
1455
+ translation,
1456
+ sourceLanguage,
1457
+ success: true,
1458
+ retryable: true
1459
+ };
1460
+ }
1236
1461
  static generateKey(sourceLanguage, targetLanguage) {
1237
1462
  return `${sourceLanguage.toUpperCase()}-${targetLanguage.toUpperCase()}`;
1238
1463
  }
@@ -1276,7 +1501,8 @@ var Client2 = class {
1276
1501
  translation: result.translation,
1277
1502
  sourceLanguage: result.sourceLanguage,
1278
1503
  isTranslated: result.success,
1279
- retryable: result.retryable
1504
+ retryable: result.retryable,
1505
+ requiresGesture: result.requiresGesture ?? false
1280
1506
  }
1281
1507
  ],
1282
1508
  targetLanguage: request.targetLanguage,
@@ -1291,6 +1517,9 @@ var Client2 = class {
1291
1517
  errorMessage
1292
1518
  };
1293
1519
  }
1520
+ async downloadModel(sourceLanguage, targetLanguage) {
1521
+ await ChromeTranslator.downloadModel(sourceLanguage, targetLanguage);
1522
+ }
1294
1523
  getConfig() {
1295
1524
  return this.config;
1296
1525
  }
@@ -1331,6 +1560,10 @@ var Client3 = class {
1331
1560
  const translator = this.getFrontendTranslator();
1332
1561
  return await translator.translateBatch(...args);
1333
1562
  }
1563
+ async downloadModel(sourceLanguage, targetLanguage) {
1564
+ const translator = this.getFrontendTranslator();
1565
+ await translator.downloadModel(sourceLanguage, targetLanguage);
1566
+ }
1334
1567
  };
1335
1568
 
1336
1569
  // src/translators/translator.ts
@@ -1345,6 +1578,17 @@ var Translator2 = class {
1345
1578
  const translatorClient = this.getTranslatorClient();
1346
1579
  return await translatorClient.translateBatch(translationRequests);
1347
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
+ }
1348
1592
  setTranslatorClient(config2) {
1349
1593
  if (config2.getToolConfig().strategy === "frontend_translations") {
1350
1594
  this.translatorClient = new Client3(config2);
@@ -1463,6 +1707,9 @@ function AITranslationInnerProvider(props) {
1463
1707
  } = props;
1464
1708
  const [translationProgress, setTranslationProgress] = useState(null);
1465
1709
  const [modelDownloadProgress, setModelDownloadProgress] = useState(null);
1710
+ const [modelConsent, setModelConsent] = useState(
1711
+ null
1712
+ );
1466
1713
  const [config2, setConfig] = useState(new Config());
1467
1714
  const translator = useRef(new Translator2(config2));
1468
1715
  const eventHandler = useRef(new EventHandler(tool));
@@ -1514,6 +1761,14 @@ function AITranslationInnerProvider(props) {
1514
1761
  );
1515
1762
  return () => unsubscribe();
1516
1763
  }, []);
1764
+ useEffect(() => {
1765
+ const unsubscribe = eventHandler.current.subscribeToModelConsentRequiredEvent(
1766
+ (request) => {
1767
+ setModelConsent(request);
1768
+ }
1769
+ );
1770
+ return () => unsubscribe();
1771
+ }, []);
1517
1772
  useEffect(() => {
1518
1773
  if (!isFetched || !remoteConfig) return;
1519
1774
  const toolConfig = remoteConfig[tool] ?? remoteConfig;
@@ -1530,9 +1785,23 @@ function AITranslationInnerProvider(props) {
1530
1785
  config2.setCompanyId(companyId);
1531
1786
  config2.setProjectId(projectId);
1532
1787
  const ait = useCallback(
1533
- 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
+ },
1534
1794
  [tool, config2, enableAIT]
1535
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]);
1536
1805
  const contextValue = {
1537
1806
  ait,
1538
1807
  locale,
@@ -1541,6 +1810,8 @@ function AITranslationInnerProvider(props) {
1541
1810
  renderVersion: renderVersionManager.getVersion(),
1542
1811
  translationProgress,
1543
1812
  modelDownloadProgress,
1813
+ modelConsent,
1814
+ confirmModelDownload,
1544
1815
  tool,
1545
1816
  onTrackAnalyticsEvent,
1546
1817
  page,
@@ -1575,8 +1846,8 @@ var BUTTON_TYPE = {
1575
1846
  var ACTION = {
1576
1847
  CLICKED: "clicked"
1577
1848
  };
1578
- function buildObject(pageContext, buttonType) {
1579
- return `${pageContext}_${buttonType}_button`;
1849
+ function buildObject(buttonType) {
1850
+ return `${buttonType}_button`;
1580
1851
  }
1581
1852
  function buildEventKey(parts) {
1582
1853
  const { scope, tool, object, action } = parts;
@@ -1593,7 +1864,7 @@ function buildAnalyticEvent(params) {
1593
1864
  additionalProperties
1594
1865
  } = params;
1595
1866
  const resolvedAction = action ?? ACTION.CLICKED;
1596
- const object = buildObject(pageContext, buttonType);
1867
+ const object = buildObject(buttonType);
1597
1868
  const key = buildEventKey({
1598
1869
  scope,
1599
1870
  tool,
@@ -1602,6 +1873,7 @@ function buildAnalyticEvent(params) {
1602
1873
  });
1603
1874
  const properties = {
1604
1875
  ...baseProperties,
1876
+ [`${buttonType}_button_location`]: pageContext,
1605
1877
  ...additionalProperties
1606
1878
  };
1607
1879
  return { key, properties };
@@ -1887,7 +2159,7 @@ var CustomizableAITranslateText = ({
1887
2159
 
1888
2160
  // src/utils/featureFlag.ts
1889
2161
  import { isFederalZone, getProcoreZone } from "@procore/web-sdk-mfe-utils";
1890
- var AI_TRANSLATION_FEATURE_FLAG_KEY = "pe_ai_translations_web_program_visible";
2162
+ var AI_TRANSLATION_FEATURE_FLAG_KEY = "ai-translation";
1891
2163
  var getAITranslationLDId = (domain) => {
1892
2164
  const { environment, zone } = getProcoreZone(domain);
1893
2165
  if (isFederalZone(zone)) {
@@ -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}`
@@ -320,7 +345,7 @@ declare const CustomizableAITranslateText: React.FC<CustomizableAITranslateTextP
320
345
  /**
321
346
  * The key used to store/retrieve the feature flag in local storage.
322
347
  */
323
- declare const AI_TRANSLATION_FEATURE_FLAG_KEY = "pe_ai_translations_web_program_visible";
348
+ declare const AI_TRANSLATION_FEATURE_FLAG_KEY = "ai-translation";
324
349
  /**
325
350
  * Retrieves the LD ID for the AI translation feature flag based on the domain.
326
351
  * @param domain - The domain to determine the LD ID for
@@ -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 };