@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,745 @@
1
+ import { useEffect, useState, useRef, useCallback } from 'react';
2
+
3
+ import useConversationStore from 'stores/chat/conversationStore';
4
+ import useResultStore from 'stores/result/resultStore';
5
+ import useRequestStore from 'stores/request/requestStore';
6
+ import { useGoodResultsChat } from 'hooks/useGoodResultsChat';
7
+ import { useBadResultsRecovery } from 'hooks/useBadResultsRecovery';
8
+ import { chatAnalysis } from 'services/vizo';
9
+ import { compressImage } from 'utils/compressImage';
10
+ import { loadRelatedPartsTable, lookupRelatedParts } from 'utils/relatedParts';
11
+ import {
12
+ fetchProductImageBase64,
13
+ getProductImageUrl,
14
+ } from 'utils/fetchProductImage';
15
+
16
+ export function useChatAssistantLogic() {
17
+ const [inputValue, setInputValue] = useState('');
18
+ const messagesEndRef = useRef<HTMLDivElement>(null);
19
+ const imageInputRef = useRef<HTMLInputElement>(null);
20
+
21
+ const [findingRelatedParts, setFindingRelatedParts] = useState(false);
22
+
23
+ // Conversation store
24
+ const isEvaluated = useConversationStore(s => s.isEvaluated);
25
+ const route = useConversationStore(s => s.route);
26
+ const messages = useConversationStore(s => s.messages);
27
+ const isLoading = useConversationStore(s => s.isLoading);
28
+ const error = useConversationStore(s => s.error);
29
+ const catalogSearchReady = useConversationStore(s => s.catalogSearchReady);
30
+ const setCatalogSearchReady = useConversationStore(
31
+ s => s.setCatalogSearchReady,
32
+ );
33
+ // const startNewSession = useConversationStore(s => s.startNewSession);
34
+
35
+ // Smart filters store (no longer requires API key)
36
+ const hasApiKey = true;
37
+
38
+ // Result store
39
+ const productsFromAlgolia = useResultStore(
40
+ state => state.productsFromAlgolia,
41
+ );
42
+ const productsFromFindApi = useResultStore(
43
+ state => state.productsFromFindApi,
44
+ );
45
+ const firstSearchResults = useResultStore(state => state.firstSearchResults);
46
+
47
+ const setFindApiProducts = useResultStore(state => state.setFindApiProducts);
48
+ const setFullResultPool = useResultStore(state => state.setFullResultPool);
49
+ const imageAnalysis = useResultStore(state => state.imageAnalysis);
50
+
51
+ const isAlgoliaEnabled = !!(window as any).settings?.algolia?.enabled;
52
+ const activeProducts = productsFromFindApi;
53
+
54
+ // Request store
55
+ const requestImages = useRequestStore(state => state.requestImages);
56
+
57
+ const p2TriggeredRef = useRef(false);
58
+
59
+ // Hooks for P2, P3, P5, P6, P7
60
+ const { sendMessage: sendP3Message } = useGoodResultsChat();
61
+ const { sendMessage: sendP5Message } = useBadResultsRecovery();
62
+
63
+ // P7 state
64
+ const p7TriggeredRef = useRef(false);
65
+ const [p7Running, setP7Running] = useState(false);
66
+ const [p7Done, setP7Done] = useState(false);
67
+
68
+ // Catalog search state
69
+ const [catalogSearching, setCatalogSearching] = useState(false);
70
+
71
+ // Helper to get the correct product setter
72
+ const getSetProducts = useCallback(() => {
73
+ return setFindApiProducts;
74
+ }, [setFindApiProducts]);
75
+
76
+ // Trigger P2 evaluation
77
+ useEffect(() => {
78
+ if (
79
+ !isEvaluated &&
80
+ !p2TriggeredRef.current &&
81
+ activeProducts.length > 0 &&
82
+ hasApiKey &&
83
+ requestImages[0]
84
+ ) {
85
+ p2TriggeredRef.current = true;
86
+ // evaluateResults();
87
+ }
88
+ }, [isEvaluated, activeProducts, hasApiKey, requestImages]);
89
+
90
+ // Reset P7 flags when session resets
91
+ useEffect(() => {
92
+ if (!isEvaluated) {
93
+ p2TriggeredRef.current = false;
94
+ p7TriggeredRef.current = false;
95
+ setP7Running(false);
96
+ setP7Done(false);
97
+ }
98
+ }, [isEvaluated]);
99
+
100
+ // Auto-scroll
101
+ useEffect(() => {
102
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
103
+ }, [messages]);
104
+
105
+ // ── Handlers ──
106
+
107
+ const handleSendMessage = useCallback(async () => {
108
+ if (!inputValue.trim() || isLoading) return;
109
+ const message = inputValue.trim();
110
+ setInputValue('');
111
+ setCatalogSearchReady(true);
112
+
113
+ if (!message) return;
114
+
115
+ if (route === 'P3') {
116
+ await sendP3Message(message);
117
+ } else if (route === 'P5') {
118
+ await sendP5Message(message);
119
+ } else {
120
+ await sendP3Message(message);
121
+ }
122
+ }, [
123
+ inputValue,
124
+ isLoading,
125
+ route,
126
+ sendP3Message,
127
+ sendP5Message,
128
+ setCatalogSearchReady,
129
+ ]);
130
+
131
+ const handleOptionClick = useCallback(
132
+ async (option: any) => {
133
+ if (option.action_type === 'text_search_prompt') {
134
+ setInputValue('');
135
+ const store = useConversationStore.getState();
136
+ store.setAwaitingDescription(true);
137
+ store.addMessage({
138
+ role: 'assistant',
139
+ content:
140
+ "What are you looking for? I'll check the current results first. The more details you share — like brand, model, or specs — the better I can help!",
141
+ });
142
+ return;
143
+ }
144
+
145
+ if (option.action_type === 'catalog_search_image') {
146
+ await handleCatalogSearchWithImage();
147
+ return;
148
+ }
149
+
150
+ if (option.action_type === 'catalog_search_text') {
151
+ await handleCatalogSearchWithText();
152
+ return;
153
+ }
154
+
155
+ if (option.action_type === 'find_related_parts') {
156
+ useConversationStore.getState().setRelatedPartsUsed(true);
157
+ await handleFindRelatedParts();
158
+ return;
159
+ }
160
+
161
+ // Related part selected — prompt user for specs, then search catalog
162
+ if (option.action_type === 'related_part') {
163
+ const partName = option.value || option.label;
164
+ const store = useConversationStore.getState();
165
+ store.setPendingRelatedPart(partName);
166
+ store.setAwaitingDescription(true);
167
+ store.addMessage({
168
+ role: 'assistant',
169
+ content: `You're looking for a **${partName}**. Can you share any details like brand, size, connection type, or model? The more specific you are, the better results I can find.\n\nOr just press Enter to search for "${partName}" as-is.`,
170
+ });
171
+ setInputValue(partName);
172
+ return;
173
+ }
174
+
175
+ if (option.action_type === 'restore_original') {
176
+ const store = useConversationStore.getState();
177
+ const originalResults = firstSearchResults;
178
+
179
+ const restoreResults =
180
+ originalResults.length > 0
181
+ ? originalResults
182
+ : store.preCatalogResults;
183
+
184
+ if (restoreResults && restoreResults.length > 0) {
185
+ const setProducts = getSetProducts();
186
+ setProducts(restoreResults);
187
+ setFullResultPool(restoreResults);
188
+ store.setPreCatalogResults(null);
189
+ store.addMessage({
190
+ role: 'assistant',
191
+ content: `Restored your original ${restoreResults.length} result${restoreResults.length === 1 ? '' : 's'} from the initial image search. Feel free to keep filtering or run a new catalog search.`,
192
+ });
193
+ } else {
194
+ store.addMessage({
195
+ role: 'assistant',
196
+ content:
197
+ "I couldn't find the original search results to restore yet. Please run an image search first.",
198
+ });
199
+ }
200
+ return;
201
+ }
202
+
203
+ const message = option.value || option.label;
204
+ setInputValue('');
205
+
206
+ if (route === 'P3') {
207
+ await sendP3Message(message);
208
+ } else if (route === 'P5') {
209
+ await sendP5Message(message);
210
+ }
211
+ },
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ [
214
+ route,
215
+ sendP3Message,
216
+ sendP5Message,
217
+ getSetProducts,
218
+ setFullResultPool,
219
+ isAlgoliaEnabled,
220
+
221
+ firstSearchResults,
222
+ ],
223
+ );
224
+
225
+ const handleImageUpload = useCallback(
226
+ async (file: File | string) => {
227
+ const store = useConversationStore.getState();
228
+ const imageUrl =
229
+ typeof file === 'string' ? file : URL.createObjectURL(file);
230
+
231
+ store.addMessage({
232
+ role: 'user',
233
+ content: 'Uploaded an image to refine search',
234
+ imageUrl,
235
+ });
236
+
237
+ store.setIsLoading(true);
238
+ store.setPendingCatalogImage(file);
239
+
240
+ try {
241
+ const currentProducts = [...activeProducts];
242
+
243
+ if (currentProducts.length === 0) {
244
+ store.addMessage({
245
+ role: 'assistant',
246
+ content:
247
+ currentProducts.length === 0
248
+ ? 'No current results to refine. You can search the catalog with this image.'
249
+ : 'Unable to analyze the image. You can search the catalog directly.',
250
+ options: [
251
+ {
252
+ label: 'Search catalog with this image',
253
+ action_type: 'catalog_search_image' as const,
254
+ key: 'catalog_search',
255
+ value: 'image',
256
+ },
257
+ ],
258
+ });
259
+ return;
260
+ }
261
+
262
+ const [newBase64, origBase64] = await Promise.all([
263
+ (async () => {
264
+ try {
265
+ const normalizedInput =
266
+ typeof file === 'string' &&
267
+ !file.includes(',') &&
268
+ !file.startsWith('https:')
269
+ ? `data:image/jpeg;base64,${file}`
270
+ : file;
271
+ const compressedDataUrl = await compressImage(normalizedInput);
272
+ return compressedDataUrl.includes(',')
273
+ ? compressedDataUrl.split(',')[1] || ''
274
+ : compressedDataUrl;
275
+ } catch (compressionError) {
276
+ console.warn(
277
+ '[ChatAssistant] Image compression failed, using original image:',
278
+ compressionError,
279
+ );
280
+ if (typeof file === 'string') {
281
+ return file.includes(',') ? file.split(',')[1] : file;
282
+ }
283
+ return await new Promise<string>(resolve => {
284
+ const reader = new FileReader();
285
+ reader.onload = () =>
286
+ resolve((reader.result as string).split(',')[1] || '');
287
+ reader.readAsDataURL(file);
288
+ });
289
+ }
290
+ })(),
291
+ requestImages[0]
292
+ ? Promise.resolve(
293
+ requestImages[0].toDataURL('image/jpeg', 0.7).split(',')[1] ||
294
+ '',
295
+ )
296
+ : Promise.resolve(''),
297
+ ]);
298
+
299
+ const visibleProducts = currentProducts.slice(0, 30);
300
+ const productList = visibleProducts.map((p: any, i: number) => ({
301
+ rank: i + 1,
302
+ sku: p.sku || p.objectID || `product-${i}`,
303
+ title: p.title || p.name || 'Untitled',
304
+ brand: p.brand || '',
305
+ description: (p.description || '').substring(0, 150),
306
+ categories: p.categories || [],
307
+ mpn: p.customIds?.mpn || p.ids?.mpn || '',
308
+ }));
309
+
310
+ const productImages = await Promise.all(
311
+ visibleProducts.map(async (product: any, index: number) => {
312
+ const imageUrl = getProductImageUrl(product);
313
+ if (!imageUrl) {
314
+ return { rank: index + 1, base64: null };
315
+ }
316
+
317
+ const base64 = await fetchProductImageBase64(imageUrl);
318
+ return { rank: index + 1, base64 };
319
+ }),
320
+ );
321
+
322
+ const imagesAvailable = productImages.filter(
323
+ image => image.base64 !== null,
324
+ ).length;
325
+ const hasVisualContext = imagesAvailable >= 3;
326
+
327
+ const productContextNotice = hasVisualContext
328
+ ? `VISIBLE PRODUCT IMAGES: ${imagesAvailable} result thumbnails are included below. Use them with the metadata to compare the uploaded image against the current results.`
329
+ : `VISIBLE PRODUCT IMAGES: NOT AVAILABLE. Do not rely on visual attributes from the current result set; use metadata only when deciding whether the uploaded image matches these products.`;
330
+
331
+ const parts: {
332
+ inlineData?: { mimeType: string; data: string };
333
+ text?: string;
334
+ }[] = [];
335
+ if (origBase64) {
336
+ parts.push({
337
+ text: 'request_image:',
338
+ inlineData: { mimeType: 'image/jpeg', data: origBase64 },
339
+ });
340
+ }
341
+ parts.push({
342
+ text: 'new_uploaded_image:',
343
+ inlineData: { mimeType: 'image/jpeg', data: newBase64 },
344
+ });
345
+
346
+ parts.push({
347
+ text: `CURRENT RESULTS CONTEXT:
348
+
349
+ ${productContextNotice}
350
+
351
+ PRODUCT LIST:
352
+ Each result includes a unique SKU in brackets. Use the result thumbnails and metadata together when deciding which current results match the uploaded image. Return matched_indices as 1-based ranks from this list.
353
+ `,
354
+ });
355
+
356
+ productList.forEach((product, index) => {
357
+ const currentProduct = visibleProducts[index];
358
+ const tags = currentProduct?._tags;
359
+ const tagStr = tags
360
+ ? ` | TAGS: type=${tags.productType}, color=${tags.color}, shape=${tags.shape}, material=${tags.material}`
361
+ : '';
362
+ const metadata = `${product.rank}. [${product.sku}] ${product.title} | brand: ${product.brand} | mpn: ${product.mpn} | categories: ${JSON.stringify(product.categories)} | desc: ${product.description}${tagStr}`;
363
+
364
+ if (hasVisualContext && productImages[index]?.base64) {
365
+ parts.push({
366
+ text: metadata,
367
+ inlineData: {
368
+ mimeType: 'image/jpeg',
369
+ data: productImages[index].base64 || '',
370
+ },
371
+ });
372
+ return;
373
+ }
374
+
375
+ parts.push({ text: metadata });
376
+ });
377
+
378
+ const response = await chatAnalysis({
379
+ promptKey: 'image_refinement',
380
+ multimodalInputs: parts.map(p =>
381
+ p.inlineData
382
+ ? {
383
+ metadata: p.text || '',
384
+ mimeType: p.inlineData.mimeType,
385
+ image: p.inlineData.data,
386
+ }
387
+ : { metadata: p.text || '' },
388
+ ),
389
+ });
390
+
391
+ const parsed = response;
392
+ const matchedIndices: number[] = parsed.matched_indices || [];
393
+ const aiMessage = parsed.message?.trim() || '';
394
+
395
+ const setProducts = getSetProducts();
396
+
397
+ if (matchedIndices.length > 0) {
398
+ const filteredProducts = matchedIndices
399
+ .filter(i => i >= 1 && i <= currentProducts.length)
400
+ .map(i => currentProducts[i - 1]);
401
+
402
+ if (filteredProducts.length > 0) {
403
+ setProducts(filteredProducts);
404
+ store.addMessage({
405
+ role: 'assistant',
406
+ content: `${aiMessage}\n\nI found ${filteredProducts.length} matching product${filteredProducts.length === 1 ? '' : 's'} from the current results. Want me to search the full catalog for more options?`,
407
+ options: [
408
+ {
409
+ label: 'Search catalog with this image',
410
+ action_type: 'catalog_search_image' as const,
411
+ key: 'catalog_search',
412
+ value: 'image',
413
+ },
414
+ {
415
+ label: 'Go back to original results',
416
+ action_type: 'restore_original' as const,
417
+ key: 'restore',
418
+ value: 'restore',
419
+ },
420
+ ],
421
+ });
422
+ } else {
423
+ store.addMessage({
424
+ role: 'assistant',
425
+ content:
426
+ aiMessage ||
427
+ "I couldn't find those items in the current results. Let me search the full catalog for you.",
428
+ options: [
429
+ {
430
+ label: 'Search catalog with this image',
431
+ action_type: 'catalog_search_image' as const,
432
+ key: 'catalog_search',
433
+ value: 'image',
434
+ },
435
+ ],
436
+ });
437
+ }
438
+ } else {
439
+ store.addMessage({
440
+ role: 'assistant',
441
+ content:
442
+ aiMessage ||
443
+ "This doesn't match the current results. Would you like to search the full catalog?",
444
+ options: [
445
+ {
446
+ label: 'Search catalog with this image',
447
+ action_type: 'catalog_search_image' as const,
448
+ key: 'catalog_search',
449
+ value: 'image',
450
+ },
451
+ ],
452
+ });
453
+ }
454
+ } catch (err: any) {
455
+ console.error('[ChatAssistant] Image refinement failed:', err);
456
+ store.addMessage({
457
+ role: 'assistant',
458
+ content:
459
+ 'I had trouble analyzing that image. You can try searching the catalog directly instead.',
460
+ options: [
461
+ {
462
+ label: 'Search catalog with this image',
463
+ action_type: 'catalog_search_image' as const,
464
+ key: 'catalog_search',
465
+ value: 'image',
466
+ },
467
+ ],
468
+ });
469
+ } finally {
470
+ store.setIsLoading(false);
471
+ }
472
+ },
473
+ [activeProducts, requestImages, getSetProducts],
474
+ );
475
+
476
+ const handleCatalogSearchWithImage = useCallback(async () => {
477
+ const store = useConversationStore.getState();
478
+ const file = store.pendingCatalogImage;
479
+ if (!file) return;
480
+
481
+ store.setIsLoading(true);
482
+ setCatalogSearching(true);
483
+ try {
484
+ const settings = (window as any).settings;
485
+ const nyrisKey = settings?.apiKey || settings?.nyrisApiKey;
486
+ const baseUrl = settings?.baseUrl || 'https://api.nyris.io';
487
+
488
+ store.setPreCatalogResults([...activeProducts]);
489
+
490
+ const formData = new FormData();
491
+ if (typeof file === 'string') {
492
+ const base64Data = file.includes(',') ? file.split(',')[1] : file;
493
+ const mimeMatch = file.match(/^data:([^;]+);base64,/);
494
+ const mimeType = mimeMatch ? mimeMatch[1] : 'image/jpeg';
495
+ const byteChars = atob(base64Data);
496
+ const byteArray = new Uint8Array(byteChars.length);
497
+ for (let i = 0; i < byteChars.length; i++) {
498
+ byteArray[i] = byteChars.charCodeAt(i);
499
+ }
500
+ formData.append(
501
+ 'image',
502
+ new Blob([byteArray], { type: mimeType }),
503
+ 'image.jpg',
504
+ );
505
+ } else {
506
+ formData.append('image', file);
507
+ }
508
+ const res = await fetch(`${baseUrl}/find/v1.1`, {
509
+ method: 'POST',
510
+ headers: {
511
+ 'X-Api-Key': nyrisKey,
512
+ Accept: 'application/offers.complete+json',
513
+ },
514
+ body: formData,
515
+ });
516
+ const data = await res.json();
517
+ const results = data?.results || [];
518
+
519
+ const setProducts = getSetProducts();
520
+
521
+ if (results.length > 0) {
522
+ setProducts(results);
523
+ setFullResultPool(results);
524
+ store.setIsLoading(false);
525
+ await sendP3Message(
526
+ `I searched the catalog with an image and got ${results.length} results. What filters can I use to narrow them down? Suggest filter options like brand, voltage, type, or other attributes.`,
527
+ { silent: true },
528
+ );
529
+ } else {
530
+ const prev = store.preCatalogResults;
531
+ if (prev) {
532
+ setProducts(prev);
533
+ setFullResultPool(prev);
534
+ }
535
+ store.setPreCatalogResults(null);
536
+ store.addMessage({
537
+ role: 'assistant',
538
+ content:
539
+ "I couldn't find any matches for that image in the catalog. No worries — I've brought back your previous results.",
540
+ });
541
+ }
542
+ } catch (err: any) {
543
+ console.error('[ChatAssistant] Catalog image search failed:', err);
544
+ const prev = store.preCatalogResults;
545
+ if (prev) {
546
+ const setProducts = getSetProducts();
547
+ setProducts(prev);
548
+ setFullResultPool(prev);
549
+ }
550
+ store.setPreCatalogResults(null);
551
+ store.addMessage({
552
+ role: 'assistant',
553
+ content:
554
+ "Something went wrong with the catalog search. Don't worry — your previous results are still here.",
555
+ });
556
+ } finally {
557
+ store.setPendingCatalogImage(null);
558
+ store.setIsLoading(false);
559
+ setCatalogSearching(false);
560
+ }
561
+ }, [activeProducts, getSetProducts, setFullResultPool, sendP3Message]);
562
+
563
+ const handleCatalogSearchWithText = useCallback(async () => {
564
+ const store = useConversationStore.getState();
565
+ const lastUserMsg = [...store.messages]
566
+ .reverse()
567
+ .find(m => m.role === 'user');
568
+ const rawText = lastUserMsg?.content || '';
569
+ if (!rawText) {
570
+ store.addMessage({
571
+ role: 'assistant',
572
+ content: 'Please type a query first so I can search the catalog.',
573
+ });
574
+ return;
575
+ }
576
+
577
+ store.setIsLoading(true);
578
+ setCatalogSearchReady(false);
579
+ setCatalogSearching(true);
580
+
581
+ try {
582
+ const settings = (window as any).settings;
583
+ const nyrisKey = settings?.apiKey || settings?.nyrisApiKey;
584
+ const baseUrl = settings?.baseUrl || 'https://api.nyris.io';
585
+
586
+ let searchQuery = rawText;
587
+
588
+ const response = await chatAnalysis({
589
+ promptKey: 'keyword_extraction',
590
+ multimodalInputs: [
591
+ {
592
+ metadata: `user_message: "${rawText}"`,
593
+ },
594
+ ],
595
+ });
596
+
597
+ try {
598
+ const cleaned = response?.query.trim();
599
+ if (cleaned) {
600
+ searchQuery = cleaned.replace(/^["']|["']$/g, '');
601
+ }
602
+ } catch (cleanErr) {
603
+ console.warn(
604
+ '[ChatAssistant] Query cleaning failed, using raw text:',
605
+ cleanErr,
606
+ );
607
+ }
608
+
609
+ store.setPreCatalogResults([...activeProducts]);
610
+
611
+ const formData = new FormData();
612
+ formData.append('text', searchQuery);
613
+ const res = await fetch(`${baseUrl}/find/v1.1`, {
614
+ method: 'POST',
615
+ headers: {
616
+ 'X-Api-Key': nyrisKey,
617
+ Accept: 'application/offers.complete+json',
618
+ },
619
+ body: formData,
620
+ });
621
+ const data = await res.json();
622
+ const results = data?.results || [];
623
+
624
+ const setProducts = getSetProducts();
625
+
626
+ if (results.length > 0) {
627
+ setProducts(results);
628
+ setFullResultPool(results);
629
+ store.setIsLoading(false);
630
+ await sendP3Message(
631
+ `I searched the catalog for "${searchQuery}" and got these ${results.length} results. What filters can I use to narrow them down? Suggest filter options like brand, voltage, type, or other attributes.`,
632
+ { silent: true },
633
+ );
634
+ } else {
635
+ const prev = store.preCatalogResults;
636
+ if (prev) {
637
+ setProducts(prev);
638
+ setFullResultPool(prev);
639
+ }
640
+ store.setPreCatalogResults(null);
641
+ store.addMessage({
642
+ role: 'assistant',
643
+ content: `I couldn't find anything matching "${searchQuery}" in the catalog. I've brought back your previous results.`,
644
+ });
645
+ }
646
+ } catch (err: unknown) {
647
+ console.error('[ChatAssistant] Catalog search failed:', err);
648
+ const prev = store.preCatalogResults;
649
+ if (prev) {
650
+ const setProducts = getSetProducts();
651
+ setProducts(prev);
652
+ setFullResultPool(prev);
653
+ }
654
+ store.setPreCatalogResults(null);
655
+ store.addMessage({
656
+ role: 'assistant',
657
+ content:
658
+ 'Something went wrong with the catalog search. Your previous results are still here.',
659
+ });
660
+ } finally {
661
+ store.setPendingCatalogText(null);
662
+ store.setIsLoading(false);
663
+ setCatalogSearching(false);
664
+ }
665
+ }, [
666
+ activeProducts,
667
+ getSetProducts,
668
+ setCatalogSearchReady,
669
+ setFullResultPool,
670
+ sendP3Message,
671
+ ]);
672
+
673
+ // Handle "Find related parts" — checks lookup table first, falls back to Gemini
674
+ const handleFindRelatedParts = async () => {
675
+ const store = useConversationStore.getState();
676
+ store.setIsLoading(true);
677
+ setFindingRelatedParts(true);
678
+
679
+ try {
680
+ // 1. Try the lookup table first (instant, no API call)
681
+ const table = await loadRelatedPartsTable();
682
+
683
+ const tableSuggestions = lookupRelatedParts(
684
+ table,
685
+ imageAnalysis.specification.product_category,
686
+ );
687
+
688
+ if (tableSuggestions && tableSuggestions.length > 0) {
689
+ const options = tableSuggestions.map(s => ({
690
+ label: s.label,
691
+ action_type: 'related_part' as const,
692
+ value: s.label,
693
+ key: `related_${s.label.toLowerCase().replace(/\s+/g, '_')}`,
694
+ }));
695
+ store.addMessage({
696
+ role: 'assistant',
697
+ content: `Here are some parts and accessories commonly associated with this product. Select one to search the catalog:\n\n${tableSuggestions.map(s => `• **${s.label}** — ${s.description}`).join('\n')}`,
698
+ options,
699
+ });
700
+ return;
701
+ }
702
+ } catch (err) {
703
+ console.error('[RelatedParts] Failed:', err);
704
+ store.addMessage({
705
+ role: 'assistant',
706
+ content:
707
+ 'Something went wrong while looking up related parts. Please try again.',
708
+ });
709
+ } finally {
710
+ store.setIsLoading(false);
711
+ setFindingRelatedParts(false);
712
+ }
713
+ };
714
+
715
+ return {
716
+ // State
717
+ inputValue,
718
+ setInputValue,
719
+ messages,
720
+ isLoading,
721
+ isEvaluated,
722
+ error,
723
+ hasApiKey,
724
+ route,
725
+ p7Running,
726
+ p7Done,
727
+ catalogSearchReady,
728
+ catalogSearching,
729
+ activeProducts,
730
+ productsFromAlgolia,
731
+ findingRelatedParts,
732
+
733
+ // Refs
734
+ messagesEndRef,
735
+ imageInputRef,
736
+
737
+ // Handlers
738
+ handleSendMessage,
739
+ handleOptionClick,
740
+ handleImageUpload,
741
+ handleCatalogSearchWithImage,
742
+ handleCatalogSearchWithText,
743
+ handleFindRelatedParts,
744
+ };
745
+ }