@blotoutio/providers-evo-search-sdk 1.66.2 → 1.68.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.
package/core.js CHANGED
@@ -599,6 +599,19 @@ var ProvidersEvoSearchSdk = (function () {
599
599
  return false;
600
600
  }
601
601
  };
602
+ // Session-sticky opt-out for internal QA: ?evs=off suppresses the widget even
603
+ // when the manifest has it enabled, so the merchant's default site can be
604
+ // previewed. Takes precedence over both `enabled` and the preview flag.
605
+ const disableKeyName = 'disableEvoSearch';
606
+ const hasDisableKey = () => {
607
+ var _a;
608
+ try {
609
+ return ((_a = sessionStorage.getItem(disableKeyName)) !== null && _a !== void 0 ? _a : '0') === '1';
610
+ }
611
+ catch {
612
+ return false;
613
+ }
614
+ };
602
615
 
603
616
  var _a;
604
617
  const registryKey = Symbol.for('evo-search');
@@ -641,6 +654,10 @@ var ProvidersEvoSearchSdk = (function () {
641
654
  // caller-superseded (aborted) request. Shared by the widget and the headless
642
655
  // hook. See callers: search-widget.ts (performAutocomplete) and
643
656
  // hooks/use-evo-search.ts.
657
+ // Per-attempt budget for /search/image, shared by the widget and the headless
658
+ // hook so the two can't drift. Wider than the text search's 7s: the request
659
+ // carries an upload and waits on a multimodal embedding.
660
+ const IMAGE_SEARCH_TIMEOUT_MS = 15000;
644
661
  const DEFAULTS = {
645
662
  timeoutMs: 7000,
646
663
  retries: 2,
@@ -744,6 +761,126 @@ var ProvidersEvoSearchSdk = (function () {
744
761
  throw lastError;
745
762
  }
746
763
 
764
+ // Client-side downscale for image search. The worker caps the decoded upload at
765
+ // ~1.5MB and every request costs a multimodal embed, so shrinking a phone photo
766
+ // here (a 12MP JPEG is ~4MB) is what keeps the request inside the cap and the
767
+ // upload quick on mobile data. Matches the sync-time `imageEmbedWidth` (see
768
+ // search-tuning.ts) so query and index renditions land in the same Gemini
769
+ // tiling bracket — a JPEG at this edge is still two orders of magnitude below
770
+ // the worker's decoded-size cap.
771
+ const MAX_IMAGE_EDGE = 768;
772
+ const JPEG_QUALITY = 0.85;
773
+ // Fallback decode for engines without createImageBitmap. Orientation here is
774
+ // whatever the UA's `image-orientation` default is — see decodeImage.
775
+ const loadImage = (source) => new Promise((resolve, reject) => {
776
+ const objectUrl = URL.createObjectURL(source);
777
+ const image = new Image();
778
+ image.onload = () => {
779
+ URL.revokeObjectURL(objectUrl);
780
+ resolve(image);
781
+ };
782
+ image.onerror = () => {
783
+ URL.revokeObjectURL(objectUrl);
784
+ reject(new Error('Image could not be decoded'));
785
+ };
786
+ image.src = objectUrl;
787
+ });
788
+ // createImageBitmap pins EXIF orientation explicitly, so a portrait phone photo
789
+ // is embedded upright even on engines that predate the
790
+ // `image-orientation: from-image` default (older iOS Safari, the embedded
791
+ // Android WebViews storefront traffic includes) — a sideways embed degrades
792
+ // retrieval with no error to show for it. It also skips the full-size <img>
793
+ // decode. So it goes first, and the <img> path catches everything it can't do:
794
+ // engines that lack the API, and the sources it rejects outright but <img>
795
+ // renders (an SVG with no intrinsic size is the one that shows up).
796
+ // A very large source can still yield a blank canvas on old iOS; if blank
797
+ // embeds are ever observed, add a source pixel-count ceiling here.
798
+ const decodeImage = async (source) => {
799
+ if (typeof createImageBitmap === 'function') {
800
+ try {
801
+ const bitmap = await createImageBitmap(source, {
802
+ imageOrientation: 'from-image',
803
+ });
804
+ return {
805
+ image: bitmap,
806
+ width: bitmap.width,
807
+ height: bitmap.height,
808
+ // Phone memory doesn't wait for GC to reclaim a decoded bitmap.
809
+ release: () => bitmap.close(),
810
+ };
811
+ }
812
+ catch {
813
+ // Fall through: a rejection here means "this decoder can't", not "this
814
+ // isn't an image". The legacy path decides, and only its failure is the
815
+ // shopper's error.
816
+ }
817
+ }
818
+ const image = await loadImage(source);
819
+ return {
820
+ image,
821
+ width: image.naturalWidth,
822
+ height: image.naturalHeight,
823
+ release: () => undefined,
824
+ };
825
+ };
826
+ // Draw `image` — or just `region` of it — into a fresh canvas whose longest
827
+ // edge is at most `maxEdge`, on a white ground. A cropped region is scaled to
828
+ // the same edge cap as the whole frame, so a selection is embedded at the
829
+ // resolution the index renditions were built at rather than at whatever
830
+ // fraction of the frame it happened to occupy.
831
+ const renderToCanvas = (image, sourceWidth, sourceHeight, maxEdge, region) => {
832
+ const sx = region ? Math.round(region.x * sourceWidth) : 0;
833
+ const sy = region ? Math.round(region.y * sourceHeight) : 0;
834
+ const sw = region
835
+ ? Math.max(1, Math.round(region.w * sourceWidth))
836
+ : sourceWidth;
837
+ const sh = region
838
+ ? Math.max(1, Math.round(region.h * sourceHeight))
839
+ : sourceHeight;
840
+ const longestEdge = Math.max(sw, sh);
841
+ const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
842
+ const canvas = document.createElement('canvas');
843
+ canvas.width = Math.max(1, Math.round(sw * scale));
844
+ canvas.height = Math.max(1, Math.round(sh * scale));
845
+ const context = canvas.getContext('2d');
846
+ if (!context) {
847
+ throw new Error('Canvas is unavailable');
848
+ }
849
+ // JPEG has no alpha, so a transparent source (a PNG/WebP cutout, the common
850
+ // paste case) would otherwise composite onto the canvas's transparent-black
851
+ // default and be embedded as a black-backed product. Catalog shots are
852
+ // typically white-background, so white is the closer prior.
853
+ context.fillStyle = '#ffffff';
854
+ context.fillRect(0, 0, canvas.width, canvas.height);
855
+ if (region) {
856
+ context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
857
+ }
858
+ else {
859
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
860
+ }
861
+ return canvas;
862
+ };
863
+ /**
864
+ * Downscale `source` to a JPEG data URI whose longest edge is at most
865
+ * `MAX_IMAGE_EDGE`, upright regardless of the source's EXIF orientation.
866
+ * Rejects anything that isn't an image, and anything the browser can't decode
867
+ * (HEIC off iOS, corrupt files) — every input path (file picker, drop, paste) is
868
+ * shopper-controlled, so this is the trust boundary that keeps a stray PDF or
869
+ * text drop off the wire.
870
+ */
871
+ const downscaleImage = async (source) => {
872
+ if (!source.type.startsWith('image/')) {
873
+ throw new Error('Only image files can be searched');
874
+ }
875
+ const { image, width, height, release } = await decodeImage(source);
876
+ try {
877
+ return renderToCanvas(image, width, height, MAX_IMAGE_EDGE).toDataURL('image/jpeg', JPEG_QUALITY);
878
+ }
879
+ finally {
880
+ release();
881
+ }
882
+ };
883
+
747
884
  // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
748
885
  // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
749
886
  // routes them to the search fallback rather than treating them as cancellation.
@@ -793,6 +930,11 @@ var ProvidersEvoSearchSdk = (function () {
793
930
  headers.set('EdgeTagSessionId', sessionId);
794
931
  }
795
932
  headers.set('X-EvoSearch-Channel', getChannel());
933
+ // Only sent when the merchant is actually previewing; these routes preflight
934
+ // anyway, so the extra header costs no round-trip.
935
+ if (hasPreviewKey()) {
936
+ headers.set('X-EvoSearch-Preview', '1');
937
+ }
796
938
  return headers;
797
939
  };
798
940
  // Identity for the public read endpoints travels as query params instead of
@@ -807,6 +949,12 @@ var ProvidersEvoSearchSdk = (function () {
807
949
  url.searchParams.set('sid', sessionId);
808
950
  }
809
951
  url.searchParams.set('channel', getChannel());
952
+ // Emitted only when previewing: a live-traffic search URL has to stay
953
+ // byte-identical to today's or the edge cache fragments and the hit rate
954
+ // regresses on the per-keystroke hot path.
955
+ if (hasPreviewKey()) {
956
+ url.searchParams.set('preview', '1');
957
+ }
810
958
  };
811
959
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
812
960
  // every keystroke when `getURL` runs inside the autocomplete hot path.
@@ -919,7 +1067,7 @@ var ProvidersEvoSearchSdk = (function () {
919
1067
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
920
1068
  // Resilience lives here, not at each call site, so no consumer can opt out —
921
1069
  // the headless channel previously had no deadline and no retry.
922
- const search = async (query, limit = 8, signal) => {
1070
+ const search = async (query, limit = 24, signal) => {
923
1071
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
924
1072
  const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
925
1073
  // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
@@ -927,6 +1075,62 @@ var ProvidersEvoSearchSdk = (function () {
927
1075
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
928
1076
  return data;
929
1077
  };
1078
+ // Image search. Unlike `/search` there is no response cache on the worker
1079
+ // side and every call costs a multimodal embedding, so callers must debounce
1080
+ // and abort rather than fire per keystroke. A 403 `{disabled:true}` means the
1081
+ // merchant turned visual search off — surfaced as `disabled` on the thrown
1082
+ // error so the widget can hide the affordance instead of showing an error.
1083
+ //
1084
+ // A File/Blob (the common case headlessly: straight off an `<input
1085
+ // type="file">`) is downscaled and JPEG-encoded here, which is both the size
1086
+ // cap the worker enforces and the trust boundary that keeps a non-image off
1087
+ // the wire. A string is assumed to be a prepared data URI and passes through.
1088
+ const searchImage = async (image, text, limit, signal) => {
1089
+ var _a;
1090
+ const dataUri = typeof image === 'string' ? image : await downscaleImage(image);
1091
+ logger.info(`EvoSearch API: Image search request - text length: ${(_a = text === null || text === void 0 ? void 0 : text.length) !== null && _a !== void 0 ? _a : 0}`);
1092
+ const headers = getHeaders();
1093
+ headers.set('Content-Type', 'application/json');
1094
+ // The widget and the hook wrap this in a no-retry resilience budget; a raw
1095
+ // consumer that passes no signal gets the same deadline here rather than a
1096
+ // request that can hang indefinitely.
1097
+ const controller = new AbortController();
1098
+ const onCallerAbort = () => controller.abort();
1099
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onCallerAbort);
1100
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
1101
+ controller.abort();
1102
+ }
1103
+ const timer = setTimeout(() => controller.abort(), IMAGE_SEARCH_TIMEOUT_MS);
1104
+ let response;
1105
+ try {
1106
+ response = await fetchImpl(getURL('/search/image'), {
1107
+ method: 'POST',
1108
+ headers,
1109
+ body: JSON.stringify({
1110
+ image: dataUri,
1111
+ ...(text ? { text } : {}),
1112
+ ...(limit === undefined ? {} : { limit }),
1113
+ }),
1114
+ credentials: 'omit',
1115
+ signal: controller.signal,
1116
+ });
1117
+ }
1118
+ finally {
1119
+ clearTimeout(timer);
1120
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onCallerAbort);
1121
+ }
1122
+ if (!response.ok) {
1123
+ const errorText = await response.text().catch(() => '');
1124
+ logger.error(`EvoSearch API: ImageSearch failed - ${response.status}: ${errorText}`);
1125
+ const error = new Error(`ImageSearch failed - ${response.status}`);
1126
+ error.status = response.status;
1127
+ if (response.status === 403 && isDisabledBody(errorText)) {
1128
+ error.disabled = true;
1129
+ }
1130
+ throw error;
1131
+ }
1132
+ return (await response.json());
1133
+ };
930
1134
  // SSE consumer for Ask AI. Decodes `event: <type>\ndata: <json>` frames
931
1135
  // from the worker stream and yields typed events as they arrive. Caller
932
1136
  // aborts via the AbortSignal — fetch teardown closes the underlying
@@ -1092,10 +1296,19 @@ var ProvidersEvoSearchSdk = (function () {
1092
1296
  clearRecents,
1093
1297
  },
1094
1298
  search,
1299
+ searchImage,
1095
1300
  askStream,
1096
1301
  sendEvent,
1097
1302
  };
1098
1303
  };
1304
+ const isDisabledBody = (body) => {
1305
+ try {
1306
+ return JSON.parse(body).disabled === true;
1307
+ }
1308
+ catch {
1309
+ return false;
1310
+ }
1311
+ };
1099
1312
  const parseSseFrame = (frame) => {
1100
1313
  let eventType = '';
1101
1314
  const dataLines = [];
@@ -1162,8 +1375,10 @@ var ProvidersEvoSearchSdk = (function () {
1162
1375
  // A re-init can land on a different channel, or bail out entirely, so drop
1163
1376
  // any previous cadence before deciding which one this run owns.
1164
1377
  disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1165
- if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1378
+ const evsParam = new URLSearchParams(window.location.search).get('evs');
1379
+ if (evsParam === 'preview') {
1166
1380
  try {
1381
+ sessionStorage.removeItem(disableKeyName);
1167
1382
  sessionStorage.setItem(previewKeyName, '1');
1168
1383
  logger.info('EvoSearch SDK: Preview mode enabled via ?evs=preview URL param');
1169
1384
  }
@@ -1171,11 +1386,32 @@ var ProvidersEvoSearchSdk = (function () {
1171
1386
  error('EvoSearch SDK: Failed to set previewEvoSearch sessionStorage');
1172
1387
  }
1173
1388
  }
1389
+ else if (evsParam === 'off') {
1390
+ try {
1391
+ sessionStorage.removeItem(previewKeyName);
1392
+ sessionStorage.setItem(disableKeyName, '1');
1393
+ logger.info('EvoSearch SDK: Disabled via ?evs=off URL param');
1394
+ }
1395
+ catch {
1396
+ error('EvoSearch SDK: Failed to set disableEvoSearch sessionStorage');
1397
+ }
1398
+ }
1399
+ else if (evsParam === 'reset') {
1400
+ try {
1401
+ sessionStorage.removeItem(previewKeyName);
1402
+ sessionStorage.removeItem(disableKeyName);
1403
+ logger.info('EvoSearch SDK: Reset to configured state via ?evs=reset URL param');
1404
+ }
1405
+ catch {
1406
+ error('EvoSearch SDK: Failed to clear evoSearch sessionStorage');
1407
+ }
1408
+ }
1174
1409
  const { enabled, uiPreferences, askAi } = (_c = (_b = params.manifest) === null || _b === void 0 ? void 0 : _b.variables) !== null && _c !== void 0 ? _c : {};
1175
1410
  const mode = uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.mode;
1176
1411
  const askAiEnabled = (askAi === null || askAi === void 0 ? void 0 : askAi.enabled) === true;
1412
+ const visualSearchEnabled = (uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.visualSearch) === true;
1177
1413
  const hasPreview = hasPreviewKey();
1178
- const shouldEnable = enabled || hasPreview;
1414
+ const shouldEnable = !hasDisableKey() && (enabled || hasPreview);
1179
1415
  if (!shouldEnable) {
1180
1416
  logger.info('EvoSearch SDK: Mode is disabled, skipping initialization');
1181
1417
  return;
@@ -1223,6 +1459,7 @@ var ProvidersEvoSearchSdk = (function () {
1223
1459
  api: evoSearchAPI,
1224
1460
  uiPreferences,
1225
1461
  askAiEnabled,
1462
+ visualSearchEnabled,
1226
1463
  });
1227
1464
  params.sendTag({
1228
1465
  eventId: crypto.randomUUID(),
@@ -1245,7 +1482,7 @@ var ProvidersEvoSearchSdk = (function () {
1245
1482
  const tag = ({ data }) => {
1246
1483
  try {
1247
1484
  return {
1248
- sdkVersion: "1.66.2" || '',
1485
+ sdkVersion: "1.68.0" || '',
1249
1486
  searchQuery: data['searchQuery'],
1250
1487
  searchResults: data['searchResults'],
1251
1488
  timestamp: Date.now(),
@@ -1254,7 +1491,7 @@ var ProvidersEvoSearchSdk = (function () {
1254
1491
  catch (err) {
1255
1492
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1256
1493
  return {
1257
- sdkVersion: "1.66.2" ,
1494
+ sdkVersion: "1.68.0" ,
1258
1495
  timestamp: Date.now(),
1259
1496
  };
1260
1497
  }