@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/README.md CHANGED
@@ -54,6 +54,122 @@ function HeaderSearch() {
54
54
  }
55
55
  ```
56
56
 
57
+ ## Image search
58
+
59
+ Shoppers can search with a photo instead of words. The hook exposes it as one extra action that writes the same `results` / `loading` / `error` state as the text path, so you render one result list:
60
+
61
+ ```tsx
62
+ import { useEvoSearch } from '@blotoutio/providers-evo-search-sdk/hooks'
63
+
64
+ function ImageSearch() {
65
+ const { query, searchByImage, results, loading, error } = useEvoSearch()
66
+ return (
67
+ <>
68
+ <input
69
+ type='file'
70
+ accept='image/*'
71
+ onChange={(e) => {
72
+ const file = e.target.files?.[0]
73
+ if (file) {
74
+ // Second argument is an optional text refinement ("in blue").
75
+ void searchByImage(file, query)
76
+ }
77
+ }}
78
+ />
79
+ {loading && <span>Searching…</span>}
80
+ {error && <span>Image search failed. Try again.</span>}
81
+ <ul>
82
+ {results.map((p) => (
83
+ <li key={p.id}>{p.title}</li>
84
+ ))}
85
+ </ul>
86
+ </>
87
+ )
88
+ }
89
+ ```
90
+
91
+ Pass a `File` or `Blob` straight from the file input (or a drop / paste handler) — it is downscaled and JPEG-encoded for you. A `string` is taken as an already-prepared `data:` URI and sent as-is.
92
+
93
+ ### Check the merchant gate first
94
+
95
+ Image search is off unless the merchant enabled it, exactly like Ask AI. Read it from the API config and hide the affordance when it is not enabled:
96
+
97
+ ```tsx
98
+ import { useEffect, useState } from 'react'
99
+ import { useEvoSearchAPI } from '@blotoutio/providers-evo-search-sdk/hooks'
100
+
101
+ function useVisualSearchEnabled() {
102
+ const { api, ready } = useEvoSearchAPI()
103
+ const [enabled, setEnabled] = useState(false)
104
+ useEffect(() => {
105
+ if (!ready || !api) {
106
+ return
107
+ }
108
+ void api
109
+ // Older workers omit the field, so compare strictly — a missing value
110
+ // means off, never on.
111
+ .config()
112
+ .then((config) => setEnabled(config.visualSearchEnabled === true))
113
+ .catch(() => setEnabled(false))
114
+ }, [api, ready])
115
+ return enabled
116
+ }
117
+ ```
118
+
119
+ If the merchant turns it off between that check and a search, the request fails with `403` and the rejection carries `disabled: true`. Treat that as "hide the entry point", not as an error to show:
120
+
121
+ ```tsx
122
+ import type { EvoSearchAPIError } from '@blotoutio/providers-evo-search-sdk/hooks'
123
+
124
+ if ((error as EvoSearchAPIError | null)?.disabled) {
125
+ // Hide the camera button — nothing the shopper can retry.
126
+ }
127
+ ```
128
+
129
+ ### The raw API (non-React, or your own state)
130
+
131
+ In React, `useEvoSearchAPI()` hands you the API object:
132
+
133
+ ```tsx
134
+ const { api } = useEvoSearchAPI()
135
+ const { mode, products } = await api.searchImage(file, 'in blue')
136
+ ```
137
+
138
+ It is the same object the registry holds, so a vanilla storefront can reach it without the hook:
139
+
140
+ ```js
141
+ const api = window[Symbol.for('evo-search')]?.api
142
+ const { mode, products } = await api.searchImage(file, 'in blue')
143
+ ```
144
+
145
+ `searchImage` returns the API product shape, not the normalized one `useEvoSearch().results` yields. Map it if you want both paths to render through the same components:
146
+
147
+ ```js
148
+ import { mapAPIProductToSDKProduct } from '@blotoutio/providers-evo-search-sdk/hooks'
149
+
150
+ const normalized = products.map(mapAPIProductToSDKProduct)
151
+ ```
152
+
153
+ ### Timeouts are your responsibility on the raw API
154
+
155
+ `searchImage` has no built-in deadline: pass an `AbortSignal` so a stalled embedding can't hang your UI forever. Do not retry — every attempt costs a multimodal embedding, and there is no response cache on this route. `useEvoSearch().searchByImage` already applies this policy (no retries, 15s budget):
156
+
157
+ ```js
158
+ const controller = new AbortController()
159
+ const timer = setTimeout(() => controller.abort(), 15_000)
160
+ try {
161
+ await api.searchImage(file, undefined, undefined, controller.signal)
162
+ } finally {
163
+ clearTimeout(timer)
164
+ }
165
+ ```
166
+
167
+ Debounce and abort rather than firing per keystroke when a text refinement is attached to an image.
168
+
169
+ ### Why 768px
170
+
171
+ Uploads are downscaled to a 768px long edge before they leave the browser: retrieval quality was tuned at that rendition, and the endpoint caps the decoded payload (~1.5MB), which a phone photo blows past. If you want to prepare or preview the data URI yourself, `downscaleImage(file)` and `MAX_IMAGE_EDGE` are exported from the same entry point — it rejects anything that isn't an image.
172
+
57
173
  ## Tracking interactions
58
174
 
59
175
  EvoSearch needs three interaction signals to power the dashboard: searches, result clicks, and cart/purchase events. **In a headless integration, only `trackClick` requires code on your side.**
package/core.cjs.js CHANGED
@@ -598,6 +598,19 @@ const hasPreviewKey = () => {
598
598
  return false;
599
599
  }
600
600
  };
601
+ // Session-sticky opt-out for internal QA: ?evs=off suppresses the widget even
602
+ // when the manifest has it enabled, so the merchant's default site can be
603
+ // previewed. Takes precedence over both `enabled` and the preview flag.
604
+ const disableKeyName = 'disableEvoSearch';
605
+ const hasDisableKey = () => {
606
+ var _a;
607
+ try {
608
+ return ((_a = sessionStorage.getItem(disableKeyName)) !== null && _a !== void 0 ? _a : '0') === '1';
609
+ }
610
+ catch {
611
+ return false;
612
+ }
613
+ };
601
614
 
602
615
  var _a;
603
616
  const registryKey = Symbol.for('evo-search');
@@ -640,6 +653,10 @@ const notifyReady = () => {
640
653
  // caller-superseded (aborted) request. Shared by the widget and the headless
641
654
  // hook. See callers: search-widget.ts (performAutocomplete) and
642
655
  // hooks/use-evo-search.ts.
656
+ // Per-attempt budget for /search/image, shared by the widget and the headless
657
+ // hook so the two can't drift. Wider than the text search's 7s: the request
658
+ // carries an upload and waits on a multimodal embedding.
659
+ const IMAGE_SEARCH_TIMEOUT_MS = 15000;
643
660
  const DEFAULTS = {
644
661
  timeoutMs: 7000,
645
662
  retries: 2,
@@ -743,6 +760,126 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
743
760
  throw lastError;
744
761
  }
745
762
 
763
+ // Client-side downscale for image search. The worker caps the decoded upload at
764
+ // ~1.5MB and every request costs a multimodal embed, so shrinking a phone photo
765
+ // here (a 12MP JPEG is ~4MB) is what keeps the request inside the cap and the
766
+ // upload quick on mobile data. Matches the sync-time `imageEmbedWidth` (see
767
+ // search-tuning.ts) so query and index renditions land in the same Gemini
768
+ // tiling bracket — a JPEG at this edge is still two orders of magnitude below
769
+ // the worker's decoded-size cap.
770
+ const MAX_IMAGE_EDGE = 768;
771
+ const JPEG_QUALITY = 0.85;
772
+ // Fallback decode for engines without createImageBitmap. Orientation here is
773
+ // whatever the UA's `image-orientation` default is — see decodeImage.
774
+ const loadImage = (source) => new Promise((resolve, reject) => {
775
+ const objectUrl = URL.createObjectURL(source);
776
+ const image = new Image();
777
+ image.onload = () => {
778
+ URL.revokeObjectURL(objectUrl);
779
+ resolve(image);
780
+ };
781
+ image.onerror = () => {
782
+ URL.revokeObjectURL(objectUrl);
783
+ reject(new Error('Image could not be decoded'));
784
+ };
785
+ image.src = objectUrl;
786
+ });
787
+ // createImageBitmap pins EXIF orientation explicitly, so a portrait phone photo
788
+ // is embedded upright even on engines that predate the
789
+ // `image-orientation: from-image` default (older iOS Safari, the embedded
790
+ // Android WebViews storefront traffic includes) — a sideways embed degrades
791
+ // retrieval with no error to show for it. It also skips the full-size <img>
792
+ // decode. So it goes first, and the <img> path catches everything it can't do:
793
+ // engines that lack the API, and the sources it rejects outright but <img>
794
+ // renders (an SVG with no intrinsic size is the one that shows up).
795
+ // A very large source can still yield a blank canvas on old iOS; if blank
796
+ // embeds are ever observed, add a source pixel-count ceiling here.
797
+ const decodeImage = async (source) => {
798
+ if (typeof createImageBitmap === 'function') {
799
+ try {
800
+ const bitmap = await createImageBitmap(source, {
801
+ imageOrientation: 'from-image',
802
+ });
803
+ return {
804
+ image: bitmap,
805
+ width: bitmap.width,
806
+ height: bitmap.height,
807
+ // Phone memory doesn't wait for GC to reclaim a decoded bitmap.
808
+ release: () => bitmap.close(),
809
+ };
810
+ }
811
+ catch {
812
+ // Fall through: a rejection here means "this decoder can't", not "this
813
+ // isn't an image". The legacy path decides, and only its failure is the
814
+ // shopper's error.
815
+ }
816
+ }
817
+ const image = await loadImage(source);
818
+ return {
819
+ image,
820
+ width: image.naturalWidth,
821
+ height: image.naturalHeight,
822
+ release: () => undefined,
823
+ };
824
+ };
825
+ // Draw `image` — or just `region` of it — into a fresh canvas whose longest
826
+ // edge is at most `maxEdge`, on a white ground. A cropped region is scaled to
827
+ // the same edge cap as the whole frame, so a selection is embedded at the
828
+ // resolution the index renditions were built at rather than at whatever
829
+ // fraction of the frame it happened to occupy.
830
+ const renderToCanvas = (image, sourceWidth, sourceHeight, maxEdge, region) => {
831
+ const sx = region ? Math.round(region.x * sourceWidth) : 0;
832
+ const sy = region ? Math.round(region.y * sourceHeight) : 0;
833
+ const sw = region
834
+ ? Math.max(1, Math.round(region.w * sourceWidth))
835
+ : sourceWidth;
836
+ const sh = region
837
+ ? Math.max(1, Math.round(region.h * sourceHeight))
838
+ : sourceHeight;
839
+ const longestEdge = Math.max(sw, sh);
840
+ const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
841
+ const canvas = document.createElement('canvas');
842
+ canvas.width = Math.max(1, Math.round(sw * scale));
843
+ canvas.height = Math.max(1, Math.round(sh * scale));
844
+ const context = canvas.getContext('2d');
845
+ if (!context) {
846
+ throw new Error('Canvas is unavailable');
847
+ }
848
+ // JPEG has no alpha, so a transparent source (a PNG/WebP cutout, the common
849
+ // paste case) would otherwise composite onto the canvas's transparent-black
850
+ // default and be embedded as a black-backed product. Catalog shots are
851
+ // typically white-background, so white is the closer prior.
852
+ context.fillStyle = '#ffffff';
853
+ context.fillRect(0, 0, canvas.width, canvas.height);
854
+ if (region) {
855
+ context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
856
+ }
857
+ else {
858
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
859
+ }
860
+ return canvas;
861
+ };
862
+ /**
863
+ * Downscale `source` to a JPEG data URI whose longest edge is at most
864
+ * `MAX_IMAGE_EDGE`, upright regardless of the source's EXIF orientation.
865
+ * Rejects anything that isn't an image, and anything the browser can't decode
866
+ * (HEIC off iOS, corrupt files) — every input path (file picker, drop, paste) is
867
+ * shopper-controlled, so this is the trust boundary that keeps a stray PDF or
868
+ * text drop off the wire.
869
+ */
870
+ const downscaleImage = async (source) => {
871
+ if (!source.type.startsWith('image/')) {
872
+ throw new Error('Only image files can be searched');
873
+ }
874
+ const { image, width, height, release } = await decodeImage(source);
875
+ try {
876
+ return renderToCanvas(image, width, height, MAX_IMAGE_EDGE).toDataURL('image/jpeg', JPEG_QUALITY);
877
+ }
878
+ finally {
879
+ release();
880
+ }
881
+ };
882
+
746
883
  // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
747
884
  // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
748
885
  // routes them to the search fallback rather than treating them as cancellation.
@@ -792,6 +929,11 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
792
929
  headers.set('EdgeTagSessionId', sessionId);
793
930
  }
794
931
  headers.set('X-EvoSearch-Channel', getChannel());
932
+ // Only sent when the merchant is actually previewing; these routes preflight
933
+ // anyway, so the extra header costs no round-trip.
934
+ if (hasPreviewKey()) {
935
+ headers.set('X-EvoSearch-Preview', '1');
936
+ }
795
937
  return headers;
796
938
  };
797
939
  // Identity for the public read endpoints travels as query params instead of
@@ -806,6 +948,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
806
948
  url.searchParams.set('sid', sessionId);
807
949
  }
808
950
  url.searchParams.set('channel', getChannel());
951
+ // Emitted only when previewing: a live-traffic search URL has to stay
952
+ // byte-identical to today's or the edge cache fragments and the hit rate
953
+ // regresses on the per-keystroke hot path.
954
+ if (hasPreviewKey()) {
955
+ url.searchParams.set('preview', '1');
956
+ }
809
957
  };
810
958
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
811
959
  // every keystroke when `getURL` runs inside the autocomplete hot path.
@@ -918,7 +1066,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
918
1066
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
919
1067
  // Resilience lives here, not at each call site, so no consumer can opt out —
920
1068
  // the headless channel previously had no deadline and no retry.
921
- const search = async (query, limit = 8, signal) => {
1069
+ const search = async (query, limit = 24, signal) => {
922
1070
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
923
1071
  const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
924
1072
  // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
@@ -926,6 +1074,62 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
926
1074
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
927
1075
  return data;
928
1076
  };
1077
+ // Image search. Unlike `/search` there is no response cache on the worker
1078
+ // side and every call costs a multimodal embedding, so callers must debounce
1079
+ // and abort rather than fire per keystroke. A 403 `{disabled:true}` means the
1080
+ // merchant turned visual search off — surfaced as `disabled` on the thrown
1081
+ // error so the widget can hide the affordance instead of showing an error.
1082
+ //
1083
+ // A File/Blob (the common case headlessly: straight off an `<input
1084
+ // type="file">`) is downscaled and JPEG-encoded here, which is both the size
1085
+ // cap the worker enforces and the trust boundary that keeps a non-image off
1086
+ // the wire. A string is assumed to be a prepared data URI and passes through.
1087
+ const searchImage = async (image, text, limit, signal) => {
1088
+ var _a;
1089
+ const dataUri = typeof image === 'string' ? image : await downscaleImage(image);
1090
+ 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}`);
1091
+ const headers = getHeaders();
1092
+ headers.set('Content-Type', 'application/json');
1093
+ // The widget and the hook wrap this in a no-retry resilience budget; a raw
1094
+ // consumer that passes no signal gets the same deadline here rather than a
1095
+ // request that can hang indefinitely.
1096
+ const controller = new AbortController();
1097
+ const onCallerAbort = () => controller.abort();
1098
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onCallerAbort);
1099
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
1100
+ controller.abort();
1101
+ }
1102
+ const timer = setTimeout(() => controller.abort(), IMAGE_SEARCH_TIMEOUT_MS);
1103
+ let response;
1104
+ try {
1105
+ response = await fetchImpl(getURL('/search/image'), {
1106
+ method: 'POST',
1107
+ headers,
1108
+ body: JSON.stringify({
1109
+ image: dataUri,
1110
+ ...(text ? { text } : {}),
1111
+ ...(limit === undefined ? {} : { limit }),
1112
+ }),
1113
+ credentials: 'omit',
1114
+ signal: controller.signal,
1115
+ });
1116
+ }
1117
+ finally {
1118
+ clearTimeout(timer);
1119
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onCallerAbort);
1120
+ }
1121
+ if (!response.ok) {
1122
+ const errorText = await response.text().catch(() => '');
1123
+ logger.error(`EvoSearch API: ImageSearch failed - ${response.status}: ${errorText}`);
1124
+ const error = new Error(`ImageSearch failed - ${response.status}`);
1125
+ error.status = response.status;
1126
+ if (response.status === 403 && isDisabledBody(errorText)) {
1127
+ error.disabled = true;
1128
+ }
1129
+ throw error;
1130
+ }
1131
+ return (await response.json());
1132
+ };
929
1133
  // SSE consumer for Ask AI. Decodes `event: <type>\ndata: <json>` frames
930
1134
  // from the worker stream and yields typed events as they arrive. Caller
931
1135
  // aborts via the AbortSignal — fetch teardown closes the underlying
@@ -1091,10 +1295,19 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1091
1295
  clearRecents,
1092
1296
  },
1093
1297
  search,
1298
+ searchImage,
1094
1299
  askStream,
1095
1300
  sendEvent,
1096
1301
  };
1097
1302
  };
1303
+ const isDisabledBody = (body) => {
1304
+ try {
1305
+ return JSON.parse(body).disabled === true;
1306
+ }
1307
+ catch {
1308
+ return false;
1309
+ }
1310
+ };
1098
1311
  const parseSseFrame = (frame) => {
1099
1312
  let eventType = '';
1100
1313
  const dataLines = [];
@@ -1161,8 +1374,10 @@ const init = (params) => {
1161
1374
  // A re-init can land on a different channel, or bail out entirely, so drop
1162
1375
  // any previous cadence before deciding which one this run owns.
1163
1376
  disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1164
- if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1377
+ const evsParam = new URLSearchParams(window.location.search).get('evs');
1378
+ if (evsParam === 'preview') {
1165
1379
  try {
1380
+ sessionStorage.removeItem(disableKeyName);
1166
1381
  sessionStorage.setItem(previewKeyName, '1');
1167
1382
  logger.info('EvoSearch SDK: Preview mode enabled via ?evs=preview URL param');
1168
1383
  }
@@ -1170,11 +1385,32 @@ const init = (params) => {
1170
1385
  error('EvoSearch SDK: Failed to set previewEvoSearch sessionStorage');
1171
1386
  }
1172
1387
  }
1388
+ else if (evsParam === 'off') {
1389
+ try {
1390
+ sessionStorage.removeItem(previewKeyName);
1391
+ sessionStorage.setItem(disableKeyName, '1');
1392
+ logger.info('EvoSearch SDK: Disabled via ?evs=off URL param');
1393
+ }
1394
+ catch {
1395
+ error('EvoSearch SDK: Failed to set disableEvoSearch sessionStorage');
1396
+ }
1397
+ }
1398
+ else if (evsParam === 'reset') {
1399
+ try {
1400
+ sessionStorage.removeItem(previewKeyName);
1401
+ sessionStorage.removeItem(disableKeyName);
1402
+ logger.info('EvoSearch SDK: Reset to configured state via ?evs=reset URL param');
1403
+ }
1404
+ catch {
1405
+ error('EvoSearch SDK: Failed to clear evoSearch sessionStorage');
1406
+ }
1407
+ }
1173
1408
  const { enabled, uiPreferences, askAi } = (_c = (_b = params.manifest) === null || _b === void 0 ? void 0 : _b.variables) !== null && _c !== void 0 ? _c : {};
1174
1409
  const mode = uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.mode;
1175
1410
  const askAiEnabled = (askAi === null || askAi === void 0 ? void 0 : askAi.enabled) === true;
1411
+ const visualSearchEnabled = (uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.visualSearch) === true;
1176
1412
  const hasPreview = hasPreviewKey();
1177
- const shouldEnable = enabled || hasPreview;
1413
+ const shouldEnable = !hasDisableKey() && (enabled || hasPreview);
1178
1414
  if (!shouldEnable) {
1179
1415
  logger.info('EvoSearch SDK: Mode is disabled, skipping initialization');
1180
1416
  return;
@@ -1222,6 +1458,7 @@ const init = (params) => {
1222
1458
  api: evoSearchAPI,
1223
1459
  uiPreferences,
1224
1460
  askAiEnabled,
1461
+ visualSearchEnabled,
1225
1462
  });
1226
1463
  params.sendTag({
1227
1464
  eventId: crypto.randomUUID(),
@@ -1244,7 +1481,7 @@ const init = (params) => {
1244
1481
  const tag = ({ data }) => {
1245
1482
  try {
1246
1483
  return {
1247
- sdkVersion: "1.66.2" || '',
1484
+ sdkVersion: "1.68.0" || '',
1248
1485
  searchQuery: data['searchQuery'],
1249
1486
  searchResults: data['searchResults'],
1250
1487
  timestamp: Date.now(),
@@ -1253,7 +1490,7 @@ const tag = ({ data }) => {
1253
1490
  catch (err) {
1254
1491
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1255
1492
  return {
1256
- sdkVersion: "1.66.2" ,
1493
+ sdkVersion: "1.68.0" ,
1257
1494
  timestamp: Date.now(),
1258
1495
  };
1259
1496
  }