@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 +116 -0
- package/core.cjs.js +230 -4
- package/core.js +230 -4
- package/core.mjs +230 -4
- package/hooks.cjs.js +320 -3
- package/hooks.d.ts +58 -1
- package/hooks.mjs +318 -4
- package/index.cjs.js +2192 -82
- package/index.js +2192 -82
- package/index.mjs +2192 -82
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1480
|
+
sdkVersion: "1.67.0" ,
|
|
1255
1481
|
timestamp: Date.now(),
|
|
1256
1482
|
};
|
|
1257
1483
|
}
|