@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,651 @@
1
+ /**
2
+ * P3 - Good Results Conversation
3
+ *
4
+ * Handles multi-turn conversation when results match the uploaded image.
5
+ * Helps user filter, compare, and identify within good results.
6
+ * Never triggers new API searches - works within 100 results.
7
+ */
8
+
9
+ import { useCallback, useRef } from 'react';
10
+ import useConversationStore, {
11
+ QuickReplyOption,
12
+ ActionType,
13
+ } from 'stores/chat/conversationStore';
14
+ import useSmartFiltersStore from 'stores/smartFilters/smartFiltersStore';
15
+ import useResultStore from 'stores/result/resultStore';
16
+ import useRequestStore from 'stores/request/requestStore';
17
+ import { chatAnalysis } from 'services/vizo';
18
+ import {
19
+ fetchProductImageBase64,
20
+ getProductImageUrl,
21
+ } from 'utils/fetchProductImage';
22
+
23
+ /**
24
+ * Extracts search keywords from a user's natural language description.
25
+ * Strips filler words and keeps product-relevant terms.
26
+ * e.g. "looking for a green color bulb shaped object" → "green bulb"
27
+ * e.g. "I need a Schneider Electric 24V blue LED indicator light" → "Schneider Electric 24V blue LED indicator light"
28
+ */
29
+ function extractSearchKeywords(description: string): string {
30
+ const stopWords = new Set([
31
+ 'i',
32
+ 'me',
33
+ 'my',
34
+ 'am',
35
+ 'is',
36
+ 'are',
37
+ 'was',
38
+ 'were',
39
+ 'be',
40
+ 'been',
41
+ 'being',
42
+ 'a',
43
+ 'an',
44
+ 'the',
45
+ 'and',
46
+ 'or',
47
+ 'but',
48
+ 'if',
49
+ 'of',
50
+ 'at',
51
+ 'by',
52
+ 'for',
53
+ 'with',
54
+ 'to',
55
+ 'from',
56
+ 'in',
57
+ 'on',
58
+ 'it',
59
+ 'its',
60
+ 'this',
61
+ 'that',
62
+ 'these',
63
+ 'those',
64
+ 'do',
65
+ 'does',
66
+ 'did',
67
+ 'have',
68
+ 'has',
69
+ 'had',
70
+ 'will',
71
+ 'would',
72
+ 'could',
73
+ 'should',
74
+ 'can',
75
+ 'may',
76
+ 'might',
77
+ 'shall',
78
+ 'need',
79
+ 'want',
80
+ 'like',
81
+ 'looking',
82
+ 'find',
83
+ 'search',
84
+ 'show',
85
+ 'get',
86
+ 'please',
87
+ 'help',
88
+ 'something',
89
+ 'thing',
90
+ 'object',
91
+ 'item',
92
+ 'product',
93
+ 'part',
94
+ 'component',
95
+ 'piece',
96
+ 'one',
97
+ 'some',
98
+ 'any',
99
+ 'very',
100
+ 'really',
101
+ 'just',
102
+ 'also',
103
+ 'too',
104
+ 'so',
105
+ 'not',
106
+ 'no',
107
+ 'yes',
108
+ 'color',
109
+ 'colour',
110
+ 'colored',
111
+ 'coloured',
112
+ 'shaped',
113
+ 'type',
114
+ 'kind',
115
+ 'sort',
116
+ 'similar',
117
+ 'same',
118
+ 'look',
119
+ 'looks',
120
+ 'looking',
121
+ ]);
122
+
123
+ const words = description
124
+ .trim()
125
+ .replace(/[.,!?;:'"()[\]{}]/g, '')
126
+ .split(/\s+/)
127
+ .filter(w => !stopWords.has(w.toLowerCase()));
128
+
129
+ // Prioritize: numbers/IDs first (likely product codes), then other keywords
130
+ const numbers = words.filter(w => /\d/.test(w));
131
+ const nonNumbers = words.filter(w => !/\d/.test(w));
132
+ const prioritized = [...numbers, ...nonNumbers];
133
+
134
+ // Keep at most 8 keywords
135
+ return (
136
+ prioritized.slice(0, 8).join(' ') ||
137
+ description.trim().split(/\s+/).slice(0, 3).join(' ')
138
+ );
139
+ }
140
+
141
+ interface P3Response {
142
+ message: string;
143
+ action: {
144
+ type: ActionType;
145
+ key?: string;
146
+ value?: string;
147
+ matched_ids?: string[];
148
+ matched_indices?: number[]; // Legacy fallback
149
+ };
150
+ options: QuickReplyOption[];
151
+ }
152
+
153
+ export function useGoodResultsChat() {
154
+ const {
155
+ messages,
156
+ activeFilters,
157
+ addMessage,
158
+ addFilter,
159
+ removeFilter,
160
+ clearFilters,
161
+ setIsLoading,
162
+ setError,
163
+ } = useConversationStore();
164
+
165
+ const { filters } = useSmartFiltersStore();
166
+
167
+ const productsFromFindApi = useResultStore(
168
+ state => state.productsFromFindApi,
169
+ );
170
+ const firstSearchResults = useResultStore(state => state.firstSearchResults);
171
+
172
+ const setFindApiProducts = useResultStore(state => state.setFindApiProducts);
173
+ const fullResultPool = useResultStore(state => state.fullResultPool);
174
+ const setFullResultPool = useResultStore(state => state.setFullResultPool);
175
+ const imageAnalysis = useResultStore(state => state.imageAnalysis);
176
+ const setImageAnalysis = useResultStore(state => state.setImageAnalysis);
177
+ const requestImages = useRequestStore(state => state.requestImages);
178
+
179
+ const isAlgoliaEnabled = !!(window as any).settings?.algolia?.enabled;
180
+ const activeProducts = productsFromFindApi;
181
+ const setActiveProducts = setFindApiProducts;
182
+
183
+ // Original unfiltered results — used for restoring and index-based filtering
184
+ // Prefer fullResultPool (50 items) when available for broader filtering
185
+ const originalProducts =
186
+ fullResultPool.length > 0
187
+ ? fullResultPool
188
+ : firstSearchResults.length > 0
189
+ ? firstSearchResults
190
+ : activeProducts;
191
+
192
+ // Image cache: persists across P3 messages within the same search session
193
+ const imageCacheRef = useRef<Map<string, string | null>>(new Map());
194
+ const lastSessionIdRef = useRef<string>('');
195
+
196
+ const sendMessage = useCallback(
197
+ async (userMessage: string, options?: { silent?: boolean }) => {
198
+ if (!options?.silent) {
199
+ addMessage({
200
+ role: 'user',
201
+ content: userMessage,
202
+ });
203
+ }
204
+
205
+ setIsLoading(true);
206
+ setError(null);
207
+
208
+ try {
209
+ // Build context for P3
210
+ const specification = imageAnalysis?.specification || {};
211
+ const imageDescription =
212
+ imageAnalysis?.imageDescription || 'No description available';
213
+
214
+ // Get P4 specification from smart filters
215
+ const p4Specification: Record<string, string> = {};
216
+ filters.forEach(f => {
217
+ p4Specification[f.label.toLowerCase()] = f.value;
218
+ });
219
+ Object.entries(specification).forEach(([key, value]) => {
220
+ if (value && typeof value === 'string') {
221
+ p4Specification[key] = value;
222
+ }
223
+ });
224
+
225
+ // Get all results for AI context — read fresh from store to avoid stale closure data
226
+ const freshState = useResultStore.getState();
227
+ const freshFullPool = freshState.fullResultPool;
228
+ const freshFindApi = freshState.productsFromFindApi;
229
+ const freshAlgolia = freshState.productsFromAlgolia;
230
+ const freshActive = isAlgoliaEnabled
231
+ ? freshAlgolia
232
+ : freshAlgolia.length > 0
233
+ ? freshAlgolia
234
+ : freshFindApi;
235
+ const freshOriginal =
236
+ freshFullPool.length > 0 ? freshFullPool : freshActive;
237
+ const poolForAI =
238
+ freshOriginal.length > 0 ? freshOriginal : activeProducts;
239
+ const visibleResults = poolForAI
240
+ .slice(0, 30)
241
+ .map((p: any, i: number) => {
242
+ const title = p.title || p.name || '';
243
+
244
+ return {
245
+ id: p.sku || p.objectID || `product-${i}`,
246
+ title: title || 'Untitled',
247
+ brand: p.brand || '',
248
+ description: (p.description || '').substring(0, 200),
249
+ mpn: p.customIds?.mpn || p.ids?.mpn || '',
250
+ };
251
+ });
252
+
253
+ // Analyze common attributes for filter suggestions
254
+ const attributeFrequency: Record<string, Record<string, number>> = {};
255
+ visibleResults.forEach(r => {
256
+ // Track brand frequency
257
+ if (r.brand) {
258
+ if (!attributeFrequency['brand']) attributeFrequency['brand'] = {};
259
+ attributeFrequency['brand'][r.brand] =
260
+ (attributeFrequency['brand'][r.brand] || 0) + 1;
261
+ }
262
+ // Extract voltage from title/description
263
+ const titleDesc = `${r.title} ${r.description}`;
264
+ const voltageMatch = titleDesc.match(/(\d+)\s*V\s*(DC|AC)?/i);
265
+ if (voltageMatch) {
266
+ if (!attributeFrequency['voltage'])
267
+ attributeFrequency['voltage'] = {};
268
+ attributeFrequency['voltage'][voltageMatch[0]] =
269
+ (attributeFrequency['voltage'][voltageMatch[0]] || 0) + 1;
270
+ }
271
+ });
272
+
273
+ // Build conversation history — skip in silent mode to avoid re-applying old filters
274
+ const conversationHistory = options?.silent
275
+ ? []
276
+ : messages.map(m => ({
277
+ role: m.role,
278
+ content: m.content,
279
+ }));
280
+
281
+ // Fetch product thumbnail images with caching — send images for ALL results (up to 50)
282
+ // Cache persists across filter operations; only clears on new search session
283
+ const productsForImages = poolForAI.slice(0, 30);
284
+ const currentSessionId = useConversationStore.getState().sessionId;
285
+ if (currentSessionId !== lastSessionIdRef.current) {
286
+ imageCacheRef.current.clear();
287
+ lastSessionIdRef.current = currentSessionId;
288
+ }
289
+ const imageResults = await Promise.all(
290
+ productsForImages.map(async (p: any, i: number) => {
291
+ const url = getProductImageUrl(p);
292
+
293
+ if (!url) return { rank: i + 1, base64: null };
294
+
295
+ const base64 = await fetchProductImageBase64(url);
296
+ imageCacheRef.current.set(url, base64);
297
+ return { rank: i + 1, base64 };
298
+ }),
299
+ );
300
+
301
+ const imagesAvailable = imageResults.filter(
302
+ r => r.base64 !== null,
303
+ ).length;
304
+ const hasVisualContext = imagesAvailable >= 3;
305
+
306
+ // Build multimodal user parts: text context + product images interleaved with metadata
307
+ const userParts: any[] = [];
308
+
309
+ // Visual availability notice — prevents AI from hallucinating visual attributes
310
+ const visualNotice = hasVisualContext
311
+ ? `PRODUCT IMAGES: ${imagesAvailable} product thumbnail images are included below. Use them to evaluate visual attributes (color, shape, size, form factor).`
312
+ : `PRODUCT IMAGES: NOT AVAILABLE — product images could not be loaded. Do NOT make claims about visual attributes like color, shape, or appearance. Only use text metadata for filtering. If the user asks about visual properties (color, shape, etc.), tell them you cannot evaluate visual attributes from the current data and suggest they search the full catalog instead.`;
313
+
314
+ // Part 1: Text context
315
+ userParts.push({
316
+ text: `CONTEXT:
317
+
318
+ IMAGE DESCRIPTION: ${imageDescription}
319
+
320
+ SPECIFICATION (from image analysis):
321
+ ${JSON.stringify(p4Specification, null, 2)}
322
+
323
+ ACTIVE FILTERS:
324
+ ${activeFilters.length > 0 ? activeFilters.map(f => `${f.key}: ${f.value}`).join('\n') : 'None'}
325
+
326
+ AVAILABLE FILTER OPTIONS (based on actual product metadata - use these for suggestions):
327
+ ${JSON.stringify(attributeFrequency, null, 2)}
328
+
329
+ ${visualNotice}
330
+
331
+ PRODUCT LIST (up to 50 results — each has a unique ID in [brackets] and AI-generated TAGS for type/color/shape/material. Use TAGS for filtering — they are pre-analyzed from product images. Return matching IDs in matched_ids array):
332
+ `,
333
+ });
334
+
335
+ // Part 2: Product entries with interleaved images (for all results up to 50)
336
+ visibleResults.forEach((r, i) => {
337
+ // Include AI-generated tags if available (from useProductTagger)
338
+ const originalProduct = poolForAI[i];
339
+ const tags = originalProduct?._tags;
340
+ const tagStr = tags
341
+ ? ` | TAGS: type=${tags.productType}, color=${tags.color}, shape=${tags.shape}, material=${tags.material}`
342
+ : '';
343
+ const metaLine = `id: ${r.id} | ${r.title} | brand: ${r.brand} | mpn: ${r.mpn} | desc: ${r.description}${tagStr}`;
344
+
345
+ // Include product image if available
346
+ if (
347
+ hasVisualContext &&
348
+ i < imageResults.length &&
349
+ imageResults[i]?.base64
350
+ ) {
351
+ userParts.push({
352
+ text: metaLine,
353
+ inlineData: {
354
+ mimeType: 'image/jpeg',
355
+ data: imageResults[i].base64,
356
+ },
357
+ });
358
+ } else {
359
+ userParts.push({ text: metaLine });
360
+ }
361
+ });
362
+
363
+ // Part 3: Remaining context
364
+ userParts.push({
365
+ text: `
366
+ Original_results_count: ${originalProducts.length}
367
+ Current RESULTS: ${visibleResults.length}
368
+
369
+ CONVERSATION HISTORY:
370
+ ${conversationHistory.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
371
+
372
+ USER MESSAGE: ${userMessage}
373
+
374
+ Respond to the user's message with an appropriate action and follow-up options.`,
375
+ });
376
+
377
+ const response: P3Response = await chatAnalysis({
378
+ promptKey: 'chat_assistant_prompt',
379
+ multimodalInputs: userParts.map(part => ({
380
+ metadata: part.text,
381
+ image: part.inlineData?.data,
382
+ mimeType: part.inlineData?.mimeType,
383
+ })),
384
+ });
385
+
386
+ const responseText = response;
387
+
388
+ // Parse the JSON response
389
+ const p3Response: P3Response = responseText;
390
+
391
+ // Silent mode: only suggest filters, never apply actions
392
+ if (options?.silent && p3Response.action) {
393
+ p3Response.action.type = 'none';
394
+ p3Response.action.matched_ids = [];
395
+ p3Response.action.matched_indices = undefined;
396
+ }
397
+
398
+ // Deterministic description detection via store flag
399
+ const { awaitingDescription, setAwaitingDescription } =
400
+ useConversationStore.getState();
401
+ if (awaitingDescription && p3Response.action) {
402
+ p3Response.action.type = 'text_search';
403
+ p3Response.action.value = extractSearchKeywords(userMessage);
404
+ p3Response.action.matched_indices = undefined;
405
+ setAwaitingDescription(false);
406
+ }
407
+
408
+ // For any text_search action, ensure value contains extracted keywords, not raw description
409
+ if (
410
+ p3Response.action?.type === 'text_search' &&
411
+ p3Response.action.value
412
+ ) {
413
+ const valWords = p3Response.action.value.trim().split(/\s+/);
414
+ if (valWords.length > 8) {
415
+ p3Response.action.value = extractSearchKeywords(
416
+ p3Response.action.value,
417
+ );
418
+ }
419
+ }
420
+
421
+ // ===== SIMPLIFIED ACTION HANDLING =====
422
+ // AI returns matched_ids (SKU/ID array). We look up products by ID from the pool.
423
+ const sourcePool = poolForAI.slice(0, 30);
424
+ let actualFilteredCount = 0;
425
+
426
+ // Build a lookup map: ID → product
427
+ const productById = new Map<string, any>();
428
+ sourcePool.forEach((p: any) => {
429
+ const id = p.sku || p.objectID || '';
430
+ if (id) productById.set(String(id), p);
431
+ });
432
+
433
+ // Debug: log the map keys so we can see what IDs are available
434
+
435
+ if (p3Response.action) {
436
+ const { type, key, value } = p3Response.action;
437
+ const matchedIds: string[] = p3Response.action.matched_ids || [];
438
+ const matchedIndices: number[] =
439
+ p3Response.action.matched_indices || [];
440
+
441
+ let matchedProducts: any[] = [];
442
+
443
+ // 1. Try matched_ids (string IDs)
444
+ if (matchedIds.length > 0) {
445
+ matchedProducts = matchedIds
446
+ .map(id => {
447
+ const found = productById.get(String(id));
448
+
449
+ return found;
450
+ })
451
+ .filter(Boolean);
452
+ }
453
+
454
+ // 2. Try matched_indices as SKU IDs (convert numbers to strings, look up in map)
455
+ if (matchedProducts.length === 0 && matchedIndices.length > 0) {
456
+ const asIds = matchedIndices
457
+ .map(id => productById.get(String(id)))
458
+ .filter(Boolean);
459
+ if (asIds.length > 0) {
460
+ matchedProducts = asIds;
461
+ }
462
+ }
463
+
464
+ // 3. Last resort: try matched_indices as 1-based positional indices
465
+ if (matchedProducts.length === 0 && matchedIndices.length > 0) {
466
+ const asPositions = matchedIndices
467
+ .filter((i: number) => i >= 1 && i <= sourcePool.length)
468
+ .map((i: number) => sourcePool[i - 1])
469
+ .filter(Boolean);
470
+ if (asPositions.length > 0) {
471
+ matchedProducts = asPositions;
472
+ }
473
+ }
474
+
475
+ switch (type) {
476
+ case 'filter_apply':
477
+ if (matchedProducts.length > 0) {
478
+ if (key && value) addFilter(key, value);
479
+ setActiveProducts(matchedProducts);
480
+ actualFilteredCount = matchedProducts.length;
481
+ }
482
+ break;
483
+
484
+ case 'filter_remove':
485
+ if (key) {
486
+ removeFilter(key);
487
+ setActiveProducts(originalProducts);
488
+ actualFilteredCount = originalProducts.length;
489
+ }
490
+ break;
491
+
492
+ case 'filter_clear':
493
+ clearFilters();
494
+ setActiveProducts(originalProducts);
495
+ actualFilteredCount = originalProducts.length;
496
+ break;
497
+
498
+ case 'text_search':
499
+ if (value) {
500
+ if (matchedProducts.length > 0) {
501
+ setActiveProducts(matchedProducts);
502
+ actualFilteredCount = matchedProducts.length;
503
+ }
504
+ useConversationStore.getState().setPendingCatalogText(value);
505
+ }
506
+ break;
507
+
508
+ case 'none':
509
+ break;
510
+ }
511
+ }
512
+
513
+ // Use AI's message directly. Only override if AI returned 0 matches for a filter action.
514
+ let finalMessage = p3Response.message?.trim() || '';
515
+ if (
516
+ actualFilteredCount === 0 &&
517
+ (p3Response.action?.type === 'filter_apply' ||
518
+ p3Response.action?.type === 'text_search')
519
+ ) {
520
+ if (!finalMessage) {
521
+ finalMessage = `I couldn't find any matches for "${p3Response.action?.value || 'your query'}" in the current results. You can try narrowing further or search the full catalog.`;
522
+ }
523
+ useConversationStore
524
+ .getState()
525
+ .setPendingCatalogText(p3Response.action?.value || '');
526
+ }
527
+ let finalOptions = p3Response.options || [];
528
+
529
+ // Fix count mismatches: AI often says a different number than what was actually resolved.
530
+ // Replace numeric counts in the message with the actual count.
531
+ if (
532
+ actualFilteredCount > 0 &&
533
+ (p3Response.action?.type === 'filter_apply' ||
534
+ p3Response.action?.type === 'text_search')
535
+ ) {
536
+ finalOptions = [
537
+ ...finalOptions,
538
+
539
+ {
540
+ label: 'Go back to original results',
541
+ action_type: 'restore_original' as const,
542
+ key: 'restore',
543
+ value: 'restore',
544
+ },
545
+ ];
546
+ }
547
+
548
+ const {
549
+ preCatalogResults,
550
+ relatedPartsUsed,
551
+ relatedPartsTableSuggestions,
552
+ } = useConversationStore.getState();
553
+
554
+ if (relatedPartsUsed) {
555
+ finalOptions = finalOptions.filter(
556
+ opt => opt.action_type !== 'find_related_parts',
557
+ );
558
+ }
559
+ const catalogSearchReady =
560
+ useConversationStore.getState().catalogSearchReady;
561
+
562
+ // If user came from a catalog search, offer to go back to previous results
563
+ if (catalogSearchReady && p3Response.action?.type !== 'filter_clear') {
564
+ finalOptions = [
565
+ ...finalOptions,
566
+ {
567
+ label: 'Text search in catalog',
568
+ action_type: 'catalog_search_text' as const,
569
+ key: 'catalog_search_text',
570
+ value: 'catalog_search_text',
571
+ },
572
+ ];
573
+ }
574
+
575
+ if (preCatalogResults && preCatalogResults.length > 0) {
576
+ finalOptions = [
577
+ ...finalOptions,
578
+ {
579
+ label: 'Go back to original results',
580
+ action_type: 'restore_original' as const,
581
+ key: 'restore',
582
+ value: 'restore',
583
+ },
584
+ ];
585
+ }
586
+
587
+ if (
588
+ window.settings.vizo?.findRelatedParts &&
589
+ !relatedPartsUsed &&
590
+ relatedPartsTableSuggestions.length > 0
591
+ ) {
592
+ finalOptions = [
593
+ ...finalOptions,
594
+ {
595
+ label: `Find parts related to ${imageAnalysis.specification.product_category || 'request image'}`,
596
+ action_type: 'find_related_parts' as const,
597
+ key: 'find_related',
598
+ value: 'find_related_parts',
599
+ },
600
+ ];
601
+ }
602
+
603
+ // Add assistant message
604
+ addMessage({
605
+ role: 'assistant',
606
+ content: finalMessage,
607
+ action: p3Response.action,
608
+ options: finalOptions,
609
+ });
610
+ } catch (err: any) {
611
+ console.error('[P3] Error:', err);
612
+ setError(err.message || 'Failed to process message');
613
+ addMessage({
614
+ role: 'assistant',
615
+ content:
616
+ 'Oops, something went wrong on my end. Could you try that again?',
617
+ });
618
+ } finally {
619
+ setIsLoading(false);
620
+ }
621
+ },
622
+ // eslint-disable-next-line react-hooks/exhaustive-deps
623
+ [
624
+ messages,
625
+ activeFilters,
626
+ filters,
627
+ activeProducts,
628
+ originalProducts,
629
+ fullResultPool,
630
+ imageAnalysis,
631
+ requestImages,
632
+ addMessage,
633
+ addFilter,
634
+ removeFilter,
635
+ clearFilters,
636
+ setActiveProducts,
637
+ setFullResultPool,
638
+ setImageAnalysis,
639
+ setIsLoading,
640
+ setError,
641
+ ],
642
+ );
643
+
644
+ return {
645
+ sendMessage,
646
+ messages,
647
+ activeFilters,
648
+ };
649
+ }
650
+
651
+ export default useGoodResultsChat;