@nyris/nyris-webapp 0.3.90 → 0.3.92

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 (106) hide show
  1. package/build/asset-manifest.json +6 -6
  2. package/build/data/related-parts.json +83 -0
  3. package/build/index.html +1 -1
  4. package/build/js/settings.example.js +3 -0
  5. package/build/static/css/main.5ea01690.css +4 -0
  6. package/build/static/css/main.5ea01690.css.map +1 -0
  7. package/build/static/js/main.36b77705.js +3 -0
  8. package/build/static/js/{main.cede3ae1.js.map → main.36b77705.js.map} +1 -1
  9. package/package.json +4 -3
  10. package/public/data/related-parts.json +83 -0
  11. package/public/js/settings.example.js +3 -0
  12. package/src/App.test.tsx +0 -1
  13. package/src/App.tsx +0 -1
  14. package/src/assets/arrow_down_expanded.svg +3 -0
  15. package/src/assets/arrow_enter.svg +3 -0
  16. package/src/assets/camera.svg +3 -0
  17. package/src/assets/close.svg +3 -0
  18. package/src/assets/enter.svg +3 -0
  19. package/src/assets/refresh.svg +3 -0
  20. package/src/assets/vizo_avatar.svg +16 -0
  21. package/src/components/Cadenas/CadenasWebViewer.tsx +1 -1
  22. package/src/components/Cart.tsx +48 -36
  23. package/src/components/ChatAssistant/ChatAssistant.tsx +289 -0
  24. package/src/components/ChatAssistant/MobileChatAssistant.tsx +291 -0
  25. package/src/components/ChatAssistant/OptionChip.tsx +78 -0
  26. package/src/components/ChatAssistant/index.ts +3 -0
  27. package/src/components/ChatAssistant/useChatAssistantLogic.ts +745 -0
  28. package/src/components/CurrentRefinements.tsx +2 -2
  29. package/src/components/CustomCameraDrawer.tsx +56 -13
  30. package/src/components/DragDropFile.tsx +5 -5
  31. package/src/components/ExperienceVisualSearch/ExperienceVisualSearch.tsx +1 -1
  32. package/src/components/Header.tsx +116 -96
  33. package/src/components/Hint.tsx +1 -2
  34. package/src/components/HitsPerPage.tsx +9 -3
  35. package/src/components/ImagePreview.tsx +32 -17
  36. package/src/components/ImageUpload.tsx +16 -8
  37. package/src/components/Inquiry/InquiryBanner.tsx +1 -1
  38. package/src/components/Inquiry/InquiryModal.tsx +35 -29
  39. package/src/components/ItemSpecification.tsx +58 -126
  40. package/src/components/LocationInfoPopup.tsx +33 -33
  41. package/src/components/MatchNotificationBanner.tsx +90 -36
  42. package/src/components/PostFilter/PostFilter.tsx +1 -1
  43. package/src/components/PostFilter/PostFilterComponent.tsx +0 -1
  44. package/src/components/PostFilter/PostFilterFindApi.tsx +0 -1
  45. package/src/components/PoweredBy.tsx +1 -1
  46. package/src/components/PreFilter/PreFilter.tsx +14 -3
  47. package/src/components/PreFilter/PreFilterModal.tsx +0 -1
  48. package/src/components/Product/Product.tsx +15 -11
  49. package/src/components/Product/ProductAttribute.tsx +4 -5
  50. package/src/components/Product/ProductDetailViewModal.tsx +2 -4
  51. package/src/components/Product/ProductList.tsx +26 -13
  52. package/src/components/Rfq/RfqModal.tsx +1 -1
  53. package/src/components/SidePanel.tsx +124 -91
  54. package/src/components/SmartFilter.tsx +320 -0
  55. package/src/components/TextSearch.tsx +134 -70
  56. package/src/components/UploadDisclaimer.tsx +1 -1
  57. package/src/hooks/useBadResultsRecovery.ts +407 -0
  58. package/src/hooks/useEffectiveGroundingResults.ts +54 -0
  59. package/src/hooks/useGoodResultsChat.ts +651 -0
  60. package/src/hooks/useGroundedSearch.ts +88 -0
  61. package/src/hooks/useImageSearch.ts +139 -187
  62. package/src/hooks/useResultEvaluator.ts +417 -0
  63. package/src/index.css +1 -1
  64. package/src/index.tsx +0 -1
  65. package/src/layouts/AppLayout.tsx +53 -2
  66. package/src/pages/Home.tsx +11 -52
  67. package/src/pages/Login.tsx +1 -2
  68. package/src/pages/Logout.tsx +1 -1
  69. package/src/pages/Result.tsx +198 -200
  70. package/src/providers/AuthProvider.tsx +0 -1
  71. package/src/services/Feedback.ts +1 -1
  72. package/src/services/visualSearch.ts +0 -21
  73. package/src/services/vizo.ts +192 -4
  74. package/src/stores/chat/chatStore.ts +150 -0
  75. package/src/stores/chat/conversationStore.ts +300 -0
  76. package/src/stores/request/Misc/misc.slice.ts +2 -2
  77. package/src/stores/request/filter/filter.slice.ts +8 -8
  78. package/src/stores/request/query/query.slice.ts +2 -2
  79. package/src/stores/request/requestImage/requestImage.slice.ts +6 -6
  80. package/src/stores/request/specifications/specifications.slice.ts +10 -7
  81. package/src/stores/result/detectedRegions/detectedRegions.slice.ts +1 -1
  82. package/src/stores/result/prodcuts/products.initialState.ts +12 -0
  83. package/src/stores/result/prodcuts/products.slice.ts +28 -8
  84. package/src/stores/result/session/session.slice.ts +2 -2
  85. package/src/stores/smartFilters/smartFiltersStore.ts +270 -0
  86. package/src/stores/types.ts +41 -0
  87. package/src/stores/ui/ai/ai.initialState.ts +5 -0
  88. package/src/stores/ui/ai/ai.slice.ts +15 -0
  89. package/src/stores/ui/banner/banner.initialState.ts +6 -0
  90. package/src/stores/ui/banner/banner.slice.ts +14 -0
  91. package/src/stores/ui/feedback/feedback.slice.ts +1 -1
  92. package/src/stores/ui/loading/loading.slice.ts +4 -4
  93. package/src/stores/ui/uiStore.ts +7 -1
  94. package/src/styles/product.scss +0 -2
  95. package/src/types.ts +3 -7
  96. package/src/utils/cropImageToBase64.ts +32 -0
  97. package/src/utils/fetchProductImage.ts +109 -0
  98. package/src/utils/imageConverters.ts +124 -0
  99. package/src/utils/relatedParts.ts +35 -0
  100. package/src/utils/specificationFilter.ts +1 -5
  101. package/tailwind.config.js +3 -2
  102. package/build/static/css/main.734b52e1.css +0 -4
  103. package/build/static/css/main.734b52e1.css.map +0 -1
  104. package/build/static/js/main.cede3ae1.js +0 -3
  105. package/src/utils/addAssets.ts +0 -40
  106. /package/build/static/js/{main.cede3ae1.js.LICENSE.txt → main.36b77705.js.LICENSE.txt} +0 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * P5 - Bad Results Recovery
