@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.
@@ -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 };
@@ -670,6 +670,7 @@ var TRANSLATION_COMPLETE_EVENT_NAME = "ai-translation-completed";
670
670
  var RERENDER_EVENT_NAME = "ai-translations-component-rerender";
671
671
  var TRANSLATION_PROGRESS_EVENT_NAME = "ai-translations-progress";
672
672
  var MODEL_DOWNLOAD_PROGRESS_EVENT_NAME = "ai-translations-model-download-progress";
673
+ var MODEL_CONSENT_REQUIRED_EVENT_NAME = "ai-translations-model-consent-required";
673
674
 
674
675
  // src/utils/eventHandler.ts
675
676
  var EventHandler = class {
@@ -743,12 +744,14 @@ var EventHandler = class {
743
744
  * Not tool-scoped — a Chrome language model is shared across the entire
744
745
  * browser session, so all Provider instances receive it.
745
746
  */
746
- publishModelDownloadProgressEvent(loaded, total, progress) {
747
+ publishModelDownloadProgressEvent(loaded, total, progress, sourceLanguage, targetLanguage) {
747
748
  this.aiTranslationEvents.publish(MODEL_DOWNLOAD_PROGRESS_EVENT_NAME, {
748
749
  loaded,
749
750
  total,
750
751
  progress,
751
- isComplete: loaded === total
752
+ isComplete: loaded === total,
753
+ sourceLanguage,
754
+ targetLanguage
752
755
  });
753
756
  }
754
757
  subscribeToModelDownloadProgressEvent(callback) {
@@ -758,7 +761,29 @@ var EventHandler = class {
758
761
  loaded: detail.data?.loaded ?? 0,
759
762
  total: detail.data?.total ?? 0,
760
763
  progress: detail.data?.progress ?? 0,
761
- isComplete: detail.data?.isComplete ?? false
764
+ isComplete: detail.data?.isComplete ?? false,
765
+ sourceLanguage: detail.data?.sourceLanguage ?? "",
766
+ targetLanguage: detail.data?.targetLanguage ?? ""
767
+ })
768
+ );
769
+ }
770
+ /**
771
+ * Publishes a request for the user to consent to downloading a specific
772
+ * source -> target language model. Like the download-progress event this is
773
+ * NOT tool-scoped — a language model is shared across the whole session.
774
+ */
775
+ publishModelConsentRequiredEvent(sourceLanguage, targetLanguage) {
776
+ this.aiTranslationEvents.publish(MODEL_CONSENT_REQUIRED_EVENT_NAME, {
777
+ sourceLanguage,
778
+ targetLanguage
779
+ });
780
+ }
781
+ subscribeToModelConsentRequiredEvent(callback) {
782
+ return this.aiTranslationEvents.subscribe(
783
+ MODEL_CONSENT_REQUIRED_EVENT_NAME,
784
+ (detail) => callback({
785
+ sourceLanguage: detail.data?.sourceLanguage ?? "",
786
+ targetLanguage: detail.data?.targetLanguage ?? ""
762
787
  })
763
788
  );
764
789
  }
@@ -767,6 +792,111 @@ var EventHandler = class {
767
792
  }
768
793
  };
769
794
 
