@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.
- package/build/asset-manifest.json +6 -6
- package/build/data/related-parts.json +83 -0
- package/build/index.html +1 -1
- package/build/js/settings.example.js +3 -0
- package/build/static/css/main.5ea01690.css +4 -0
- package/build/static/css/main.5ea01690.css.map +1 -0
- package/build/static/js/main.36b77705.js +3 -0
- package/build/static/js/{main.cede3ae1.js.map → main.36b77705.js.map} +1 -1
- package/package.json +4 -3
- package/public/data/related-parts.json +83 -0
- package/public/js/settings.example.js +3 -0
- package/src/App.test.tsx +0 -1
- package/src/App.tsx +0 -1
- package/src/assets/arrow_down_expanded.svg +3 -0
- package/src/assets/arrow_enter.svg +3 -0
- package/src/assets/camera.svg +3 -0
- package/src/assets/close.svg +3 -0
- package/src/assets/enter.svg +3 -0
- package/src/assets/refresh.svg +3 -0
- package/src/assets/vizo_avatar.svg +16 -0
- package/src/components/Cadenas/CadenasWebViewer.tsx +1 -1
- package/src/components/Cart.tsx +48 -36
- package/src/components/ChatAssistant/ChatAssistant.tsx +289 -0
- package/src/components/ChatAssistant/MobileChatAssistant.tsx +291 -0
- package/src/components/ChatAssistant/OptionChip.tsx +78 -0
- package/src/components/ChatAssistant/index.ts +3 -0
- package/src/components/ChatAssistant/useChatAssistantLogic.ts +745 -0
- package/src/components/CurrentRefinements.tsx +2 -2
- package/src/components/CustomCameraDrawer.tsx +56 -13
- package/src/components/DragDropFile.tsx +5 -5
- package/src/components/ExperienceVisualSearch/ExperienceVisualSearch.tsx +1 -1
- package/src/components/Header.tsx +116 -96
- package/src/components/Hint.tsx +1 -2
- package/src/components/HitsPerPage.tsx +9 -3
- package/src/components/ImagePreview.tsx +32 -17
- package/src/components/ImageUpload.tsx +16 -8
- package/src/components/Inquiry/InquiryBanner.tsx +1 -1
- package/src/components/Inquiry/InquiryModal.tsx +35 -29
- package/src/components/ItemSpecification.tsx +58 -126
- package/src/components/LocationInfoPopup.tsx +33 -33
- package/src/components/MatchNotificationBanner.tsx +90 -36
- package/src/components/PostFilter/PostFilter.tsx +1 -1
- package/src/components/PostFilter/PostFilterComponent.tsx +0 -1
- package/src/components/PostFilter/PostFilterFindApi.tsx +0 -1
- package/src/components/PoweredBy.tsx +1 -1
- package/src/components/PreFilter/PreFilter.tsx +14 -3
- package/src/components/PreFilter/PreFilterModal.tsx +0 -1
- package/src/components/Product/Product.tsx +15 -11
- package/src/components/Product/ProductAttribute.tsx +4 -5
- package/src/components/Product/ProductDetailViewModal.tsx +2 -4
- package/src/components/Product/ProductList.tsx +26 -13
- package/src/components/Rfq/RfqModal.tsx +1 -1
- package/src/components/SidePanel.tsx +124 -91
- package/src/components/SmartFilter.tsx +320 -0
- package/src/components/TextSearch.tsx +134 -70
- package/src/components/UploadDisclaimer.tsx +1 -1
- package/src/hooks/useBadResultsRecovery.ts +407 -0
- package/src/hooks/useEffectiveGroundingResults.ts +54 -0
- package/src/hooks/useGoodResultsChat.ts +651 -0
- package/src/hooks/useGroundedSearch.ts +88 -0
- package/src/hooks/useImageSearch.ts +139 -187
- package/src/hooks/useResultEvaluator.ts +417 -0
- package/src/index.css +1 -1
- package/src/index.tsx +0 -1
- package/src/layouts/AppLayout.tsx +53 -2
- package/src/pages/Home.tsx +11 -52
- package/src/pages/Login.tsx +1 -2
- package/src/pages/Logout.tsx +1 -1
- package/src/pages/Result.tsx +198 -200
- package/src/providers/AuthProvider.tsx +0 -1
- package/src/services/Feedback.ts +1 -1
- package/src/services/visualSearch.ts +0 -21
- package/src/services/vizo.ts +192 -4
- package/src/stores/chat/chatStore.ts +150 -0
- package/src/stores/chat/conversationStore.ts +300 -0
- package/src/stores/request/Misc/misc.slice.ts +2 -2
- package/src/stores/request/filter/filter.slice.ts +8 -8
- package/src/stores/request/query/query.slice.ts +2 -2
- package/src/stores/request/requestImage/requestImage.slice.ts +6 -6
- package/src/stores/request/specifications/specifications.slice.ts +10 -7
- package/src/stores/result/detectedRegions/detectedRegions.slice.ts +1 -1
- package/src/stores/result/prodcuts/products.initialState.ts +12 -0
- package/src/stores/result/prodcuts/products.slice.ts +28 -8
- package/src/stores/result/session/session.slice.ts +2 -2
- package/src/stores/smartFilters/smartFiltersStore.ts +270 -0
- package/src/stores/types.ts +41 -0
- package/src/stores/ui/ai/ai.initialState.ts +5 -0
- package/src/stores/ui/ai/ai.slice.ts +15 -0
- package/src/stores/ui/banner/banner.initialState.ts +6 -0
- package/src/stores/ui/banner/banner.slice.ts +14 -0
- package/src/stores/ui/feedback/feedback.slice.ts +1 -1
- package/src/stores/ui/loading/loading.slice.ts +4 -4
- package/src/stores/ui/uiStore.ts +7 -1
- package/src/styles/product.scss +0 -2
- package/src/types.ts +3 -7
- package/src/utils/cropImageToBase64.ts +32 -0
- package/src/utils/fetchProductImage.ts +109 -0
- package/src/utils/imageConverters.ts +124 -0
- package/src/utils/relatedParts.ts +35 -0
- package/src/utils/specificationFilter.ts +1 -5
- package/tailwind.config.js +3 -2
- package/build/static/css/main.734b52e1.css +0 -4
- package/build/static/css/main.734b52e1.css.map +0 -1
- package/build/static/js/main.cede3ae1.js +0 -3
- package/src/utils/addAssets.ts +0 -40
- /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;
|