@blotoutio/providers-evo-search-sdk 1.66.2 → 1.67.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.
@@ -927,6 +1064,62 @@ var ProvidersEvoSearchSdk = (function () {
927
1064
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
928
1065
  return data;
929
1066
  };
1067
+ // Image search. Unlike `/search` there is no response cache on the worker
1068
+ // side and every call costs a multimodal embedding, so callers must debounce
1069
+ // and abort rather than fire per keystroke. A 403 `{disabled:true}` means the
1070
+ // merchant turned visual search off — surfaced as `disabled` on the thrown
1071
+ // error so the widget can hide the affordance instead of showing an error.
1072
+ //
1073
+ // A File/Blob (the common case headlessly: straight off an `<input
1074
+ // type="file">`) is downscaled and JPEG-encoded here, which is both the size
1075
+ // cap the worker enforces and the trust boundary that keeps a non-image off
1076
+ // the wire. A string is assumed to be a prepared data URI and passes through.
1077
+ const searchImage = async (image, text, limit, signal) => {
1078
+ var _a;
1079
+ const dataUri = typeof image === 'string' ? image : await downscaleImage(image);
1080
+ 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}`);
1081
+ const headers = getHeaders();
1082
+ headers.set('Content-Type', 'application/json');
1083
+ // The widget and the hook wrap this in a no-retry resilience budget; a raw
1084
+ // consumer that passes no signal gets the same deadline here rather than a
1085
+ // request that can hang indefinitely.
1086
+ const controller = new AbortController();
1087
+ const onCallerAbort = () => controller.abort();
1088
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onCallerAbort);
1089
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
1090
+ controller.abort();
1091
+ }
1092
+ const timer = setTimeout(() => controller.abort(), IMAGE_SEARCH_TIMEOUT_MS);
1093
+ let response;
1094
+ try {
1095
+ response = await fetchImpl(getURL('/search/image'), {
1096
+ method: 'POST',
1097
+ headers,
1098
+ body: JSON.stringify({
1099
+ image: dataUri,
1100
+ ...(text ? { text } : {}),
1101
+ ...(limit === undefined ? {} : { limit }),
1102
+ }),
1103
+ credentials: 'omit',
1104
+ signal: controller.signal,
1105
+ });
1106
+ }
1107
+ finally {
1108
+ clearTimeout(timer);
1109
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onCallerAbort);
1110
+ }
1111
+ if (!response.ok) {
1112
+ const errorText = await response.text().catch(() => '');
1113
+ logger.error(`EvoSearch API: ImageSearch failed - ${response.status}: ${errorText}`);
1114
+ const error = new Error(`ImageSearch failed - ${response.status}`);
1115
+ error.status = response.status;
1116
+ if (response.status === 403 && isDisabledBody(errorText)) {
1117
+ error.disabled = true;
1118
+ }
1119
+ throw error;
1120
+ }
1121
+ return (await response.json());
1122
+ };
930
1123
  // SSE consumer for Ask AI. Decodes `event: <type>\ndata: <json>` frames
931
1124
  // from the worker stream and yields typed events as they arrive. Caller
932
1125
  // aborts via the AbortSignal — fetch teardown closes the underlying
@@ -1092,10 +1285,19 @@ var ProvidersEvoSearchSdk = (function () {
1092
1285
  clearRecents,
1093
1286
  },
1094
1287
  search,
1288
+ searchImage,
1095
1289
  askStream,
1096
1290
  sendEvent,
1097
1291
  };
1098
1292
  };
1293
+ const isDisabledBody = (body) => {
1294
+ try {
1295
+ return JSON.parse(body).disabled === true;
1296
+ }
1297
+ catch {
1298
+ return false;
1299
+ }
1300
+ };
1099
1301
  const parseSseFrame = (frame) => {
1100
1302
  let eventType = '';
1101
1303
  const dataLines = [];
@@ -1162,8 +1364,10 @@ var ProvidersEvoSearchSdk = (function () {
1162
1364
  // A re-init can land on a different channel, or bail out entirely, so drop
1163
1365
  // any previous cadence before deciding which one this run owns.
1164
1366
  disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1165
- if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1367
+ const evsParam = new URLSearchParams(window.location.search).get('evs');
1368
+ if (evsParam === 'preview') {
1166
1369
  try {
1370
+ sessionStorage.removeItem(disableKeyName);
1167
1371
  sessionStorage.setItem(previewKeyName, '1');
1168
1372
  logger.info('EvoSearch SDK: Preview mode enabled via ?evs=preview URL param');
1169
1373
  }
@@ -1171,11 +1375,32 @@ var ProvidersEvoSearchSdk = (function () {
1171
1375
  error('EvoSearch SDK: Failed to set previewEvoSearch sessionStorage');
1172
1376
  }
1173
1377
  }
1378
+ else if (evsParam === 'off') {
1379
+ try {
1380
+ sessionStorage.removeItem(previewKeyName);
1381
+ sessionStorage.setItem(disableKeyName, '1');
1382
+ logger.info('EvoSearch SDK: Disabled via ?evs=off URL param');
1383
+ }
1384
+ catch {
1385
+ error('EvoSearch SDK: Failed to set disableEvoSearch sessionStorage');
1386
+ }
1387
+ }
1388
+ else if (evsParam === 'reset') {
1389
+ try {
1390
+ sessionStorage.removeItem(previewKeyName);
1391
+ sessionStorage.removeItem(disableKeyName);
1392
+ logger.info('EvoSearch SDK: Reset to configured state via ?evs=reset URL param');
1393
+ }
1394
+ catch {
1395
+ error('EvoSearch SDK: Failed to clear evoSearch sessionStorage');
1396
+ }
1397
+ }
1174
1398
  const { enabled, uiPreferences, askAi } = (_c = (_b = params.manifest) === null || _b === void 0 ? void 0 : _b.variables) !== null && _c !== void 0 ? _c : {};
1175
1399
  const mode = uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.mode;
1176
1400
  const askAiEnabled = (askAi === null || askAi === void 0 ? void 0 : askAi.enabled) === true;
1401
+ const visualSearchEnabled = (uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.visualSearch) === true;
1177
1402
  const hasPreview = hasPreviewKey();
1178
- const shouldEnable = enabled || hasPreview;
1403
+ const shouldEnable = !hasDisableKey() && (enabled || hasPreview);
1179
1404
  if (!shouldEnable) {
1180
1405
  logger.info('EvoSearch SDK: Mode is disabled, skipping initialization');
1181
1406
  return;
@@ -1223,6 +1448,7 @@ var ProvidersEvoSearchSdk = (function () {
1223
1448
  api: evoSearchAPI,
1224
1449
  uiPreferences,
1225
1450
  askAiEnabled,
1451
+ visualSearchEnabled,
1226
1452
  });
1227
1453
  params.sendTag({
1228
1454
  eventId: crypto.randomUUID(),
@@ -1245,7 +1471,7 @@ var ProvidersEvoSearchSdk = (function () {
1245
1471
  const tag = ({ data }) => {
1246
1472
  try {
1247
1473
  return {
1248
- sdkVersion: "1.66.2" || '',
1474
+ sdkVersion: "1.67.0" || '',
1249
1475
  searchQuery: data['searchQuery'],
1250
1476
  searchResults: data['searchResults'],
1251
1477
  timestamp: Date.now(),
@@ -1254,7 +1480,7 @@ var ProvidersEvoSearchSdk = (function () {
1254
1480
  catch (err) {
1255
1481
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1256
1482
  return {
1257
- sdkVersion: "1.66.2" ,
1483
+ sdkVersion: "1.67.0" ,
1258
1484
  timestamp: Date.now(),
1259
1485
  };
1260
1486
  }
package/core.mjs CHANGED
@@ -596,6 +596,19 @@ const hasPreviewKey = () => {
596
596
  return false;
597
597
  }
598
598
  };
599
+ // Session-sticky opt-out for internal QA: ?evs=off suppresses the widget even
600
+ // when the manifest has it enabled, so the merchant's default site can be
601
+ // previewed. Takes precedence over both `enabled` and the preview flag.
602
+ const disableKeyName = 'disableEvoSearch';
603
+ const hasDisableKey = () => {
604
+ var _a;
605
+ try {
606
+ return ((_a = sessionStorage.getItem(disableKeyName)) !== null && _a !== void 0 ? _a : '0') === '1';
607
+ }
608
+ catch {
609
+ return false;
610
+ }
611
+ };
599
612
 
600
613
  var _a;
601
614
  const registryKey = Symbol.for('evo-search');
@@ -638,6 +651,10 @@ const notifyReady = () => {
638
651
  // caller-superseded (aborted) request. Shared by the widget and the headless
639
652
  // hook. See callers: search-widget.ts (performAutocomplete) and
640
653
  // hooks/use-evo-search.ts.
654
+ // Per-attempt budget for /search/image, shared by the widget and the headless
655
+ // hook so the two can't drift. Wider than the text search's 7s: the request
656
+ // carries an upload and waits on a multimodal embedding.
657
+ const IMAGE_SEARCH_TIMEOUT_MS = 15000;
641
658
  const DEFAULTS = {
642
659
  timeoutMs: 7000,
643
660
  retries: 2,
@@ -741,6 +758,126 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
741
758
  throw lastError;
742
759
  }
743
760
 
761
+ // Client-side downscale for image search. The worker caps the decoded upload at
762
+ // ~1.5MB and every request costs a multimodal embed, so shrinking a phone photo
763
+ // here (a 12MP JPEG is ~4MB) is what keeps the request inside the cap and the
764
+ // upload quick on mobile data. Matches the sync-time `imageEmbedWidth` (see
765
+ // search-tuning.ts) so query and index renditions land in the same Gemini
766
+ // tiling bracket — a JPEG at this edge is still two orders of magnitude below
767
+ // the worker's decoded-size cap.
768
+ const MAX_IMAGE_EDGE = 768;
769
+ const JPEG_QUALITY = 0.85;
770
+ // Fallback decode for engines without createImageBitmap. Orientation here is
771
+ // whatever the UA's `image-orientation` default is — see decodeImage.
772
+ const loadImage = (source) => new Promise((resolve, reject) => {
773
+ const objectUrl = URL.createObjectURL(source);
774
+ const image = new Image();
775
+ image.onload = () => {
776
+ URL.revokeObjectURL(objectUrl);
777
+ resolve(image);
778
+ };
779
+ image.onerror = () => {
780
+ URL.revokeObjectURL(objectUrl);
781
+ reject(new Error('Image could not be decoded'));
782
+ };
783
+ image.src = objectUrl;
784
+ });
785
+ // createImageBitmap pins EXIF orientation explicitly, so a portrait phone photo
786
+ // is embedded upright even on engines that predate the
787
+ // `image-orientation: from-image` default (older iOS Safari, the embedded
788
+ // Android WebViews storefront traffic includes) — a sideways embed degrades
789
+ // retrieval with no error to show for it. It also skips the full-size <img>
790
+ // decode. So it goes first, and the <img> path catches everything it can't do:
791
+ // engines that lack the API, and the sources it rejects outright but <img>
792
+ // renders (an SVG with no intrinsic size is the one that shows up).
793
+ // A very large source can still yield a blank canvas on old iOS; if blank
794
+ // embeds are ever observed, add a source pixel-count ceiling here.
795
+ const decodeImage = async (source) => {
796
+ if (typeof createImageBitmap === 'function') {
797
+ try {
798
+ const bitmap = await createImageBitmap(source, {
799
+ imageOrientation: 'from-image',
800
+ });
801
+ return {
802
+ image: bitmap,
803
+ width: bitmap.width,
804
+ height: bitmap.height,
805
+ // Phone memory doesn't wait for GC to reclaim a decoded bitmap.
806
+ release: () => bitmap.close(),
807
+ };
808
+ }
809
+ catch {
810
+ // Fall through: a rejection here means "this decoder can't", not "this
811
+ // isn't an image". The legacy path decides, and only its failure is the
812
+ // shopper's error.
813
+ }
814
+ }
815
+ const image = await loadImage(source);
816
+ return {
817
+ image,
818
+ width: image.naturalWidth,
819
+ height: image.naturalHeight,
820
+ release: () => undefined,
821
+ };
822
+ };
823
+ // Draw `image` — or just `region` of it — into a fresh canvas whose longest
824
+ // edge is at most `maxEdge`, on a white ground. A cropped region is scaled to
825
+ // the same edge cap as the whole frame, so a selection is embedded at the
826
+ // resolution the index renditions were built at rather than at whatever
827
+ // fraction of the frame it happened to occupy.
828
+ const renderToCanvas = (image, sourceWidth, sourceHeight, maxEdge, region) => {
829
+ const sx = region ? Math.round(region.x * sourceWidth) : 0;
830
+ const sy = region ? Math.round(region.y * sourceHeight) : 0;
831
+ const sw = region
832
+ ? Math.max(1, Math.round(region.w * sourceWidth))
833
+ : sourceWidth;
834
+ const sh = region
835
+ ? Math.max(1, Math.round(region.h * sourceHeight))
836
+ : sourceHeight;
837
+ const longestEdge = Math.max(sw, sh);
838
+ const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
839
+ const canvas = document.createElement('canvas');
840
+ canvas.width = Math.max(1, Math.round(sw * scale));
841
+ canvas.height = Math.max(1, Math.round(sh * scale));
842
+ const context = canvas.getContext('2d');
843
+ if (!context) {
844
+ throw new Error('Canvas is unavailable');
845
+ }
846
+ // JPEG has no alpha, so a transparent source (a PNG/WebP cutout, the common
847
+ // paste case) would otherwise composite onto the canvas's transparent-black
848
+ // default and be embedded as a black-backed product. Catalog shots are
849
+ // typically white-background, so white is the closer prior.
850
+ context.fillStyle = '#ffffff';
851
+ context.fillRect(0, 0, canvas.width, canvas.height);
852
+ if (region) {
853
+ context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
854
+ }
855
+ else {
856
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
857
+ }
858
+ return canvas;
859
+ };
860
+ /**
861
+ * Downscale `source` to a JPEG data URI whose longest edge is at most
862
+ * `MAX_IMAGE_EDGE`, upright regardless of the source's EXIF orientation.
863
+ * Rejects anything that isn't an image, and anything the browser can't decode
864
+ * (HEIC off iOS, corrupt files) — every input path (file picker, drop, paste) is
865
+ * shopper-controlled, so this is the trust boundary that keeps a stray PDF or
866
+ * text drop off the wire.
867
+ */
868
+ const downscaleImage = async (source) => {
869
+ if (!source.type.startsWith('image/')) {
870
+ throw new Error('Only image files can be searched');
871
+ }
872
+ const { image, width, height, release } = await decodeImage(source);
873
+ try {
874
+ return renderToCanvas(image, width, height, MAX_IMAGE_EDGE).toDataURL('image/jpeg', JPEG_QUALITY);
875
+ }
876
+ finally {
877
+ release();
878
+ }
879
+ };
880
+
744
881
  // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
745
882
  // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
746
883
  // routes them to the search fallback rather than treating them as cancellation.
@@ -924,6 +1061,62 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
924
1061
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
925
1062
  return data;
926
1063
  };
1064
+ // Image search. Unlike `/search` there is no response cache on the worker
1065
+ // side and every call costs a multimodal embedding, so callers must debounce
1066
+ // and abort rather than fire per keystroke. A 403 `{disabled:true}` means the
1067
+ // merchant turned visual search off — surfaced as `disabled` on the thrown
1068
+ // error so the widget can hide the affordance instead of showing an error.
1069
+ //
1070
+ // A File/Blob (the common case headlessly: straight off an `<input
1071
+ // type="file">`) is downscaled and JPEG-encoded here, which is both the size
1072
+ // cap the worker enforces and the trust boundary that keeps a non-image off
1073
+ // the wire. A string is assumed to be a prepared data URI and passes through.
1074
+ const searchImage = async (image, text, limit, signal) => {
1075
+ var _a;
1076
+ const dataUri = typeof image === 'string' ? image : await downscaleImage(image);
1077
+ 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}`);
1078
+ const headers = getHeaders();
1079
+ headers.set('Content-Type', 'application/json');
1080
+ // The widget and the hook wrap this in a no-retry resilience budget; a raw
1081
+ // consumer that passes no signal gets the same deadline here rather than a
1082
+ // request that can hang indefinitely.
1083
+ const controller = new AbortController();
1084
+ const onCallerAbort = () => controller.abort();
1085
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onCallerAbort);
1086
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
1087
+ controller.abort();
1088
+ }
1089
+ const timer = setTimeout(() => controller.abort(), IMAGE_SEARCH_TIMEOUT_MS);
1090
+ let response;
1091
+ try {
1092
+ response = await fetchImpl(getURL('/search/image'), {
1093
+ method: 'POST',
1094
+ headers,
1095
+ body: JSON.stringify({
1096
+ image: dataUri,
1097
+ ...(text ? { text } : {}),
1098
+ ...(limit === undefined ? {} : { limit }),
1099
+ }),
1100
+ credentials: 'omit',
1101
+ signal: controller.signal,
1102
+ });
1103
+ }
1104
+ finally {
1105
+ clearTimeout(timer);
1106
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onCallerAbort);
1107
+ }
1108
+ if (!response.ok) {
1109
+ const errorText = await response.text().catch(() => '');
1110
+ logger.error(`EvoSearch API: ImageSearch failed - ${response.status}: ${errorText}`);
1111
+ const error = new Error(`ImageSearch failed - ${response.status}`);
1112
+ error.status = response.status;
1113
+ if (response.status === 403 && isDisabledBody(errorText)) {
1114
+ error.disabled = true;
1115
+ }
1116
+ throw error;
1117
+ }
1118
+ return (await response.json());
1119
+ };
927
1120
  // SSE consumer for Ask AI. Decodes `event: <type>\ndata: <json>` frames
928
1121
  // from the worker stream and yields typed events as they arrive. Caller
929
1122
  // aborts via the AbortSignal — fetch teardown closes the underlying
@@ -1089,10 +1282,19 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1089
1282
  clearRecents,
1090
1283
  },
1091
1284
  search,
1285
+ searchImage,
1092
1286
  askStream,
1093
1287
  sendEvent,
1094
1288
  };
1095
1289
  };
1290
+ const isDisabledBody = (body) => {
1291
+ try {
1292
+ return JSON.parse(body).disabled === true;
1293
+ }
1294
+ catch {
1295
+ return false;
1296
+ }
1297
+ };
1096
1298
  const parseSseFrame = (frame) => {
1097
1299
  let eventType = '';
1098
1300
  const dataLines = [];
@@ -1159,8 +1361,10 @@ const init = (params) => {
1159
1361
  // A re-init can land on a different channel, or bail out entirely, so drop
1160
1362
  // any previous cadence before deciding which one this run owns.
1161
1363
  disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1162
- if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1364
+ const evsParam = new URLSearchParams(window.location.search).get('evs');
1365
+ if (evsParam === 'preview') {
1163
1366
  try {
1367
+ sessionStorage.removeItem(disableKeyName);
1164
1368
  sessionStorage.setItem(previewKeyName, '1');
1165
1369
  logger.info('EvoSearch SDK: Preview mode enabled via ?evs=preview URL param');
1166
1370
  }
@@ -1168,11 +1372,32 @@ const init = (params) => {
1168
1372
  error('EvoSearch SDK: Failed to set previewEvoSearch sessionStorage');
1169
1373
  }
1170
1374
  }
1375
+ else if (evsParam === 'off') {
1376
+ try {
1377
+ sessionStorage.removeItem(previewKeyName);
1378
+ sessionStorage.setItem(disableKeyName, '1');
1379
+ logger.info('EvoSearch SDK: Disabled via ?evs=off URL param');
1380
+ }
1381
+ catch {
1382
+ error('EvoSearch SDK: Failed to set disableEvoSearch sessionStorage');
1383
+ }
1384
+ }
1385
+ else if (evsParam === 'reset') {
1386
+ try {
1387
+ sessionStorage.removeItem(previewKeyName);
1388
+ sessionStorage.removeItem(disableKeyName);
1389
+ logger.info('EvoSearch SDK: Reset to configured state via ?evs=reset URL param');
1390
+ }
1391
+ catch {
1392
+ error('EvoSearch SDK: Failed to clear evoSearch sessionStorage');
1393
+ }
1394
+ }
1171
1395
  const { enabled, uiPreferences, askAi } = (_c = (_b = params.manifest) === null || _b === void 0 ? void 0 : _b.variables) !== null && _c !== void 0 ? _c : {};
1172
1396
  const mode = uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.mode;
1173
1397
  const askAiEnabled = (askAi === null || askAi === void 0 ? void 0 : askAi.enabled) === true;
1398
+ const visualSearchEnabled = (uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.visualSearch) === true;
1174
1399
  const hasPreview = hasPreviewKey();
1175
- const shouldEnable = enabled || hasPreview;
1400
+ const shouldEnable = !hasDisableKey() && (enabled || hasPreview);
1176
1401
  if (!shouldEnable) {
1177
1402
  logger.info('EvoSearch SDK: Mode is disabled, skipping initialization');
1178
1403
  return;
@@ -1220,6 +1445,7 @@ const init = (params) => {
1220
1445
  api: evoSearchAPI,
1221
1446
  uiPreferences,
1222
1447
  askAiEnabled,
1448
+ visualSearchEnabled,
1223
1449
  });
1224
1450
  params.sendTag({
1225
1451
  eventId: crypto.randomUUID(),
@@ -1242,7 +1468,7 @@ const init = (params) => {
1242
1468
  const tag = ({ data }) => {
1243
1469
  try {
1244
1470
  return {
1245
- sdkVersion: "1.66.2" || '',
1471
+ sdkVersion: "1.67.0" || '',
1246
1472
  searchQuery: data['searchQuery'],
1247
1473
  searchResults: data['searchResults'],
1248
1474
  timestamp: Date.now(),
@@ -1251,7 +1477,7 @@ const tag = ({ data }) => {
1251
1477
  catch (err) {
1252
1478
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1253
1479
  return {
1254
- sdkVersion: "1.66.2" ,
1480
+ sdkVersion: "1.67.0" ,
1255
1481
  timestamp: Date.now(),
1256
1482
  };
1257
1483
  }