795
+ // src/utils/modelDownloadGate.ts
796
+ var ModelDownloadGateImpl = class _ModelDownloadGateImpl {
797
+ eventHandler = null;
798
+ ready = /* @__PURE__ */ new Set();
799
+ pending = /* @__PURE__ */ new Map();
800
+ resolvers = /* @__PURE__ */ new Map();
801
+ rejecters = /* @__PURE__ */ new Map();
802
+ getEventHandler() {
803
+ if (!this.eventHandler) {
804
+ this.eventHandler = new EventHandler("__chrome_model__");
805
+ }
806
+ return this.eventHandler;
807
+ }
808
+ static key(sourceLanguage, targetLanguage) {
809
+ return `${sourceLanguage}->${targetLanguage}`;
810
+ }
811
+ /**
812
+ * Resolves once the model for `sourceLanguage -> targetLanguage` is downloaded
813
+ * and ready. Pauses (stays pending) until the user confirms the download and
814
+ * it completes. Re-prompts are deduped per model.
815
+ */
816
+ ensureModelReady(sourceLanguage, targetLanguage) {
817
+ const key = _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage);
818
+ if (this.ready.has(key)) {
819
+ return Promise.resolve();
820
+ }
821
+ const existing = this.pending.get(key);
822
+ if (existing) {
823
+ return existing;
824
+ }
825
+ const promise = new Promise((resolve, reject) => {
826
+ this.resolvers.set(key, resolve);
827
+ this.rejecters.set(key, reject);
828
+ });
829
+ this.pending.set(key, promise);
830
+ this.getEventHandler().publishModelConsentRequiredEvent(
831
+ sourceLanguage,
832
+ targetLanguage
833
+ );
834
+ return promise;
835
+ }
836
+ /**
837
+ * Marks a model as ready and resolves any waiter. Resolved exclusively when
838
+ * `downloadModel` resolves (wired in the Provider). This guarantees Chrome's
839
+ * `Translator.create()` has returned and the translator instance is cached in
840
+ * `chromeTranslatorRegistry`, so the resumed translation reuses it instead of
841
+ * calling `Translator.create()` again outside the user gesture (which throws
842
+ * `NotAllowedError`). It is deliberately NOT resolved on the intermediate
843
+ * `MODEL_DOWNLOAD_PROGRESS` `isComplete` event, which fires before
844
+ * `Translator.create()` returns.
845
+ */
846
+ markReady(sourceLanguage, targetLanguage) {
847
+ const key = _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage);
848
+ this.ready.add(key);
849
+ const resolve = this.resolvers.get(key);
850
+ if (resolve) {
851
+ resolve();
852
+ }
853
+ this.clearPending(key);
854
+ }
855
+ /**
856
+ * Marks a model download as failed and rejects any waiter so the paused
857
+ * translation run can move past this text instead of hanging forever. Called
858
+ * when `downloadModel` rejects (e.g. Chrome returns 404 for a pair it
859
+ * reported as downloadable). The pair is NOT cached as ready, so it can be
860
+ * re-attempted later.
861
+ */
862
+ markFailed(sourceLanguage, targetLanguage, error) {
863
+ const key = _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage);
864
+ const reject = this.rejecters.get(key);
865
+ if (reject) {
866
+ reject(
867
+ error instanceof Error ? error : new Error(`Model download failed for ${key}`)
868
+ );
869
+ }
870
+ this.clearPending(key);
871
+ }
872
+ isReady(sourceLanguage, targetLanguage) {
873
+ return this.ready.has(
874
+ _ModelDownloadGateImpl.key(sourceLanguage, targetLanguage)
875
+ );
876
+ }
877
+ /**
878
+ * Clears all gate state. Intended for test teardown so cached "ready" models
879
+ * do not leak between cases.
880
+ */
881
+ reset() {
882
+ this.ready.clear();
883
+ this.pending.clear();
884
+ this.resolvers.clear();
885
+ this.rejecters.clear();
886
+ }
887
+ clearPending(key) {
888
+ this.resolvers.delete(key);
889
+ this.rejecters.delete(key);
890
+ this.pending.delete(key);
891
+ }
892
+ };
893
+ var modelDownloadGate = new ModelDownloadGateImpl();
894
+
895
+ // src/utils/language.ts
896
+ function normalizeLanguage(language) {
897
+ return language.split("-")[0] ?? language;
898
+ }
899
+
770
900
  // src/utils/renderVersionManager.ts
