@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/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.
@@ -926,6 +1063,62 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
926
1063
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
927
1064
  return data;
928
1065
  };
1066
+ // Image search. Unlike `/search` there is no response cache on the worker
1067
+ // side and every call costs a multimodal embedding, so callers must debounce
1068
+ // and abort rather than fire per keystroke. A 403 `{disabled:true}` means the
1069
+ // merchant turned visual search off — surfaced as `disabled` on the thrown
1070
+ // error so the widget can hide the affordance instead of showing an error.
1071
+ //
1072
+ // A File/Blob (the common case headlessly: straight off an `<input
1073
+ // type="file">`) is downscaled and JPEG-encoded here, which is both the size
1074
+ // cap the worker enforces and the trust boundary that keeps a non-image off
1075
+ // the wire. A string is assumed to be a prepared data URI and passes through.
1076
+ const searchImage = async (image, text, limit, signal) => {
1077
+ var _a;
1078
+ const dataUri = typeof image === 'string' ? image : await downscaleImage(image);
1079
+ 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}`);
1080
+ const headers = getHeaders();
1081
+ headers.set('Content-Type', 'application/json');
1082
+ // The widget and the hook wrap this in a no-retry resilience budget; a raw
1083
+ // consumer that passes no signal gets the same deadline here rather than a
1084
+ // request that can hang indefinitely.
1085
+ const controller = new AbortController();
1086
+ const onCallerAbort = () => controller.abort();
1087
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onCallerAbort);
1088
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
1089
+ controller.abort();
1090
+ }
1091
+ const timer = setTimeout(() => controller.abort(), IMAGE_SEARCH_TIMEOUT_MS);
1092
+ let response;
1093
+ try {
1094
+ response = await fetchImpl(getURL('/search/image'), {
1095
+ method: 'POST',
1096
+ headers,
1097
+ body: JSON.stringify({
1098
+ image: dataUri,
1099
+ ...(text ? { text } : {}),
1100
+ ...(limit === undefined ? {} : { limit }),
1101
+ }),
1102
+ credentials: 'omit',
1103
+ signal: controller.signal,
1104
+ });
1105
+ }
1106
+ finally {
1107
+ clearTimeout(timer);
1108
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onCallerAbort);
1109
+ }
1110
+ if (!response.ok) {
1111
+ const errorText = await response.text().catch(() => '');
1112
+ logger.error(`EvoSearch API: ImageSearch failed - ${response.status}: ${errorText}`);
1113
+ const error = new Error(`ImageSearch failed - ${response.status}`);
1114
+ error.status = response.status;
1115
+ if (response.status === 403 && isDisabledBody(errorText)) {
1116
+ error.disabled = true;
1117
+ }
1118
+ throw error;
1119
+ }
1120
+ return (await response.json());
1121
+ };
929
1122
  // SSE consumer for Ask AI. Decodes `event: <type>\ndata: <json>` frames
930
1123
  // from the worker stream and yields typed events as they arrive. Caller
931
1124
  // aborts via the AbortSignal — fetch teardown closes the underlying
@@ -1091,10 +1284,19 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1091
1284
  clearRecents,
1092
1285
  },
1093
1286
  search,
1287
+ searchImage,
1094
1288
  askStream,
1095
1289
  sendEvent,
1096
1290
  };
1097
1291
  };
1292
+ const isDisabledBody = (body) => {
1293
+ try {
1294
+ return JSON.parse(body).disabled === true;
1295
+ }
1296
+ catch {
1297
+ return false;
1298
+ }
1299
+ };
1098
1300
  const parseSseFrame = (frame) => {
1099
1301
  let eventType = '';
1100
1302
  const dataLines = [];
@@ -1161,8 +1363,10 @@ const init = (params) => {
1161
1363
  // A re-init can land on a different channel, or bail out entirely, so drop
1162
1364
  // any previous cadence before deciding which one this run owns.
1163
1365
  disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1164
- if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1366
+ const evsParam = new URLSearchParams(window.location.search).get('evs');
1367
+ if (evsParam === 'preview') {
1165
1368
  try {
1369
+ sessionStorage.removeItem(disableKeyName);
1166
1370
  sessionStorage.setItem(previewKeyName, '1');
1167
1371
  logger.info('EvoSearch SDK: Preview mode enabled via ?evs=preview URL param');
1168
1372
  }
@@ -1170,11 +1374,32 @@ const init = (params) => {
1170
1374
  error('EvoSearch SDK: Failed to set previewEvoSearch sessionStorage');
1171
1375
  }
1172
1376
  }
1377
+ else if (evsParam === 'off') {
1378
+ try {
1379
+ sessionStorage.removeItem(previewKeyName);
1380
+ sessionStorage.setItem(disableKeyName, '1');
1381
+ logger.info('EvoSearch SDK: Disabled via ?evs=off URL param');
1382
+ }
1383
+ catch {
1384
+ error('EvoSearch SDK: Failed to set disableEvoSearch sessionStorage');
1385
+ }
1386
+ }
1387
+ else if (evsParam === 'reset') {
1388
+ try {
1389
+ sessionStorage.removeItem(previewKeyName);
1390
+ sessionStorage.removeItem(disableKeyName);
1391
+ logger.info('EvoSearch SDK: Reset to configured state via ?evs=reset URL param');
1392
+ }
1393
+ catch {
1394
+ error('EvoSearch SDK: Failed to clear evoSearch sessionStorage');
1395
+ }
1396
+ }
1173
1397
  const { enabled, uiPreferences, askAi } = (_c = (_b = params.manifest) === null || _b === void 0 ? void 0 : _b.variables) !== null && _c !== void 0 ? _c : {};
1174
1398
  const mode = uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.mode;
1175
1399
  const askAiEnabled = (askAi === null || askAi === void 0 ? void 0 : askAi.enabled) === true;
1400
+ const visualSearchEnabled = (uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.visualSearch) === true;
1176
1401
  const hasPreview = hasPreviewKey();
1177
- const shouldEnable = enabled || hasPreview;
1402
+ const shouldEnable = !hasDisableKey() && (enabled || hasPreview);
1178
1403
  if (!shouldEnable) {
1179
1404
  logger.info('EvoSearch SDK: Mode is disabled, skipping initialization');
1180
1405
  return;
@@ -1222,6 +1447,7 @@ const init = (params) => {
1222
1447
  api: evoSearchAPI,
1223
1448
  uiPreferences,
1224
1449
  askAiEnabled,
1450
+ visualSearchEnabled,
1225
1451
  });
1226
1452
  params.sendTag({
1227
1453
  eventId: crypto.randomUUID(),
@@ -1244,7 +1470,7 @@ const init = (params) => {
1244
1470
  const tag = ({ data }) => {
1245
1471
  try {
1246
1472
  return {
1247
- sdkVersion: "1.66.2" || '',
1473
+ sdkVersion: "1.67.0" || '',
1248
1474
  searchQuery: data['searchQuery'],
1249
1475
  searchResults: data['searchResults'],
1250
1476
  timestamp: Date.now(),
@@ -1253,7 +1479,7 @@ const tag = ({ data }) => {
1253
1479
  catch (err) {
1254
1480
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1255
1481
  return {
1256
- sdkVersion: "1.66.2" ,
1482
+ sdkVersion: "1.67.0" ,
1257
1483
  timestamp: Date.now(),
1258
1484
  };
1259
1485
  }