3
+ *
4
+ * Handles conversation when results don't match the uploaded image.
5
+ * Asks clarifying questions based on failure mode.
6
+ * Digs through all 100 results to resurface better matches.
7
+ * Maximum 3 attempts before suggesting a new image.
8
+ */
9
+
10
+ import { useCallback, useRef } from 'react';
11
+ import useConversationStore, {
12
+ QuickReplyOption,
13
+ } from 'stores/chat/conversationStore';
14
+ import useSmartFiltersStore from 'stores/smartFilters/smartFiltersStore';
15
+ import useResultStore from 'stores/result/resultStore';
16
+ import { chatAnalysis } from 'services/vizo';
17
+ import {
18
+ getProductImageUrl,
19
+ fetchProductImageBase64,
20
+ } from 'utils/fetchProductImage';
21
+
22
+ interface P5Response {
23
+ message: string;
24
+ action: 'ask' | 'resurface' | 'reset' | 'exit';
25
+ filtered_results?: number[];
26
+ options: QuickReplyOption[];
27
+ attempt: number;
28
+ }
29
+
30
+ export function useBadResultsRecovery() {
31
+ const {
32
+ messages,
33
+ failureMode,
34
+ attemptCounter,
35
+ addMessage,
36
+ incrementAttempt,
37
+ setIsLoading,
38
+ setError,
39
+ } = useConversationStore();
40
+
41
+ const { filters } = useSmartFiltersStore();
42
+
43
+ const productsFromFindApi = useResultStore(
44
+ state => state.productsFromFindApi,
45
+ );
46
+
47
+ const setFindApiProducts = useResultStore(state => state.setFindApiProducts);
48
+ const imageAnalysis = useResultStore(state => state.imageAnalysis);
49
+
50
+ const imageCacheRef = useRef<Map<string, string | null>>(new Map());
51
+
52
+ const activeProducts = productsFromFindApi;
53
+ const setActiveProducts = setFindApiProducts;
54
+
55
+ const sendMessage = useCallback(
56
+ async (userMessage: string) => {
57
+ // Add user message to chat
58
+ addMessage({
59
+ role: 'user',
60
+ content: userMessage,
61
+ });
62
+
63
+ // Check if we've exceeded max attempts
64
+ if (attemptCounter >= 3) {
65
+ addMessage({
66
+ role: 'assistant',
67
+ content:
68
+ "I wasn't able to find a match in the current results. Try uploading a clearer image or from a different angle.",
69
+ action: { type: 'exit' },
70
+ });
71
+ return;
72
+ }
73
+
74
+ setIsLoading(true);
75
+ setError(null);
76
+
77
+ try {
78
+ // Build context for P5
79
+ const specification = imageAnalysis?.specification || {};
80
+ const imageDescription =
81
+ imageAnalysis?.imageDescription || 'No description available';
82
+
83
+ // Get P4 specification from smart filters
84
+ const p4Specification: Record<string, string> = {};
85
+ filters.forEach(f => {
86
+ p4Specification[f.label.toLowerCase()] = f.value;
87
+ });
88
+ Object.entries(specification).forEach(([key, value]) => {
89
+ if (value && typeof value === 'string') {
90
+ p4Specification[key] = value;
91
+ }
92
+ });
93
+
94
+ // Get all 100 results with detailed metadata
95
+ // ALWAYS use firstAlgoliaProducts (original) so indices are consistent
96
+ // This allows "show original results" and filtering from original to work correctly
97
+ const originalProducts = activeProducts;
98
+ const allResults = originalProducts
99
+ .slice(0, 100)
100
+ .map((p: any, i: number) => {
101
+ // Extract all potentially useful metadata
102
+ const metadata: Record<string, any> = {};
103
+ const metadataFields = [
104
+ 'voltage',
105
+ 'size',
106
+ 'brand',
107
+ 'manufacturer',
108
+ 'material',
109
+ 'type',
110
+ 'category',
111
+ 'color',
112
+ 'dimensions',
113
+ 'power',
114
+ 'current',
115
+ 'connector',
116
+ 'model',
117
+ 'series',
118
+ 'product_line',
119
+ ];
120
+
121
+ metadataFields.forEach(field => {
122
+ if (p[field]) metadata[field] = p[field];
123
+ });
124
+
125
+ // Extract from title/description using common patterns
126
+ const title = p.title || p.name || '';
127
+ const titleDesc = `${title} ${p.description || ''}`;
128
+
129
+ // Extract brand from title - often first word(s) in CAPS or before asterisk
130
+ if (!metadata.brand && !metadata.manufacturer) {
131
+ const capsMatch = title.match(/^([A-Z][A-Z0-9]+)(?:\*|\s)/);
132
+ if (capsMatch) metadata.brand = capsMatch[1];
133
+ const asteriskMatch = title.match(/^([^*]+)\*/);
134
+ if (asteriskMatch && asteriskMatch[1].length <= 20)
135
+ metadata.brand = asteriskMatch[1].trim();
136
+ const tokenMatch = titleDesc.match(/\b([A-Z][A-Z0-9]{2,})\b/);
137
+ if (tokenMatch) metadata.brand = tokenMatch[1].toUpperCase();
138
+ }
139
+
140
+ // Extract dimensions like "382 mm x 260 mm x 3 mm" or "344 x 221 x 3"
141
+ const dimensionMatch = titleDesc.match(
142
+ /(\d+)\s*(?:mm)?\s*[xX×]\s*(\d+)\s*(?:mm)?\s*(?:[xX×]\s*(\d+))?/i,
143
+ );
144
+ if (dimensionMatch && !metadata.dimensions) {
145
+ const dims = dimensionMatch[3]
146
+ ? `${dimensionMatch[1]}x${dimensionMatch[2]}x${dimensionMatch[3]}mm`
147
+ : `${dimensionMatch[1]}x${dimensionMatch[2]}mm`;
148
+ metadata.dimensions = dims;
149
+ }
150
+
151
+ // Extract voltage
152
+ const voltageMatch = titleDesc.match(/(\d+)\s*V\s*(DC|AC)?/i);
153
+ if (voltageMatch && !metadata.voltage)
154
+ metadata.voltage = voltageMatch[0];
155
+
156
+ // Extract generic product type keywords from title/description
157
+ if (!metadata.product_type) {
158
+ const typeMatch = titleDesc.match(
159
+ /\b(bearing|ring|relay|sensor|motor|fan|contactor|valve|pump|faucet|thermostat|filter|housing)\b/i,
160
+ );
161
+ if (typeMatch) {
162
+ metadata.product_type = typeMatch[1].toUpperCase();
163
+ }
164
+ }
165
+
166
+ // Extract material
167
+ const materialMatch = titleDesc.match(
168
+ /\b(steel|aluminum|aluminium|brass|copper|plastic|rubber|stainless)\b/i,
169
+ );
170
+ if (materialMatch && !metadata.material)
171
+ metadata.material = materialMatch[1].toLowerCase();
172
+
173
+ return {
174
+ index: i,
175
+ sku: p.sku || p.objectID || `product-${i}`,
176
+ title: title || 'Untitled',
177
+ description: (p.description || '').substring(0, 100),
178
+ category: p.category || p.type || '',
179
+ metadata: Object.keys(metadata).length > 0 ? metadata : null,
180
+ };
181
+ });
182
+
183
+ // Analyze common attributes across results for smart question options
184
+ const attributeFrequency: Record<string, Record<string, number>> = {};
185
+ allResults.forEach((r: any) => {
186
+ if (r.metadata) {
187
+ Object.entries(r.metadata).forEach(([key, value]) => {
188
+ if (!attributeFrequency[key]) attributeFrequency[key] = {};
189
+ const strValue = String(value);
190
+ attributeFrequency[key][strValue] =
191
+ (attributeFrequency[key][strValue] || 0) + 1;
192
+ });
193
+ }
194
+ });
195
+
196
+ // Build conversation history
197
+ const conversationHistory = messages.map(m => ({
198
+ role: m.role,
199
+ content: m.content,
200
+ }));
201
+
202
+ // Fetch product thumbnails for first 30 results (same limit as P3)
203
+ const productsForImages = originalProducts.slice(0, 30);
204
+ const imageResults = await Promise.all(
205
+ productsForImages.map(async (p: any, i: number) => {
206
+ const url = getProductImageUrl(p);
207
+ if (!url) return { index: i, base64: null };
208
+
209
+ if (imageCacheRef.current.has(url)) {
210
+ return {
211
+ index: i,
212
+ base64: imageCacheRef.current.get(url) ?? null,
213
+ };
214
+ }
215
+
216
+ const base64 = await fetchProductImageBase64(url);
217
+ imageCacheRef.current.set(url, base64);
218
+ return { index: i, base64 };
219
+ }),
220
+ );
221
+
222
+ const imagesAvailable = imageResults.filter(
223
+ r => r.base64 !== null,
224
+ ).length;
225
+ const hasVisualContext = imagesAvailable >= 3;
226
+
227
+ const visualNotice = hasVisualContext
228
+ ? `PRODUCT IMAGES: ${imagesAvailable} result thumbnails are included below (first 30 results). Use them to visually compare against the uploaded image when resurfacing better matches.`
229
+ : `PRODUCT IMAGES: NOT AVAILABLE — thumbnails could not be loaded. Use text metadata only for filtering.`;
230
+
231
+ // Build multimodal parts
232
+ const multimodalParts: any[] = [];
233
+
234
+ // Part 1: Text context
235
+ multimodalParts.push({
236
+ metadata: `CONTEXT:
237
+
238
+ IMAGE DESCRIPTION: ${imageDescription}
239
+
240
+ SPECIFICATION (from image analysis):
241
+ ${JSON.stringify(p4Specification, null, 2)}
242
+
243
+ FAILURE MODE: ${failureMode || 'unknown'}
244
+
245
+ COMMON ATTRIBUTES FOUND IN RESULTS (use these for question options - ask about varied attributes, NOT the obvious category):
246
+ ${JSON.stringify(attributeFrequency, null, 2)}
247
+
248
+ CURRENT ATTEMPT: ${attemptCounter} of 3
249
+
250
+ ${visualNotice}
251
+
252
+ PRODUCT LIST (first 30 with thumbnails where available, indices 0-based for filtered_results):`,
253
+ });
254
+
255
+ // Part 2: First 30 results interleaved with thumbnails
256
+ allResults
257
+ .slice(0, 30)
258
+ .forEach((r: (typeof allResults)[number], i: number) => {
259
+ const metaLine = `index: ${r.index} ${r.sku} | ${r.title}${r.metadata ? ` | ${JSON.stringify(r.metadata)}` : ''}`;
260
+
261
+ if (hasVisualContext && imageResults[i]?.base64) {
262
+ multimodalParts.push({
263
+ metadata: metaLine,
264
+ image: imageResults[i].base64,
265
+ mimeType: 'image/jpeg',
266
+ });
267
+ } else {
268
+ multimodalParts.push({ metadata: metaLine });
269
+ }
270
+ });
271
+
272
+ // Part 3: Remaining results (31–100) as text only
273
+ if (allResults.length > 30) {
274
+ multimodalParts.push({
275
+ metadata: `REMAINING RESULTS (indices 30–${allResults.length - 1}, text only):
276
+ ${allResults
277
+ .slice(30)
278
+ .map(
279
+ (r: (typeof allResults)[number]) =>
280
+ `index: ${r.index} | sku: ${r.sku} | ${r.title}${r.metadata ? ` | ${JSON.stringify(r.metadata)}` : ''}`,
281
+ )
282
+ .join('\n')}`,
283
+ });
284
+ }
285
+
286
+ // Part 4: Conversation + user message
287
+ multimodalParts.push({
288
+ metadata: `CONVERSATION HISTORY:
289
+ ${conversationHistory.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
290
+
291
+ USER MESSAGE: ${userMessage}
292
+
293
+ Based on the user's answer and the failure mode strategy, decide whether to ask another question, resurface better matches, or exit.`,
294
+ });
295
+
296
+ const response = await chatAnalysis({
297
+ promptKey: 'recovery_prompt',
298
+ multimodalInputs: multimodalParts,
299
+ });
300
+
301
+ const responseText = response;
302
+
303
+ // Parse the JSON response
304
+ const p5Response: P5Response = responseText;
305
+
306
+ // Execute the action
307
+ // ALWAYS use firstAlgoliaProducts for index lookup (matches what we sent to LLM)
308
+ const sourceProducts = activeProducts;
309
+ let options = p5Response.options || [];
310
+ switch (p5Response.action) {
311
+ case 'resurface':
312
+ // FILTER products to only show matching ones (not just reorder)
313
+ if (
314
+ p5Response.filtered_results &&
315
+ p5Response.filtered_results.length > 0
316
+ ) {
317
+ const indices = p5Response.filtered_results;
318
+ const filteredProducts: any[] = [];
319
+ const usedIndices = new Set<number>();
320
+
321
+ // Only add products that match the filter criteria
322
+ for (const idx of indices) {
323
+ if (
324
+ idx >= 0 &&
325
+ idx < sourceProducts.length &&
326
+ !usedIndices.has(idx)
327
+ ) {
328
+ filteredProducts.push(sourceProducts[idx]);
329
+ usedIndices.add(idx);
330
+ }
331
+ }
332
+
333
+ // Only show filtered products, not all products
334
+ if (filteredProducts.length > 0) {
335
+ setActiveProducts(filteredProducts);
336
+
337
+ options = [
338
+ ...options,
339
+ {
340
+ label: 'Go back to original results',
341
+ action_type: 'restore_original' as const,
342
+ key: 'restore',
343
+ value: 'restore',
344
+ },
345
+ ];
346
+ }
347
+ }
348
+ break;
349
+ case 'ask':
350
+ incrementAttempt();
351
+ break;
352
+ case 'reset':
353
+ if (activeProducts.length > 0) {
354
+ setActiveProducts(activeProducts);
355
+ }
356
+ break;
357
+ case 'exit':
358
+ // No action needed, just show exit message
359
+ break;
360
+ }
361
+
362
+ // Add assistant message
363
+ addMessage({
364
+ role: 'assistant',
365
+ content: p5Response.message,
366
+ action: { type: p5Response.action },
367
+ options: options,
368
+ });
369
+ } catch (err: any) {
370
+ console.error('[P5] Error:', err);
371
+ setError(err.message || 'Failed to process message');
372
+ addMessage({
373
+ role: 'assistant',
374
+ content:
375
+ 'Oops, something went wrong on my end. Would you like to try with a different image?',
376
+ options: [{ label: 'Upload new image', action_type: 'clarify' }],
377
+ });
378
+ } finally {
379
+ setIsLoading(false);
380
+ }
381
+ },
382
+ // eslint-disable-next-line react-hooks/exhaustive-deps
383
+ [
384
+ messages,
385
+ failureMode,
386
+ attemptCounter,
387
+ filters,
388
+ activeProducts,
389
+
390
+ imageAnalysis,
391
+ addMessage,
392
+ incrementAttempt,
393
+ setActiveProducts,
394
+ setIsLoading,
395
+ setError,
396
+ ],
397
+ );
398
+
399
+ return {
400
+ sendMessage,
401
+ messages,
402
+ failureMode,
403
+ attemptCounter,
404
+ };
405
+ }
406
+
407
+ export default useBadResultsRecovery;
@@ -0,0 +1,54 @@
1
+ import { useMemo } from 'react';
2
+ import useRequestStore from 'stores/request/requestStore';
3
+ import useResultStore from 'stores/result/resultStore';
4
+ import { filterProductsByText } from 'utils/textSearchFilter';
5
+
6
+ export function useEffectiveGroundingResults() {
7
+ const productsFromFindApi = useResultStore(
8
+ state => state.productsFromFindApi,
9
+ );
10
+ const groundingFilterResult = useResultStore(
11
+ state => state.groundingFilterResult,
12
+ );
13
+ const query = useRequestStore(state => state.query);
14
+ const requestImages = useRequestStore(state => state.requestImages);
15
+ const postFilterSelections = useRequestStore(
16
+ state => state.postFilterSelections,
17
+ );
18
+
19
+ const effectiveGroundingResults = useMemo(() => {
20
+ let filteredProducts = productsFromFindApi;
21
+
22
+ if (requestImages?.length && query?.trim()) {
23
+ filteredProducts = filterProductsByText(query, productsFromFindApi);
24
+ }
25
+
26
+ const postEntries = Object.entries(postFilterSelections || {}).filter(
27
+ ([, values]) => values?.length,
28
+ );
29
+ if (postEntries.length > 0) {
30
+ filteredProducts = filteredProducts.filter((product: any) => {
31
+ const productFilters = product?.filters || {};
32
+ return postEntries.every(([attribute, selectedValues]) => {
33
+ const productValues = productFilters?.[attribute];
34
+ if (!Array.isArray(productValues)) return false;
35
+ return selectedValues.some((value: string) =>
36
+ productValues.includes(value),
37
+ );
38
+ });
39
+ });
40
+ }
41
+
42
+ return groundingFilterResult.filter((result: any) =>
43
+ filteredProducts.some((product: any) => product.sku === result.sku),
44
+ );
45
+ }, [
46
+ productsFromFindApi,
47
+ groundingFilterResult,
48
+ query,
49
+ requestImages,
50
+ postFilterSelections,
51
+ ]);
52
+
53
+ return effectiveGroundingResults;
54
+ }