771
901
  var RenderVersionManager = class {
772
902
  version = 0;
@@ -823,27 +953,30 @@ var TranslationManager = class {
823
953
  this.translationProgress.total = queueManager.queueSize(
824
954
  this.currentTranslatorStrategy
825
955
  );
826
- do {
827
- for (const batch of queueManager.generateBatches(
828
- this.translator.getConfig()
829
- )) {
830
- batch.forEach((entry) => this.mfeToBeNotified.add(entry.tool));
831
- const result = await this.translator.processTranslations(
832
- batch.map(
833
- (entry) => this.convertTranslationQueueEntryToTranslationRequest(entry)
834
- )
835
- );
836
- this.setTranslationProgress(batch.length);
837
- await this.updateDatabaseWithTranslations(result);
838
- await this.notifyTranslationCompleted();
956
+ try {
957
+ do {
958
+ for (const batch of queueManager.generateBatches(
959
+ this.translator.getConfig()
960
+ )) {
961
+ batch.forEach((entry) => this.mfeToBeNotified.add(entry.tool));
962
+ const result = await this.translator.processTranslations(
963
+ batch.map(
964
+ (entry) => this.convertTranslationQueueEntryToTranslationRequest(entry)
965
+ )
966
+ );
967
+ this.setTranslationProgress(batch.length);
968
+ await this.updateDatabaseWithTranslations(result);
969
+ await this.notifyTranslationCompleted();
970
+ }
971
+ } while (queueManager.queueSize(this.currentTranslatorStrategy) > 0);
972
+ this.resetTranslationProgress();
973
+ this.publishTranslationProgress();
974
+ } finally {
975
+ if (this.currentTranslatorStrategy === "frontend_translations") {
976
+ globalThis._FRONTEND_AI_TRANSLATION_IN_PROGRESS_ = false;
977
+ } else {
978
+ globalThis._BACKEND_AI_TRANSLATION_IN_PROGRESS_ = false;
839
979
  }
840
- } while (queueManager.queueSize(this.currentTranslatorStrategy) > 0);
841
- this.resetTranslationProgress();
842
- this.publishTranslationProgress();
843
- if (this.currentTranslatorStrategy === "frontend_translations") {
844
- globalThis._FRONTEND_AI_TRANSLATION_IN_PROGRESS_ = false;
845
- } else {
846
- globalThis._BACKEND_AI_TRANSLATION_IN_PROGRESS_ = false;
847
980
  }
848
981
  }
849
982
  async updateDatabaseWithTranslations(translationResult) {
@@ -1013,6 +1146,16 @@ var Client = class {
1013
1146
  return this.createTranslationErrorResponse(requests, errorMessage);
1014
1147
  }
1015
1148
  }
1149
+ /**
1150
+ * No-op: backend translation does not manage on-device models, so there is
1151
+ * nothing to download client-side. The parameters are kept to match the
1152
+ * {@link TranslatorClientAdapter.downloadModel} signature and make the no-op
1153
+ * intent explicit to callers.
1154
+ */
1155
+ async downloadModel(sourceLanguage, targetLanguage) {
1156
+ void sourceLanguage;
1157
+ void targetLanguage;
1158
+ }
1016
1159
  createTranslationErrorResponse(requests, errorMessage) {
1017
1160
  return {
1018
1161
  responseBody: requests.map((req) => ({
@@ -1132,6 +1275,7 @@ var ChromeTranslator = class _ChromeTranslator {
1132
1275
  }
1133
1276
  }
1134
1277
  static async init(sourceLanguage, targetLanguage) {
1278
+ this.ensureRegistry();
1135
1279
  if (globalThis.chromeTranslatorRegistry.has(
1136
1280
  this.generateKey(sourceLanguage, targetLanguage)
1137
1281
  )) {
@@ -1146,7 +1290,30 @@ var ChromeTranslator = class _ChromeTranslator {
1146
1290
  new _ChromeTranslator(translator)
1147
1291
  );
1148
1292
  }
1149
- static async createTranslator(source, target) {
1293
+ /**
1294
+ * Downloads the on-device model for a source -> target language pair. Must be
1295
+ * invoked from a user gesture (e.g. a click handler), since Chrome requires an
1296
+ * active gesture to start a model download.
1297
+ */
1298
+ static async downloadModel(sourceLanguage, targetLanguage) {
1299
+ await this.init(
1300
+ normalizeLanguage(sourceLanguage),
1301
+ normalizeLanguage(targetLanguage)
1302
+ );
1303
+ }
1304
+ /**
1305
+ * Creates a Chrome translator instance for the pair.
1306
+ *
1307
+ * @param publishProgress - When true, `downloadprogress` events are forwarded
1308
+ * as `MODEL_DOWNLOAD_PROGRESS` events (driving the download-progress popup).
1309
+ * This should only be true for an explicit, consent-driven download
1310
+ * (`init`/`downloadModel`). When creating an already-downloaded
1311
+ * (`available`) model in the translate loop, Chrome still emits
1312
+ * `downloadprogress` while loading the pack from disk into memory; forwarding
1313
+ * those would wrongly show a "downloading" popup for a model that is already
1314
+ * downloaded, so the translate-loop path passes `false`.
1315
+ */
1316
+ static async createTranslator(source, target, publishProgress = true) {
1150
1317
  const key = this.generateKey(source, target);
1151
1318
  let resolveReady;
1152
1319
  const readyPromise = new Promise((resolve) => {
@@ -1163,11 +1330,15 @@ var ChromeTranslator = class _ChromeTranslator {
1163
1330
  const loaded = e.loaded ?? 0;
1164
1331
  const total = e.total ?? 0;
1165
1332
  const progress = total > 0 ? Math.round(loaded / total * 100) : 0;
1166
- getModelDownloadEventHandler().publishModelDownloadProgressEvent(
1167
- loaded,
1168
- total,
1169
- progress
1170
- );
1333
+ if (publishProgress) {
1334
+ getModelDownloadEventHandler().publishModelDownloadProgressEvent(
1335
+ loaded,
1336
+ total,
1337
+ progress,
1338
+ source,
1339
+ target
1340
+ );
1341
+ }
1171
1342
  if (total > 0 && loaded >= total) {
1172
1343
  resolveReady();
1173
1344
  }
@@ -1184,26 +1355,27 @@ var ChromeTranslator = class _ChromeTranslator {
1184
1355
  }
1185
1356
  static async translate(text, targetLanguage) {
1186
1357
  this.ensureRegistry();
1358
+ const normalizedTarget = normalizeLanguage(targetLanguage);
1359
+ let sourceLanguage = normalizedTarget;
1187
1360
  try {
1188
1361
  const detector = await ChromeLanguageDetector.getInstance();
1189
- const sourceLanguage = await detector.detectLanguage(text);
1190
- const key = this.generateKey(sourceLanguage, targetLanguage);
1191
- if (!this.isSupportedLanguage(sourceLanguage) || !this.isSupportedLanguage(targetLanguage)) {
1362
+ sourceLanguage = normalizeLanguage(await detector.detectLanguage(text));
1363
+ if (!this.isSupportedLanguage(sourceLanguage) || !this.isSupportedLanguage(normalizedTarget)) {
1192
1364
  return {
1193
1365
  translation: text,
1194
1366
  sourceLanguage,
1195
1367
  success: false,
1196
1368
  retryable: false,
1197
- errorMessage: `Unsupported language pair: ${sourceLanguage} \u2192 ${targetLanguage}`
1369
+ errorMessage: `Unsupported language pair: ${sourceLanguage} \u2192 ${normalizedTarget}`
1198
1370
  };
1199
1371
  }
1200
- const existingInstance = globalThis.chromeTranslatorRegistry.get(key);
1201
- if (!existingInstance) {
1202
- const translatorCapabilities = await Translator.availability({
1372
+ const key = this.generateKey(sourceLanguage, normalizedTarget);
1373
+ if (!globalThis.chromeTranslatorRegistry.has(key)) {
1374
+ const availability = await Translator.availability({
1203
1375
  sourceLanguage,
1204
- targetLanguage
1376
+ targetLanguage: normalizedTarget
1205
1377
  });
1206
- if (translatorCapabilities === "unavailable") {
1378
+ if (availability === "unavailable") {
1207
1379
  return {
1208
1380
  translation: text,
1209
1381
  sourceLanguage,
@@ -1212,47 +1384,94 @@ var ChromeTranslator = class _ChromeTranslator {
1212
1384
  errorMessage: "Translation not supported"
1213
1385
  };
1214
1386
  }
1215
- const chromeTranslator = await this.createTranslator(
1216
- sourceLanguage,
1217
- targetLanguage
1218
- );
1219
- if (!globalThis.chromeTranslatorRegistry.has(key)) {
1220
- globalThis.chromeTranslatorRegistry.set(
1221
- key,
1222
- new _ChromeTranslator(chromeTranslator)
1223
- );
1387
+ if (availability !== "available") {
1388
+ const isUserActivationActive = typeof navigator !== "undefined" && (navigator.userActivation?.isActive ?? false);
1389
+ if (isUserActivationActive) {
1390
+ await _ChromeTranslator.downloadModel(
1391
+ sourceLanguage,
1392
+ normalizedTarget
1393
+ );
1394
+ modelDownloadGate.markReady(sourceLanguage, normalizedTarget);
1395
+ } else {
1396
+ await modelDownloadGate.ensureModelReady(
1397
+ sourceLanguage,
1398
+ normalizedTarget
1399
+ );
1400
+ }
1224
1401
  }
1225
1402
  }
1226
- const instance = globalThis.chromeTranslatorRegistry.get(key);
1227
- await this.waitForReady(key);
1228
- this.translatorReadyPromises.delete(key);
1229
- const translation = await instance.translator?.translate(text);
1230
- if (!translation) {
1231
- return {
1232
- translation: text,
1233
- sourceLanguage,
1234
- success: false,
1235
- errorMessage: "Translation failed",
1236
- retryable: false
1237
- };
1238
- }
1239
- return {
1240
- translation,
1403
+ return await this.createInstanceAndTranslate(
1404
+ text,
1241
1405
  sourceLanguage,
1242
- success: true,
1243
- retryable: true
1244
- };
1406
+ normalizedTarget
1407
+ );
1245
1408
  } catch (error) {
1409
+ const isGestureRequired = error instanceof DOMException && error.name === "NotAllowedError";
1410
+ if (isGestureRequired) {
1411
+ try {
1412
+ await modelDownloadGate.ensureModelReady(
1413
+ sourceLanguage,
1414
+ normalizedTarget
1415
+ );
1416
+ return await this.createInstanceAndTranslate(
1417
+ text,
1418
+ sourceLanguage,
1419
+ normalizedTarget
1420
+ );
1421
+ } catch (retryError) {
1422
+ void retryError;
1423
+ }
1424
+ }
1246
1425
  const message = error instanceof Error ? error.message : "Unknown error";
1247
1426
  return {
1248
1427
  translation: text,
1249
- sourceLanguage: targetLanguage,
1428
+ sourceLanguage,
1250
1429
  success: false,
1251
1430
  errorMessage: message,
1252
- retryable: true
1431
+ retryable: false,
1432
+ requiresGesture: isGestureRequired
1253
1433
  };
1254
1434
  }
1255
1435
  }
1436
+ /**
1437
+ * Creates the translator instance for the pair if it is not already cached,
1438
+ * waits for the model to be ready, and translates the text. The model is
1439
+ * expected to already be downloaded (or downloadable without a gesture) by
1440
+ * the time this is called.
1441
+ */
1442
+ static async createInstanceAndTranslate(text, sourceLanguage, targetLanguage) {
1443
+ const key = this.generateKey(sourceLanguage, targetLanguage);
1444
+ if (!globalThis.chromeTranslatorRegistry.has(key)) {
1445
+ const chromeTranslator = await this.createTranslator(
1446
+ sourceLanguage,
1447
+ targetLanguage,
1448
+ false
1449
+ );
1450
+ globalThis.chromeTranslatorRegistry.set(
1451
+ key,
1452
+ new _ChromeTranslator(chromeTranslator)
1453
+ );
1454
+ }
1455
+ const instance = globalThis.chromeTranslatorRegistry.get(key);
1456
+ await this.waitForReady(key);
1457
+ this.translatorReadyPromises.delete(key);
1458
+ const translation = await instance.translator?.translate(text);
1459
+ if (!translation) {
1460
+ return {
1461
+ translation: text,
1462
+ sourceLanguage,
1463
+ success: false,
1464
+ errorMessage: "Translation failed",
1465
+ retryable: false
1466
+ };
1467
+ }
1468
+ return {
1469
+ translation,
1470
+ sourceLanguage,
1471
+ success: true,
1472
+ retryable: true
1473
+ };
1474
+ }
1256
1475
  static generateKey(sourceLanguage, targetLanguage) {
1257
1476
  return `${sourceLanguage.toUpperCase()}-${targetLanguage.toUpperCase()}`;
1258
1477
  }
@@ -1299,7 +1518,8 @@ var Client2 = class {
1299
1518
  translation: result.translation,
1300
1519
  sourceLanguage: result.sourceLanguage,
1301
1520
  isTranslated: result.success,
1302
- retryable: result.retryable
1521
+ retryable: result.retryable,
1522
+ requiresGesture: result.requiresGesture ?? false
1303
1523
  }
1304
1524
  ],
1305
1525
  targetLanguage: request.targetLanguage,
@@ -1314,6 +1534,9 @@ var Client2 = class {
1314
1534
  errorMessage
1315
1535
  };
1316
1536
  }
1537
+ async downloadModel(sourceLanguage, targetLanguage) {
1538
+ await ChromeTranslator.downloadModel(sourceLanguage, targetLanguage);
1539
+ }
1317
1540
  getConfig() {
1318
1541
  return this.config;
1319
1542
  }
@@ -1349,6 +1572,10 @@ var Client3 = class {
1349
1572
  const translator = this.getFrontendTranslator();
1350
1573
  return await translator.translateBatch(...args);
1351
1574
  }
1575
+ async downloadModel(sourceLanguage, targetLanguage) {
1576
+ const translator = this.getFrontendTranslator();
1577
+ await translator.downloadModel(sourceLanguage, targetLanguage);
1578
+ }
1352
1579
  };
1353
1580
 
1354
1581
  // src/translators/translator.ts
@@ -1363,6 +1590,17 @@ var Translator2 = class {
1363
1590
  const translatorClient = this.getTranslatorClient();
1364
1591
  return await translatorClient.translateBatch(translationRequests);
1365
1592
  }
1593
+ /**
1594
+ * Downloads the on-device model for a source -> target language pair via the
1595
+ * active translator client. Must be invoked from a user gesture for clients
1596
+ * (e.g. Chrome) that require one to start a model download.
1597
+ */
1598
+ async downloadModel(sourceLanguage, targetLanguage) {
1599
+ await this.getTranslatorClient().downloadModel(
1600
+ sourceLanguage,
1601
+ targetLanguage
1602
+ );
1603
+ }
1366
1604
  setTranslatorClient(config2) {
1367
1605
  if (config2.getToolConfig().strategy === "frontend_translations") {
1368
1606
  this.translatorClient = new Client3(config2);
@@ -1481,6 +1719,9 @@ function AITranslationInnerProvider(props) {
1481
1719
  } = props;
1482
1720
  const [translationProgress, setTranslationProgress] = (0, import_react.useState)(null);
1483
1721
  const [modelDownloadProgress, setModelDownloadProgress] = (0, import_react.useState)(null);
1722
+ const [modelConsent, setModelConsent] = (0, import_react.useState)(
1723
+ null
1724
+ );
1484
1725
  const [config2, setConfig] = (0, import_react.useState)(new Config());
1485
1726
  const translator = (0, import_react.useRef)(new Translator2(config2));
1486
1727
  const eventHandler = (0, import_react.useRef)(new EventHandler(tool));
@@ -1532,6 +1773,14 @@ function AITranslationInnerProvider(props) {
1532
1773
  );
1533
1774
  return () => unsubscribe();
1534
1775
  }, []);
1776
+ (0, import_react.useEffect)(() => {
1777
+ const unsubscribe = eventHandler.current.subscribeToModelConsentRequiredEvent(
1778
+ (request) => {
1779
+ setModelConsent(request);
1780
+ }
1781
+ );
1782
+ return () => unsubscribe();
1783
+ }, []);
1535
1784
  (0, import_react.useEffect)(() => {
1536
1785
  if (!isFetched || !remoteConfig) return;
1537
1786
  const toolConfig = remoteConfig[tool] ?? remoteConfig;
@@ -1548,9 +1797,23 @@ function AITranslationInnerProvider(props) {
1548
1797
  config2.setCompanyId(companyId);
1549
1798
  config2.setProjectId(projectId);
1550
1799
  const ait = (0, import_react.useCallback)(
1551
- async (text) => enableAIT ? await aitFunction(text, translationRegistry.current, config2, tool) : await Promise.resolve(text),
1800
+ async (text) => {
1801
+ if (!enableAIT) {
1802
+ return text;
1803
+ }
1804
+ return aitFunction(text, translationRegistry.current, config2, tool);
1805
+ },
1552
1806
  [tool, config2, enableAIT]
1553
1807
  );
1808
+ const confirmModelDownload = (0, import_react.useCallback)(() => {
1809
+ if (!modelConsent) return;
1810
+ if (config2.getToolConfig().strategy !== "frontend_translations") return;
1811
+ const { sourceLanguage, targetLanguage } = modelConsent;
1812
+ setModelConsent(null);
1813
+ translator.current.downloadModel(sourceLanguage, targetLanguage).then(() => modelDownloadGate.markReady(sourceLanguage, targetLanguage)).catch(
1814
+ (error) => modelDownloadGate.markFailed(sourceLanguage, targetLanguage, error)
1815
+ );
1816
+ }, [modelConsent, config2]);
1554
1817
  const contextValue = {
1555
1818
  ait,
1556
1819
  locale,
@@ -1559,6 +1822,8 @@ function AITranslationInnerProvider(props) {
1559
1822
  renderVersion: renderVersionManager.getVersion(),
1560
1823
  translationProgress,
1561
1824
  modelDownloadProgress,
1825
+ modelConsent,
1826
+ confirmModelDownload,
1562
1827
  tool,
1563
1828
  onTrackAnalyticsEvent,
1564
1829
  page,
@@ -1593,8 +1858,8 @@ var BUTTON_TYPE = {
1593
1858
  var ACTION = {
1594
1859
  CLICKED: "clicked"
1595
1860
  };
1596
- function buildObject(pageContext, buttonType) {
1597
- return `${pageContext}_${buttonType}_button`;
1861
+ function buildObject(buttonType) {
1862
+ return `${buttonType}_button`;
1598
1863
  }
1599
1864
  function buildEventKey(parts) {
1600
1865
  const { scope, tool, object, action } = parts;
@@ -1611,7 +1876,7 @@ function buildAnalyticEvent(params) {
1611
1876
  additionalProperties
1612
1877
  } = params;
1613
1878
  const resolvedAction = action ?? ACTION.CLICKED;
1614
- const object = buildObject(pageContext, buttonType);
1879
+ const object = buildObject(buttonType);
1615
1880
  const key = buildEventKey({
1616
1881
  scope,
1617
1882
  tool,
@@ -1620,6 +1885,7 @@ function buildAnalyticEvent(params) {
1620
1885
  });
1621
1886
  const properties = {
1622
1887
  ...baseProperties,
1888
+ [`${buttonType}_button_location`]: pageContext,
1623
1889
  ...additionalProperties
1624
1890
  };
1625
1891
  return { key, properties };
@@ -1899,7 +2165,7 @@ var CustomizableAITranslateText = ({
1899
2165
 
1900
2166
  // src/utils/featureFlag.ts
1901
2167
  var import_web_sdk_mfe_utils = require("@procore/web-sdk-mfe-utils");
1902
- var AI_TRANSLATION_FEATURE_FLAG_KEY = "pe_ai_translations_web_program_visible";
2168
+ var AI_TRANSLATION_FEATURE_FLAG_KEY = "ai-translation";
1903
2169
  var getAITranslationLDId = (domain) => {
1904
2170
  const { environment, zone } = (0, import_web_sdk_mfe_utils.getProcoreZone)(domain);
1905
2171
  if ((0, import_web_sdk_mfe_utils.isFederalZone)(zone)) {