@nyris/nyris-webapp 0.3.93 → 0.3.95

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.
Files changed (36) hide show
  1. package/build/asset-manifest.json +6 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +1 -0
  4. package/build/static/css/main.5fff4ca0.css +4 -0
  5. package/build/static/css/main.5fff4ca0.css.map +1 -0
  6. package/build/static/js/{main.c9bbbcb9.js → main.b80e9533.js} +3 -3
  7. package/build/static/js/{main.c9bbbcb9.js.map → main.b80e9533.js.map} +1 -1
  8. package/package.json +3 -3
  9. package/public/js/settings.example.js +1 -0
  10. package/src/components/ChatAssistant/useChatAssistantLogic.ts +1 -1
  11. package/src/components/CustomCameraDrawer.tsx +5 -5
  12. package/src/components/DragDropFile.tsx +4 -4
  13. package/src/components/ExperienceVisualSearch/ExperienceVisualSearchModal.tsx +1 -1
  14. package/src/components/ExperienceVisualSearch/ExperienceVisualSearchTrigger.tsx +1 -1
  15. package/src/components/ImagePreview.tsx +4 -4
  16. package/src/components/ImageUpload.tsx +6 -110
  17. package/src/components/MobileCameraCTA.tsx +1 -1
  18. package/src/components/Rfq/RfqBanner.tsx +3 -4
  19. package/src/components/SidePanel.tsx +8 -12
  20. package/src/components/SmartFilter.tsx +70 -42
  21. package/src/components/Toaster.tsx +3 -2
  22. package/src/hooks/useGoodResultsChat.ts +1 -1
  23. package/src/hooks/useImageSearch.ts +4 -8
  24. package/src/hooks/useResultEvaluator.ts +5 -4
  25. package/src/hooks/useUploadSearch.ts +170 -0
  26. package/src/layouts/AppLayout.tsx +70 -0
  27. package/src/pages/Home.tsx +2 -2
  28. package/src/pages/Result.tsx +9 -11
  29. package/src/services/vizo.ts +0 -6
  30. package/src/styles/common.scss +1 -0
  31. package/src/styles/experienceVisualSearch.scss +2 -0
  32. package/src/translations.ts +33 -18
  33. package/src/types.ts +1 -0
  34. package/build/static/css/main.fed38059.css +0 -4
  35. package/build/static/css/main.fed38059.css.map +0 -1
  36. /package/build/static/js/{main.c9bbbcb9.js.LICENSE.txt → main.b80e9533.js.LICENSE.txt} +0 -0
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nyris/nyris-webapp",
3
- "version": "0.3.93",
3
+ "version": "0.3.95",
4
4
  "dependencies": {
5
5
  "@auth0/auth0-react": "^2.2.4",
6
6
  "@emailjs/browser": "^4.3.3",
7
7
  "@google/genai": "^1.46.0",
8
- "@nyris/nyris-api": "^0.3.93",
9
- "@nyris/nyris-react-components": "^0.3.93",
8
+ "@nyris/nyris-api": "^0.3.95",
9
+ "@nyris/nyris-react-components": "^0.3.95",
10
10
  "@radix-ui/react-accordion": "^1.2.2",
11
11
  "@radix-ui/react-dialog": "^1.1.4",
12
12
  "@radix-ui/react-popover": "^1.1.4",
@@ -110,6 +110,7 @@ var settings = {
110
110
  geoLocation: false,
111
111
  geoLocationMessage: '',
112
112
  showImageDetails: false,
113
+ supportButtonLabel: 'Request a Quote',
113
114
  rfq: {
114
115
  enabled: '',
115
116
  emailTemplateId: '',
@@ -682,7 +682,7 @@ Each result includes a unique SKU in brackets. Use the result thumbnails and met
682
682
 
683
683
  const tableSuggestions = lookupRelatedParts(
684
684
  table,
685
- imageAnalysis.specification.product_category,
685
+ imageAnalysis?.specification?.product_category,
686
686
  );
687
687
 
688
688
  if (tableSuggestions && tableSuggestions.length > 0) {
@@ -95,7 +95,7 @@ function CustomCamera(props: Props) {
95
95
  showFeedback: true,
96
96
  }).then(singleImageResp => {
97
97
  const specificationPrefilter =
98
- singleImageResp.image_analysis?.specification?.prefilter_value || null;
98
+ singleImageResp?.image_analysis?.specification?.prefilter_value || null;
99
99
 
100
100
  const hasPrefilter = preFilterList.filter((filter: any) =>
101
101
  filter.values.includes(specificationPrefilter),
@@ -105,15 +105,15 @@ function CustomCamera(props: Props) {
105
105
  setShowNotMatchedError(false);
106
106
  if (hasPrefilter.length) {
107
107
  setSpecifications(
108
- clone(singleImageResp.image_analysis.specification),
108
+ clone(singleImageResp?.image_analysis?.specification),
109
109
  );
110
110
  setNameplateImage(image);
111
111
  setPreFilter({
112
- [singleImageResp.image_analysis?.specification?.prefilter_value]:
112
+ [singleImageResp?.image_analysis?.specification?.prefilter_value]:
113
113
  true,
114
114
  });
115
115
  setAlgoliaFilter(
116
- `${settings.alogoliaFilterField}:'${singleImageResp.image_analysis?.specification?.prefilter_value}'`,
116
+ `${settings.alogoliaFilterField}:'${singleImageResp?.image_analysis?.specification?.prefilter_value}'`,
117
117
  );
118
118
 
119
119
  setShowLoading(false);
@@ -133,7 +133,7 @@ function CustomCamera(props: Props) {
133
133
  if (!hasPrefilter.length && window.settings.preFilterOption) {
134
134
  setSpecifications(
135
135
  clone({
136
- ...singleImageResp.image_analysis.specification,
136
+ ...singleImageResp?.image_analysis?.specification,
137
137
  specificationPrefilter,
138
138
  }),
139
139
  );
@@ -70,15 +70,15 @@ function DragDropFile(props: Props) {
70
70
  setShowNotMatchedError(false);
71
71
  if (hasPrefilter.length) {
72
72
  setSpecifications(
73
- clone(singleImageResp.image_analysis.specification),
73
+ clone(singleImageResp?.image_analysis?.specification),
74
74
  );
75
75
  setNameplateImage(file);
76
76
  setPreFilter({
77
- [singleImageResp.image_analysis?.specification?.prefilter_value]:
77
+ [singleImageResp?.image_analysis?.specification?.prefilter_value]:
78
78
  true,
79
79
  });
80
80
  setAlgoliaFilter(
81
- `${window.settings.alogoliaFilterField}:'${singleImageResp.image_analysis?.specification?.prefilter_value}'`,
81
+ `${window.settings.alogoliaFilterField}:'${singleImageResp?.image_analysis?.specification?.prefilter_value}'`,
82
82
  );
83
83
 
84
84
  setShowLoading(false);
@@ -100,7 +100,7 @@ function DragDropFile(props: Props) {
100
100
  if (!hasPrefilter.length && window.settings.preFilterOption) {
101
101
  setSpecifications(
102
102
  clone({
103
- ...singleImageResp.image_analysis.specification,
103
+ ...singleImageResp?.image_analysis?.specification,
104
104
  specificationPrefilter,
105
105
  }),
106
106
  );
@@ -23,7 +23,7 @@ function ExperienceVisualSearchModal(props: Props) {
23
23
  return (
24
24
  <Dialog open={openModal} onOpenChange={handleClose}>
25
25
  <DialogContent
26
- className="bg-white p-0 max-w-[1500px] w-fit max-h-[95vh] h-fit m-auto overflow-y-hidden rounded-lg"
26
+ className="bg-white p-0 max-w-[1500px] w-fit max-h-[95vh] m-auto overflow-y-hidden rounded-lg"
27
27
  closeButton={false}
28
28
  >
29
29
  <DialogTitle className="h-0 w-0 hidden">
@@ -46,7 +46,7 @@ function ExperienceVisualSearchTrigger({
46
46
 
47
47
  return (
48
48
  <>
49
- <div className="flex flex-col items-center">
49
+ <div className={`flex flex-col items-center ${window.settings.experienceVisualSearch ? 'mb-[80px]' : ''}`}>
50
50
  <div
51
51
  className={`group bg-[#3E36DC] w-10 hover:w-[215px] hover:gap-2 ${
52
52
  expand ? 'w-[215px] gap-2' : ''
@@ -116,13 +116,13 @@ function ImagePreviewComponent({
116
116
  setRequestImages([]);
117
117
  setShowNotMatchedError(false);
118
118
  if (hasPrefilter.length) {
119
- setSpecifications(clone(res.image_analysis.specification));
119
+ setSpecifications(clone(res?.image_analysis?.specification));
120
120
  setNameplateImage(image);
121
121
  setPreFilter({
122
- [res.image_analysis?.specification?.prefilter_value]: true,
122
+ [res?.image_analysis?.specification?.prefilter_value]: true,
123
123
  });
124
124
  setAlgoliaFilter(
125
- `${settings.alogoliaFilterField}:'${res.image_analysis?.specification?.prefilter_value}'`,
125
+ `${settings.alogoliaFilterField}:'${res?.image_analysis?.specification?.prefilter_value}'`,
126
126
  );
127
127
 
128
128
  setShowLoading(false);
@@ -144,7 +144,7 @@ function ImagePreviewComponent({
144
144
  if (!hasPrefilter.length && window.settings.preFilterOption) {
145
145
  setSpecifications(
146
146
  clone({
147
- ...res.image_analysis.specification,
147
+ ...res?.image_analysis?.specification,
148
148
  specificationPrefilter,
149
149
  }),
150
150
  );
@@ -4,13 +4,10 @@ import { twMerge } from 'tailwind-merge';
4
4
  import Tooltip from './Tooltip/TooltipComponent';
5
5
  import { useAuth0 } from '@auth0/auth0-react';
6
6
  import { useMemo, useState } from 'react';
7
- import { useLocation, useNavigate } from 'react-router';
7
+ import { useLocation } from 'react-router';
8
8
  import useRequestStore from 'stores/request/requestStore';
9
- import { useImageSearch } from 'hooks/useImageSearch';
10
- import { clone } from 'lodash';
11
- import { useCadSearch } from 'hooks/useCadSearch';
12
- import { isCadFile } from '@nyris/nyris-api';
13
9
  import UploadDisclaimer from './UploadDisclaimer';
10
+ import { useUploadSearch } from 'hooks/useUploadSearch';
14
11
 
15
12
  function ImageUpload({
16
13
  onCameraClick,
@@ -25,27 +22,10 @@ function ImageUpload({
25
22
  const { t } = useTranslation();
26
23
 
27
24
  const location = useLocation();
28
- const navigate = useNavigate();
25
+ const { uploadFile } = useUploadSearch();
29
26
 
30
- const preFilterList = useRequestStore(state => state.preFilterList);
31
27
  const requestImages = useRequestStore(state => state.requestImages);
32
- const setQuery = useRequestStore(state => state.setQuery);
33
- const setValueInput = useRequestStore(state => state.setValueInput);
34
28
  const setMetaFilter = useRequestStore(state => state.setMetaFilter);
35
- const specifications = useRequestStore(state => state.specifications);
36
-
37
- const setRequestImages = useRequestStore(state => state.setRequestImages);
38
- const setSpecifications = useRequestStore(state => state.setSpecifications);
39
- const setShowLoading = useRequestStore(state => state.setShowLoading);
40
- const setNameplateNotificationText = useRequestStore(
41
- state => state.setNameplateNotificationText,
42
- );
43
- const setShowNotMatchedError = useRequestStore(
44
- state => state.setShowNotMatchedError,
45
- );
46
- const setAlgoliaFilter = useRequestStore(state => state.setAlgoliaFilter);
47
- const setPreFilter = useRequestStore(state => state.setPreFilter);
48
- const setNameplateImage = useRequestStore(state => state.setNameplateImage);
49
29
 
50
30
  const [showDisclaimer, setShowDisclaimer] = useState(false);
51
31
 
@@ -80,97 +60,13 @@ function ImageUpload({
80
60
  // eslint-disable-next-line react-hooks/exhaustive-deps
81
61
  }, [showDisclaimer, requestImages, disableDisclaimer]);
82
62
 
83
- const { singleImageSearch } = useImageSearch();
84
- const { cadSearch } = useCadSearch();
85
-
86
63
  const handleUpload = (files: File[]) => {
87
- setValueInput('');
88
- setQuery('');
89
-
90
- if (isCadFile(files[0])) {
91
- cadSearch({ file: files[0], settings, newSearch: true }).then(() => {});
92
-
64
+ const file = files[0];
65
+ if (!file) {
93
66
  return;
94
67
  }
95
68
 
96
- setShowLoading(true);
97
-
98
- singleImageSearch({
99
- image: files[0],
100
- settings: window.settings,
101
- showFeedback: true,
102
- newSearch: true,
103
- clearPostFilter: true,
104
- }).then(singleImageResp => {
105
- const specificationPrefilter =
106
- singleImageResp.image_analysis?.specification?.prefilter_value || null;
107
- const hasPrefilter = preFilterList.filter((filter: any) =>
108
- filter.values.includes(specificationPrefilter),
109
- );
110
- if (specificationPrefilter) {
111
- setRequestImages([]);
112
- setShowNotMatchedError(false);
113
- if (hasPrefilter.length) {
114
- setSpecifications(
115
- clone(singleImageResp.image_analysis.specification),
116
- );
117
- setNameplateImage(files[0]);
118
- setPreFilter({
119
- [singleImageResp.image_analysis?.specification?.prefilter_value]:
120
- true,
121
- });
122
- setAlgoliaFilter(
123
- `${settings.alogoliaFilterField}:'${singleImageResp.image_analysis?.specification?.prefilter_value}'`,
124
- );
125
-
126
- setShowLoading(false);
127
- navigate('/result');
128
-
129
- setTimeout(() => {
130
- setNameplateNotificationText(
131
- t('We have successfully defined the search criteria', {
132
- prefilter_value: specificationPrefilter,
133
- preFilterTitle:
134
- window.settings.preFilterTitle?.toLocaleLowerCase(),
135
- }),
136
- );
137
- }, 1000);
138
- setTimeout(() => {
139
- setNameplateNotificationText('');
140
- }, 10000);
141
- }
142
- if (!hasPrefilter.length && showPreFilter) {
143
- setSpecifications(
144
- clone({
145
- ...singleImageResp.image_analysis.specification,
146
- specificationPrefilter,
147
- }),
148
- );
149
- navigate('/result');
150
- setPreFilter({});
151
- setAlgoliaFilter('');
152
- setShowLoading(false);
153
- setShowNotMatchedError(true);
154
- setTimeout(() => {
155
- setNameplateNotificationText(
156
- t('Extracted details from the nameplate could not be matched', {
157
- preFilterTitle:
158
- window.settings.preFilterTitle?.toLocaleLowerCase(),
159
- }),
160
- );
161
- }, 1000);
162
- setTimeout(() => {
163
- setNameplateNotificationText('');
164
- }, 15000);
165
- }
166
- } else {
167
- if (!specifications?.is_nameplate) {
168
- setSpecifications({ ...specifications, is_nameplate: false });
169
- }
170
- setShowLoading(false);
171
- navigate('/result');
172
- }
173
- });
69
+ uploadFile(file, { showPreFilter });
174
70
  };
175
71
 
176
72
  return (
@@ -4,7 +4,7 @@ import '../styles/mobileCameraCTA.scss';
4
4
 
5
5
  function MobileCameraCTA({ setOpenModalCamera }: { setOpenModalCamera: any }) {
6
6
  return (
7
- <div className="take-photo">
7
+ <div className={`take-photo ${window.settings.experienceVisualSearch ? 'mt-[180px]' : ''}`}>
8
8
  <div
9
9
  className="take-photo-wrapper"
10
10
  >
@@ -29,7 +29,6 @@ function RfqBanner({
29
29
  <div
30
30
  style={{
31
31
  padding: '0px 16px 0px 16px',
32
- backgroundColor: '#F6F3F1',
33
32
  width: '100%',
34
33
  alignSelf: 'end',
35
34
  height: '248px',
@@ -72,9 +71,9 @@ function RfqBanner({
72
71
  fontWeight: 'bold',
73
72
  }}
74
73
  >
75
- <p className="desktop:hidden">{`${t('No matches found')}?`}</p>
74
+ <p className="desktop:hidden">{`${t('No matches found')}`}?</p>
76
75
  <p className="hidden desktop:block">
77
- `${t('No matches found for your request')}?`
76
+ {`${t('No matches found for your request')}`}?
78
77
  </p>
79
78
  </div>
80
79
  <div
@@ -112,7 +111,7 @@ function RfqBanner({
112
111
  setIsRfqModalOpen(true);
113
112
  }}
114
113
  >
115
- Request a Quote
114
+ {window.settings.supportButtonLabel}
116
115
  </button>
117
116
  </div>
118
117
  </div>
@@ -24,7 +24,6 @@ export default function SidePanel({ className }: { className?: string }) {
24
24
  const showSidePanel = useUiStore(state => state.showSidePanel);
25
25
 
26
26
  const imageAnalysis = useResultStore(state => state.imageAnalysis);
27
- const smartFilters = useResultStore(state => state.smartFilters);
28
27
 
29
28
  const showPostFilter = window.settings?.postFilterOption;
30
29
 
@@ -105,17 +104,6 @@ export default function SidePanel({ className }: { className?: string }) {
105
104
  {requestImages[0] && <ImagePreview />}
106
105
  </div>
107
106
  <div className="pb-4 flex flex-col gap-6">
108
- {isSmartFiltersEnabled &&
109
- (imageAnalysis?.imageDescription ||
110
- Object.keys(imageAnalysis?.specification || {}).length > 0 ||
111
- smartFilters.status !== 'idle') && (
112
- <SmartFilter
113
- imageAnalysis={imageAnalysis}
114
- specificationFilter={specificationFilter}
115
- variant="sidepanel"
116
- />
117
- )}
118
-
119
107
  {window.settings.showImageDetails &&
120
108
  (imageAnalysis?.imageDescription ||
121
109
  Object.keys(imageAnalysis?.specification || {}).length > 0) && (
@@ -165,6 +153,14 @@ export default function SidePanel({ className }: { className?: string }) {
165
153
  </div>
166
154
  )}
167
155
 
156
+ {isSmartFiltersEnabled && (
157
+ <SmartFilter
158
+ imageAnalysis={imageAnalysis}
159
+ specificationFilter={specificationFilter}
160
+ variant="sidepanel"
161
+ />
162
+ )}
163
+
168
164
  {(isGoogleGroundingLoading ||
169
165
  googleGroundingResponse ||
170
166
  groundingError) && (
@@ -3,6 +3,7 @@ import ItemSpecification from './ItemSpecification';
3
3
  import { Icon } from '@nyris/nyris-react-components';
4
4
  import useRequestStore from 'stores/request/requestStore';
5
5
  import useResultStore from 'stores/result/resultStore';
6
+ import useUiStore from 'stores/ui/uiStore';
6
7
  import { rerankSmartFilters, SmartFilterRerankItem } from 'services/vizo';
7
8
 
8
9
  function SmartFilter({
@@ -25,6 +26,7 @@ function SmartFilter({
25
26
  const setAlgoliaProducts = useResultStore(state => state.setAlgoliaProducts);
26
27
  const requestImages = useRequestStore(state => state.requestImages);
27
28
  const setAlgoliaFilter = useRequestStore(state => state.setAlgoliaFilter);
29
+ const setIsFindApiLoading = useUiStore(state => state.setIsFindApiLoading);
28
30
  const isAlgoliaEnabled = !!window.settings?.algolia?.enabled;
29
31
 
30
32
  const activeProducts =
@@ -32,10 +34,20 @@ function SmartFilter({
32
34
  ? productsFromAlgolia
33
35
  : productsFromFindApi;
34
36
 
35
- const rerankItems: SmartFilterRerankItem[] = useMemo(() => {
36
- return (activeProducts || [])
37
- .slice(0, 50)
38
- .map((product: any): SmartFilterRerankItem | null => {
37
+ // Always rerank against the original, unfiltered result set. Reranking
38
+ // mutates `productsFromFindApi`/`productsFromAlgolia`, so deriving items
39
+ // from those would shrink the payload on every subsequent rerank.
40
+ const originalResults = useMemo(
41
+ () =>
42
+ firstSearchResults && firstSearchResults.length > 0
43
+ ? firstSearchResults
44
+ : activeProducts || [],
45
+ [firstSearchResults, activeProducts],
46
+ );
47
+
48
+ const rerankItems: any[] = useMemo(() => {
49
+ return originalResults
50
+ .map((product: any): any | null => {
39
51
  const sku = product?.sku || product?.objectID;
40
52
  const imageUrl =
41
53
  product?.matches?.[0]?.url ||
@@ -49,31 +61,39 @@ function SmartFilter({
49
61
  }
50
62
 
51
63
  return {
52
- sku: String(sku),
64
+ ...product,
53
65
  imageUrl: String(imageUrl),
54
- metadata: {
55
- title: product?.title || product?.name || product?.Bezeichnung || '',
56
- description:
57
- product?.description ||
58
- product?.VK_Text_Englisch ||
59
- product?.VK_Text_Deutsch ||
60
- '',
61
- language: window.settings.language || 'en',
62
- sku: String(sku),
63
- score:
64
- typeof (product?.score ?? product?.similarity) === 'number'
65
- ? Number(product?.score ?? product?.similarity)
66
- : null,
67
- },
68
66
  };
69
67
  })
70
68
  .filter((item): item is SmartFilterRerankItem => item !== null);
71
- }, [activeProducts]);
69
+ }, [originalResults]);
70
+
71
+ const buildAlgoliaSkuFilter = (skus: string[]) => {
72
+ const nonEmptyFilter: string[] = ['sku:DOES_NOT_EXIST<score=1> '];
73
+ const rankedFilters = skus
74
+ .slice()
75
+ .reverse()
76
+ .map((sku, i) => `sku:'${sku}'<score=${i}> `);
77
+ return [...nonEmptyFilter, ...rankedFilters].join('OR ');
78
+ };
79
+
80
+ const restoreOriginalResults = () => {
81
+ if (originalResults.length === 0) {
82
+ return;
83
+ }
84
+ setFindApiProducts(originalResults);
85
+ if (isAlgoliaEnabled) {
86
+ setAlgoliaProducts(originalResults);
87
+ const skus = originalResults
88
+ .map((p: any) => String(p?.sku || p?.objectID || '').trim())
89
+ .filter(Boolean);
90
+ setAlgoliaFilter(buildAlgoliaSkuFilter(skus));
91
+ }
92
+ };
72
93
 
73
94
  const applySkuOrderingToCurrentResults = (orderedSkus: string[]) => {
74
- const sourceResults = activeProducts || [];
75
95
  const map = new Map<string, any>(
76
- sourceResults.map((item: any) => [
96
+ originalResults.map((item: any) => [
77
97
  String(item?.sku || item?.objectID || '').trim(),
78
98
  item,
79
99
  ]),
@@ -90,12 +110,7 @@ function SmartFilter({
90
110
  setFindApiProducts(reordered);
91
111
  if (isAlgoliaEnabled) {
92
112
  setAlgoliaProducts(reordered);
93
- const nonEmptyFilter: string[] = ['sku:DOES_NOT_EXIST<score=1> '];
94
- const rankedFilters = orderedSkus
95
- .slice()
96
- .reverse()
97
- .map((sku, i) => `sku:'${sku}'<score=${i}> `);
98
- setAlgoliaFilter([...nonEmptyFilter, ...rankedFilters].join('OR '));
113
+ setAlgoliaFilter(buildAlgoliaSkuFilter(orderedSkus));
99
114
  }
100
115
  return true;
101
116
  };
@@ -112,12 +127,11 @@ function SmartFilter({
112
127
  ...smartFilters,
113
128
  selectedFilterKey: null,
114
129
  selectedFilterValue: null,
130
+ rerankedSkus: [],
115
131
  rerankStatus: 'idle',
116
132
  errorMessage: null,
117
133
  });
118
- if (firstSearchResults.length > 0) {
119
- setFindApiProducts(firstSearchResults);
120
- }
134
+ restoreOriginalResults();
121
135
  return;
122
136
  }
123
137
 
@@ -139,6 +153,7 @@ function SmartFilter({
139
153
  rerankStatus: 'loading',
140
154
  errorMessage: null,
141
155
  });
156
+ setIsFindApiLoading(true);
142
157
 
143
158
  try {
144
159
  const rerankResponse = await rerankSmartFilters({
@@ -209,6 +224,8 @@ function SmartFilter({
209
224
  ? 'Smart filters are unavailable right now.'
210
225
  : 'Could not rerank results.',
211
226
  });
227
+ } finally {
228
+ setIsFindApiLoading(false);
212
229
  }
213
230
  };
214
231
 
@@ -224,7 +241,7 @@ function SmartFilter({
224
241
  </div>
225
242
  <Icon name="smart_filter_indication" className="mt-1" />
226
243
  </div>
227
- <div className="flex flex-wrap mx-4 gap-2 mt-4">
244
+ <div className={`flex flex-wrap mx-4 gap-2 ${imageAnalysis?.specification ? 'mt-4' : ''}`}>
228
245
  {Object.keys(imageAnalysis?.specification || {})
229
246
  .filter(key => key !== 'is_nameplate' && key !== 'prefilter_value')
230
247
  .map(key => {
@@ -244,23 +261,20 @@ function SmartFilter({
244
261
  })}
245
262
  </div>
246
263
  {smartFilters.status === 'loading' && (
247
- <div className="mx-4 mt-3 text-xs text-[#3E36DC]">
264
+ <div className="ml-[30px] mt-3 text-xs text-[#3E36DC]">
248
265
  Loading smart filters...
249
266
  </div>
250
267
  )}
251
268
  {smartFilters.status === 'error' && (
252
- <div className="mx-4 mt-3 text-xs text-[#D14343]">
269
+ <div className="ml-[30px] mt-3 text-xs text-[#D14343]">
253
270
  {smartFilters.errorMessage || 'Could not generate smart filters.'}
254
271
  </div>
255
272
  )}
256
273
  {smartFilters.status === 'success' && smartFilters.filters.length === 0 && (
257
- <div className="mx-4 mt-3 text-xs text-[#767A9F]">
274
+ <div className="ml-[30px] mt-3 text-xs text-[#767A9F]">
258
275
  No smart filters available for this image.
259
276
  </div>
260
277
  )}
261
- {smartFilters.rerankStatus === 'loading' && (
262
- <div className="mx-4 mt-1 text-xs text-[#3E36DC]">Applying rerank...</div>
263
- )}
264
278
  <div
265
279
  className={
266
280
  variant === 'results'
@@ -290,24 +304,38 @@ function SmartFilter({
290
304
  const isSelected =
291
305
  smartFilters.selectedFilterKey === filter.key &&
292
306
  smartFilters.selectedFilterValue === filter.value;
307
+ const hasSelection =
308
+ !!smartFilters.selectedFilterKey &&
309
+ !!smartFilters.selectedFilterValue;
310
+ const isRerankLoading = smartFilters.rerankStatus === 'loading';
311
+ const isDisabled = !isSelected && (hasSelection || isRerankLoading);
293
312
  return (
294
313
  <button
295
314
  key={`${filter.key}-${filter.value}-${index}`}
315
+ type="button"
316
+ disabled={isDisabled}
317
+ aria-pressed={isSelected}
296
318
  style={variant === 'results' ? { flex: '0 0 auto' } : undefined}
297
- className={`px-3.5 py-2 rounded-lg border text-sm ${
319
+ className={`px-3.5 py-2 rounded-lg border text-sm inline-flex items-center gap-1.5 ${
298
320
  isSelected
299
321
  ? 'bg-[#545987] text-white border-[#545987]'
300
322
  : 'bg-[#E7E8F1] text-[#767A9F] border-transparent'
301
- }`}
323
+ } ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
302
324
  onClick={() => {
303
- if (smartFilters.rerankStatus === 'loading') return;
325
+ if (isRerankLoading) return;
304
326
  void onFilterClick({
305
327
  key: filter.key,
306
328
  value: filter.value,
307
329
  });
308
330
  }}
309
331
  >
310
- {filter.label}: {filter.value}
332
+ <span>{filter.value}</span>
333
+ {isSelected && (
334
+ <Icon
335
+ name="close"
336
+ className="w-3 h-3 text-white"
337
+ />
338
+ )}
311
339
  </button>
312
340
  );
313
341
  })}
@@ -15,8 +15,9 @@ export const Toaster = () => {
15
15
  <ReactHotToaster
16
16
  position={isMobile ? 'bottom-center' : 'top-right'}
17
17
  containerStyle={{
18
- top: 80,
19
- bottom: isMobile ? 80 : 'auto',
18
+ top: 100,
19
+ bottom: isMobile ? 90 : 'auto',
20
+ right: isMobile ? 15 : 25,
20
21
  }}
21
22
  >
22
23
  {t => (
@@ -592,7 +592,7 @@ Respond to the user's message with an appropriate action and follow-up options.`
592
592
  finalOptions = [
593
593
  ...finalOptions,
594
594
  {
595
- label: `Find parts related to ${imageAnalysis.specification.product_category || 'request image'}`,
595
+ label: `Find parts related to ${imageAnalysis?.specification?.product_category || 'request image'}`,
596
596
  action_type: 'find_related_parts' as const,
597
597
  key: 'find_related',
598
598
  value: 'find_related_parts',
@@ -18,9 +18,7 @@ import useResultEvaluator from './useResultEvaluator';
18
18
  import useConversationStore from 'stores/chat/conversationStore';
19
19
  import { useGroundedSearch } from './useGroundedSearch';
20
20
  import { generateSmartFilters } from '../services/vizo';
21
-
22
- const DEFAULT_SMART_FILTER_PROMPT =
23
- 'Extract at most 4 relevant product intent filters from the request image. Return strict JSON only.';
21
+ import { cropImage } from 'utils/cropImageToBase64';
24
22
 
25
23
  export const useImageSearch = () => {
26
24
  const setRegions = useRequestStore(state => state.setRegions);
@@ -195,7 +193,7 @@ export const useImageSearch = () => {
195
193
  ];
196
194
 
197
195
  let smartFilterPromise: Promise<void> | null = null;
198
- if (isSmartFiltersEnabled && requestImage && newSearch) {
196
+ if (isSmartFiltersEnabled && requestImage && (newSearch || imageRegion)) {
199
197
  setSmartFilters({
200
198
  status: 'loading',
201
199
  filters: [],
@@ -206,10 +204,8 @@ export const useImageSearch = () => {
206
204
  errorMessage: null,
207
205
  });
208
206
 
209
- smartFilterPromise = generateSmartFilters({
210
- image: requestImage,
211
- prompt: DEFAULT_SMART_FILTER_PROMPT,
212
- })
207
+ const smartFilterImage = cropImage(requestImage, region);
208
+ smartFilterPromise = generateSmartFilters({ image: smartFilterImage })
213
209
  .then(response => {
214
210
  const rawFilters = response?.filters;
215
211
  if (!rawFilters || typeof rawFilters !== 'object') {