@nyris/nyris-webapp 0.3.99 → 0.4.1

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 (33) hide show
  1. package/build/asset-manifest.json +6 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +3 -1
  4. package/build/static/css/main.a7ff7a7f.css +4 -0
  5. package/build/static/css/main.a7ff7a7f.css.map +1 -0
  6. package/build/static/js/main.1fdb0ec6.js +3 -0
  7. package/build/static/js/{main.2339271b.js.map → main.1fdb0ec6.js.map} +1 -1
  8. package/package.json +3 -3
  9. package/public/js/settings.example.js +3 -1
  10. package/src/components/ChatAssistant/ChatAssistant.test.tsx +213 -0
  11. package/src/components/ChatAssistant/ChatAssistant.tsx +18 -1
  12. package/src/components/ChatAssistant/MobileChatAssistant.tsx +18 -1
  13. package/src/components/ChatAssistant/OptionChip.tsx +23 -1
  14. package/src/components/ChatAssistant/useChatAssistantLogic.ts +35 -14
  15. package/src/components/Inquiry/InquiryModal.tsx +58 -26
  16. package/src/components/PostFilter/PostFilterComponent.tsx +22 -8
  17. package/src/components/SidePanel.tsx +3 -4
  18. package/src/components/TextSearch.tsx +12 -4
  19. package/src/hooks/chatProductMetadata.test.ts +307 -0
  20. package/src/hooks/useBadResultsRecovery.ts +26 -49
  21. package/src/hooks/useGoodResultsChat.ts +20 -17
  22. package/src/hooks/useResultEvaluator.ts +21 -55
  23. package/src/services/Feedback.ts +5 -2
  24. package/src/stores/chat/conversationStore.test.ts +133 -0
  25. package/src/stores/chat/conversationStore.ts +55 -14
  26. package/src/types.ts +1 -0
  27. package/src/utils/chatProductMetadata.test.ts +130 -0
  28. package/src/utils/chatProductMetadata.ts +93 -0
  29. package/src/utils/compressImage.ts +3 -3
  30. package/build/static/css/main.5cb72c09.css +0 -4
  31. package/build/static/css/main.5cb72c09.css.map +0 -1
  32. package/build/static/js/main.2339271b.js +0 -3
  33. /package/build/static/js/{main.2339271b.js.LICENSE.txt → main.1fdb0ec6.js.LICENSE.txt} +0 -0
@@ -6,6 +6,7 @@ import {
6
6
  import React, { useCallback, useMemo, useState } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
8
  import { twMerge } from 'tailwind-merge';
9
+ import PostFilter from './PostFilter';
9
10
  import PostFilterFindApi from './PostFilterFindApi';
10
11
 
11
12
  function PostFilterComponent({ className }: { className?: string }) {
@@ -116,14 +117,27 @@ function PostFilterComponent({ className }: { className?: string }) {
116
117
  refinement.attribute;
117
118
 
118
119
  return (
119
- <PostFilterFindApi
120
- key={index}
121
- attribute={refinement.attribute}
122
- label={refinement.header}
123
- searchable={false}
124
- onVisibilityChange={handleVisibilityChange}
125
- isLastVisible={isLastVisible}
126
- />
120
+ <React.Fragment key={index}>
121
+ {window.settings.algolia.enabled && (
122
+ <PostFilter
123
+ attribute={refinement.attribute}
124
+ label={refinement.header}
125
+ searchable={refinement.searchable}
126
+ onVisibilityChange={handleVisibilityChange}
127
+ isLastVisible={isLastVisible}
128
+ />
129
+ )}
130
+
131
+ {!window.settings.algolia.enabled && (
132
+ <PostFilterFindApi
133
+ attribute={refinement.attribute}
134
+ label={refinement.header}
135
+ searchable={false}
136
+ onVisibilityChange={handleVisibilityChange}
137
+ isLastVisible={isLastVisible}
138
+ />
139
+ )}
140
+ </React.Fragment>
127
141
  );
128
142
  },
129
143
  )}
@@ -16,7 +16,8 @@ export default function SidePanel({ className }: { className?: string }) {
16
16
  const settings = window.settings;
17
17
  const isSmartFiltersEnabled = !!settings.useSmartFilters;
18
18
  const requestImages = useRequestStore(state => state.requestImages);
19
- const [isRequestQualityExpanded, setIsRequestQualityExpanded] = useState(true);
19
+ const [isRequestQualityExpanded, setIsRequestQualityExpanded] =
20
+ useState(true);
20
21
 
21
22
  const specificationFilter = useRequestStore(
22
23
  state => state.specificationFilter,
@@ -181,9 +182,7 @@ export default function SidePanel({ className }: { className?: string }) {
181
182
  <button
182
183
  type="button"
183
184
  className="w-full flex items-center justify-between gap-2"
184
- onClick={() =>
185
- setIsRequestQualityExpanded(prev => !prev)
186
- }
185
+ onClick={() => setIsRequestQualityExpanded(prev => !prev)}
187
186
  >
188
187
  <span className="text-[#3B3E5F] font-bold text-sm leading-4">
189
188
  Request Image Quality
@@ -23,6 +23,8 @@ function machinePartsListingText() {
23
23
  return firstMachinePartsListingText();
24
24
  }
25
25
 
26
+ const TEXT_SEARCH_DEBOUNCE = 1500;
27
+
26
28
  function TextSearch({
27
29
  className,
28
30
  onCameraClick,
@@ -137,7 +139,7 @@ function TextSearch({
137
139
  }
138
140
 
139
141
  navigate('/result');
140
- }, 350),
142
+ }, TEXT_SEARCH_DEBOUNCE),
141
143
  [requestImages, preFilter, regions],
142
144
  );
143
145
 
@@ -226,9 +228,7 @@ function TextSearch({
226
228
  }
227
229
  >
228
230
  {isScopeMode && (
229
- <TypePlateUpload
230
- iconOnly={location.pathname === '/result'}
231
- />
231
+ <TypePlateUpload iconOnly={location.pathname === '/result'} />
232
232
  )}
233
233
  {showPreFilter && (
234
234
  <Tooltip
@@ -444,6 +444,14 @@ function TextSearch({
444
444
  placeholder={t('Search part names, SKU, or description...')}
445
445
  value={valueInput}
446
446
  onChange={onChangeText}
447
+ onKeyDown={event => {
448
+ if (event.key === 'Enter') {
449
+ event.preventDefault();
450
+ searchOrRedirect.cancel();
451
+ searchOrRedirect(event.currentTarget.value);
452
+ searchOrRedirect.flush();
453
+ }
454
+ }}
447
455
  onFocus={() => setIsInputFocused(true)}
448
456
  onBlur={() => setIsInputFocused(false)}
449
457
  ref={focusInp}
@@ -0,0 +1,307 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { chatAnalysis } from 'services/vizo';
3
+ import useConversationStore from 'stores/chat/conversationStore';
4
+ import { compressImage } from 'utils/compressImage';
5
+ import {
6
+ fetchProductImageBase64,
7
+ getProductImageUrl,
8
+ } from 'utils/fetchProductImage';
9
+ import { loadRelatedPartsTable } from 'utils/relatedParts';
10
+ import { useChatAssistantLogic } from 'components/ChatAssistant/useChatAssistantLogic';
11
+ import { useBadResultsRecovery } from './useBadResultsRecovery';
12
+ import { useGoodResultsChat } from './useGoodResultsChat';
13
+ import { useResultEvaluator } from './useResultEvaluator';
14
+
15
+ const mockProduct = {
16
+ sku: 'pump-1',
17
+ title: 'Pump',
18
+ description: 'Complete technical specification. '.repeat(20),
19
+ customIds: { mpn: 'MPN-1', gtin: '123456' },
20
+ attributes: { pressure: 0, available: false, material: 'steel' },
21
+ metadata: { customerAttribute: 'custom-value' },
22
+ categories: ['Industrial', 'Pumps'],
23
+ _tags: { productType: 'pump', color: 'blue', shape: 'round' },
24
+ url: 'https://example.com/product',
25
+ imageUrl: 'https://example.com/image.jpg',
26
+ images: [{ url: 'https://example.com/image.jpg', width: 300 }],
27
+ };
28
+ const mockResultState = {
29
+ productsFromFindApi: [mockProduct],
30
+ productsFromAlgolia: [] as typeof mockProduct[],
31
+ firstSearchResults: [mockProduct],
32
+ fullResultPool: [mockProduct],
33
+ imageAnalysis: {},
34
+ setFindApiProducts: jest.fn(),
35
+ setFullResultPool: jest.fn(),
36
+ setImageAnalysis: jest.fn(),
37
+ };
38
+ const mockRequestState = { requestImages: [], regions: [] };
39
+
40
+ jest.mock('stores/result/resultStore', () => ({
41
+ __esModule: true,
42
+ default: Object.assign(
43
+ (selector: (state: typeof mockResultState) => unknown) =>
44
+ selector(mockResultState),
45
+ { getState: () => mockResultState },
46
+ ),
47
+ }));
48
+ jest.mock('stores/request/requestStore', () => ({
49
+ __esModule: true,
50
+ default: Object.assign(
51
+ (selector: (state: typeof mockRequestState) => unknown) =>
52
+ selector(mockRequestState),
53
+ { getState: () => mockRequestState },
54
+ ),
55
+ }));
56
+ jest.mock('stores/smartFilters/smartFiltersStore', () => ({
57
+ __esModule: true,
58
+ default: () => ({ filters: [] }),
59
+ }));
60
+ jest.mock('services/vizo', () => ({ chatAnalysis: jest.fn() }));
61
+ jest.mock('utils/compressImage', () => ({ compressImage: jest.fn() }));
62
+ jest.mock('utils/fetchProductImage', () => ({
63
+ fetchProductImageBase64: jest.fn(),
64
+ getProductImageUrl: jest.fn(),
65
+ }));
66
+ jest.mock('utils/relatedParts', () => ({
67
+ loadRelatedPartsTable: jest.fn(),
68
+ lookupRelatedParts: jest.fn(),
69
+ }));
70
+ jest.mock('utils/nyrisApiKey', () => ({ getEffectiveApiKey: jest.fn() }));
71
+
72
+ const mockChatAnalysis = chatAnalysis as jest.MockedFunction<typeof chatAnalysis>;
73
+ const mockFetchImage = fetchProductImageBase64 as jest.MockedFunction<
74
+ typeof fetchProductImageBase64
75
+ >;
76
+ const mockGetImageUrl = getProductImageUrl as jest.MockedFunction<
77
+ typeof getProductImageUrl
78
+ >;
79
+ const mockLoadRelatedParts = loadRelatedPartsTable as jest.MockedFunction<
80
+ typeof loadRelatedPartsTable
81
+ >;
82
+ const mockCompressImage = compressImage as jest.MockedFunction<
83
+ typeof compressImage
84
+ >;
85
+
86
+ beforeEach(() => {
87
+ jest.clearAllMocks();
88
+ window.settings = {
89
+ algolia: { enabled: false },
90
+ vizo: { findRelatedParts: false },
91
+ } as typeof window.settings;
92
+ mockResultState.productsFromFindApi = [mockProduct];
93
+ mockResultState.productsFromAlgolia = [];
94
+ mockResultState.firstSearchResults = [mockProduct];
95
+ mockResultState.fullResultPool = [mockProduct];
96
+ useConversationStore.getState().reset();
97
+ mockGetImageUrl.mockReturnValue(mockProduct.imageUrl);
98
+ mockFetchImage.mockResolvedValue(null);
99
+ mockLoadRelatedParts.mockResolvedValue({});
100
+ mockCompressImage.mockResolvedValue('data:image/jpeg;base64,new-image');
101
+ mockChatAnalysis.mockResolvedValue({
102
+ quality: 'good',
103
+ route: 'P3',
104
+ opening_message: 'How can I help?',
105
+ message: 'How can I help?',
106
+ action: { type: 'none' },
107
+ options: [],
108
+ });
109
+ });
110
+
111
+ function expectCompleteMetadata(promptKey: string) {
112
+ expect(mockChatAnalysis).toHaveBeenCalledTimes(1);
113
+ const request = mockChatAnalysis.mock.calls[0][0];
114
+ expect(request.promptKey).toBe(promptKey);
115
+ const text = request.multimodalInputs.map(input => input.metadata).join('\n');
116
+ expect(text).toContain(JSON.stringify(mockProduct.description));
117
+ expect(text).toContain(JSON.stringify(mockProduct.customIds));
118
+ expect(text).toContain(JSON.stringify(mockProduct.attributes));
119
+ expect(text).toContain(JSON.stringify(mockProduct.metadata));
120
+ expect(text).toContain(JSON.stringify(mockProduct.categories));
121
+ expect(text).toContain(JSON.stringify(mockProduct._tags));
122
+ expect(text).toContain('"images":[{"width":300}]');
123
+ expect(text).not.toContain('https://');
124
+ expect(text).not.toContain('"imageUrl"');
125
+ expect(text).not.toContain('"url"');
126
+ expect(mockProduct.url).toBe('https://example.com/product');
127
+ return request;
128
+ }
129
+
130
+ function expectProductEnvelope(text: string, key: string, value: string | number) {
131
+ const product = JSON.parse(text.replace(/\. Result_images\.$/, ''));
132
+ expect(Object.keys(product).sort()).toEqual([key, 'metadata'].sort());
133
+ expect(product[key]).toBe(value);
134
+ expect(product.metadata.title).toBe(mockProduct.title);
135
+ expect(product.metadata.description).toBe(mockProduct.description);
136
+ }
137
+
138
+ test.each([
139
+ { phase: 'evaluation', limit: 30 },
140
+ { phase: 'recovery', limit: 100 },
141
+ ])('$phase counts curated traits within its result limit', async ({ phase, limit }) => {
142
+ const products = Array.from({ length: limit + 1 }, (_, index) => ({
143
+ ...mockProduct,
144
+ sku: `pump-${index}`,
145
+ title: `ACME pump 24V DC ${index}`,
146
+ description: `steel housing ${index}`,
147
+ material: { name: 'steel' },
148
+ brand: { name: 'ACME' },
149
+ voltage: ['24V DC'],
150
+ }));
151
+ mockResultState.productsFromFindApi = products;
152
+ const snapshot = JSON.stringify(products);
153
+ const { result } = renderHook(() => ({
154
+ evaluator: useResultEvaluator(),
155
+ recovery: useBadResultsRecovery(),
156
+ }));
157
+
158
+ await act(async () => {
159
+ if (phase === 'evaluation') {
160
+ await result.current.evaluator.evaluateResults({
161
+ imageAnalysis: {},
162
+ activeProducts: products,
163
+ query: 'pump',
164
+ });
165
+ } else {
166
+ await result.current.recovery.sendMessage('Find a pump');
167
+ }
168
+ });
169
+
170
+ expect(mockChatAnalysis).toHaveBeenCalledTimes(1);
171
+ const request = mockChatAnalysis.mock.calls[0][0];
172
+ const context = request.multimodalInputs[0].metadata;
173
+ const frequency = JSON.parse(
174
+ context.split(/COMMON ATTRIBUTES FOUND IN RESULTS[^\n]*\n/)[1].split('\n\n')[0],
175
+ );
176
+ expect(frequency).toEqual({
177
+ brand: { ACME: limit },
178
+ voltage: { '24V DC': limit },
179
+ product_type: { PUMP: limit },
180
+ material: { steel: limit },
181
+ });
182
+ const product = JSON.parse(
183
+ request.multimodalInputs[1].metadata.replace(/\. Result_images\.$/, ''),
184
+ );
185
+ expect(product.metadata.sku).toBe('pump-0');
186
+ expect(product.metadata.description).toBe('steel housing 0');
187
+ expect(product.metadata.attributes).toEqual(mockProduct.attributes);
188
+ expect(product.metadata.customIds).toEqual(mockProduct.customIds);
189
+ expect(product.metadata.brand).toEqual({ name: 'ACME' });
190
+ expect(product.metadata.voltage).toEqual(['24V DC']);
191
+ expect(product.metadata.material).toEqual({ name: 'steel' });
192
+ expect(Object.keys(product).sort()).toEqual(
193
+ [phase === 'evaluation' ? 'rank' : 'index', 'metadata'].sort(),
194
+ );
195
+ expect(JSON.stringify(products)).toBe(snapshot);
196
+ expect(useConversationStore.getState().error).toBeNull();
197
+ });
198
+
199
+ test.each([false, true])(
200
+ 'evaluation includes complete metadata even when thumbnails are available: %s',
201
+ async hasImages => {
202
+ mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
203
+ const { result } = renderHook(() => useResultEvaluator());
204
+ await act(async () => {
205
+ await result.current.evaluateResults({
206
+ imageAnalysis: {},
207
+ activeProducts: [mockProduct],
208
+ query: 'pump',
209
+ });
210
+ });
211
+ const request = expectCompleteMetadata('result_evaluator_prompt');
212
+ expectProductEnvelope(request.multimodalInputs[1].metadata, 'rank', 1);
213
+ expect(request.multimodalInputs[1].image).toBe(
214
+ hasImages ? 'product-image' : undefined,
215
+ );
216
+ expect(useConversationStore.getState().error).toBeNull();
217
+ },
218
+ );
219
+
220
+ test.each([
221
+ { silent: false, algolia: false, hasImages: false },
222
+ { silent: true, algolia: false, hasImages: true },
223
+ { silent: false, algolia: true, hasImages: true },
224
+ ])('chat sends full metadata for %j', async ({ silent, algolia, hasImages }) => {
225
+ window.settings.algolia.enabled = algolia;
226
+ if (algolia) {
227
+ mockResultState.productsFromAlgolia = [
228
+ mockProduct,
229
+ mockProduct,
230
+ mockProduct,
231
+ ];
232
+ mockResultState.fullResultPool = [];
233
+ } else {
234
+ mockResultState.fullResultPool = [mockProduct, mockProduct, mockProduct];
235
+ }
236
+ mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
237
+ const { result } = renderHook(() => useGoodResultsChat());
238
+ await act(async () => {
239
+ await result.current.sendMessage('Find a pump', { silent });
240
+ });
241
+ const request = expectCompleteMetadata('chat_assistant_prompt');
242
+ expectProductEnvelope(request.multimodalInputs[1].metadata, 'id', 'pump-1');
243
+ expect(request.multimodalInputs[1].image).toBe(
244
+ hasImages ? 'product-image' : undefined,
245
+ );
246
+ expect(useConversationStore.getState().error).toBeNull();
247
+ });
248
+
249
+ test.each([false, true])(
250
+ 'recovery includes all metadata for thumbnail and text-only results: %s',
251
+ async hasImages => {
252
+ mockResultState.productsFromFindApi = Array.from(
253
+ { length: 31 },
254
+ (_, index) => ({ ...mockProduct, sku: `pump-${index}` }),
255
+ );
256
+ mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
257
+ mockChatAnalysis.mockResolvedValue({
258
+ message: 'What pressure do you need?',
259
+ action: 'ask',
260
+ options: [],
261
+ });
262
+ const { result } = renderHook(() => useBadResultsRecovery());
263
+ await act(async () => {
264
+ await result.current.sendMessage('Find a pump');
265
+ });
266
+ const request = expectCompleteMetadata('recovery_prompt');
267
+ expectProductEnvelope(request.multimodalInputs[1].metadata, 'index', 0);
268
+ expect(request.multimodalInputs[1].image).toBe(
269
+ hasImages ? 'product-image' : undefined,
270
+ );
271
+ const remaining = request.multimodalInputs.find(input =>
272
+ input.metadata.startsWith('REMAINING RESULTS'),
273
+ );
274
+ expect(remaining?.metadata).toContain('"index":30');
275
+ expectProductEnvelope(remaining!.metadata.split('\n')[1], 'index', 30);
276
+ expect(remaining?.metadata).toContain(
277
+ JSON.stringify(mockProduct.description),
278
+ );
279
+ expect(remaining?.metadata).toContain(JSON.stringify(mockProduct.attributes));
280
+ expect(useConversationStore.getState().error).toBeNull();
281
+ },
282
+ );
283
+
284
+ test.each([false, true])(
285
+ 'image refinement sends complete metadata with thumbnails available: %s',
286
+ async hasImages => {
287
+ mockResultState.productsFromFindApi = [mockProduct, mockProduct, mockProduct];
288
+ mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
289
+ mockChatAnalysis.mockResolvedValue({
290
+ message: 'Found a pump',
291
+ matched_indices: [1],
292
+ });
293
+ const { result } = renderHook(() => useChatAssistantLogic());
294
+ await act(async () => {
295
+ await result.current.handleImageUpload('data:image/jpeg;base64,new-image');
296
+ });
297
+ const request = expectCompleteMetadata('image_refinement');
298
+ expectProductEnvelope(request.multimodalInputs[2].metadata, 'rank', 1);
299
+ expect(request.multimodalInputs[2].image).toBe(
300
+ hasImages ? 'product-image' : undefined,
301
+ );
302
+ expect(mockResultState.setFindApiProducts).toHaveBeenCalledWith([
303
+ mockProduct,
304
+ ]);
305
+ expect(useConversationStore.getState().error).toBeNull();
306
+ },
307
+ );
@@ -14,6 +14,11 @@ import useConversationStore, {
14
14
  import useSmartFiltersStore from 'stores/smartFilters/smartFiltersStore';
15
15
  import useResultStore from 'stores/result/resultStore';
16
16
  import { chatAnalysis } from 'services/vizo';
17
+ import {
18
+ getChatAttributeFrequency,
19
+ getChatProductAttributes,
20
+ getChatProductMetadata,
21
+ } from 'utils/chatProductMetadata';
17
22
  import {
18
23
  getProductImageUrl,
19
24
  fetchProductImageBase64,
@@ -98,29 +103,8 @@ export function useBadResultsRecovery() {
98
103
  const allResults = originalProducts
99
104
  .slice(0, 100)
100
105
  .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
- });
106
+ const fullMetadata = getChatProductMetadata(p);
107
+ const metadata = getChatProductAttributes(fullMetadata);
124
108
 
125
109
  // Extract from title/description using common patterns
126
110
  const title = p.title || p.name || '';
@@ -172,26 +156,15 @@ export function useBadResultsRecovery() {
172
156
 
173
157
  return {
174
158
  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,
159
+ metadata: fullMetadata,
160
+ attributes: metadata,
180
161
  };
181
162
  });
182
163
 
183
164
  // 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
- });
165
+ const attributeFrequency = getChatAttributeFrequency(
166
+ allResults.map(result => ({ metadata: result.attributes })),
167
+ );
195
168
 
196
169
  // Build conversation history
197
170
  const conversationHistory = messages.map(m => ({
@@ -255,8 +228,9 @@ PRODUCT LIST (first 30 with thumbnails where available, indices 0-based for filt
255
228
  // Part 2: First 30 results interleaved with thumbnails
256
229
  allResults
257
230
  .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)}` : ''}`;
231
+ .forEach(({ index, metadata }, i: number) => {
232
+ const r = { index, metadata };
233
+ const metaLine = JSON.stringify(getChatProductMetadata(r));
260
234
 
261
235
  if (hasVisualContext && imageResults[i]?.base64) {
262
236
  multimodalParts.push({
@@ -276,8 +250,8 @@ PRODUCT LIST (first 30 with thumbnails where available, indices 0-based for filt
276
250
  ${allResults
277
251
  .slice(30)
278
252
  .map(
279
- (r: (typeof allResults)[number]) =>
280
- `index: ${r.index} | sku: ${r.sku} | ${r.title}${r.metadata ? ` | ${JSON.stringify(r.metadata)}` : ''}`,
253
+ ({ index, metadata }) =>
254
+ JSON.stringify(getChatProductMetadata({ index, metadata })),
281
255
  )
282
256
  .join('\n')}`,
283
257
  });
@@ -360,12 +334,15 @@ Based on the user's answer and the failure mode strategy, decide whether to ask
360
334
  }
361
335
 
362
336
  // Add assistant message
363
- addMessage({
364
- role: 'assistant',
365
- content: p5Response.message,
366
- action: { type: p5Response.action },
367
- options: options,
368
- });
337
+ addMessage(
338
+ {
339
+ role: 'assistant',
340
+ content: p5Response.message,
341
+ action: { type: p5Response.action },
342
+ options: options,
343
+ },
344
+ { isApiResponse: true },
345
+ );
369
346
  } catch (err: any) {
370
347
  console.error('[P5] Error:', err);
371
348
  setError(err.message || 'Failed to process message');
@@ -12,6 +12,7 @@ import useSmartFiltersStore from 'stores/smartFilters/smartFiltersStore';
12
12
  import useResultStore from 'stores/result/resultStore';
13
13
  import useRequestStore from 'stores/request/requestStore';
14
14
  import { chatAnalysis } from 'services/vizo';
15
+ import { getChatProductMetadata } from 'utils/chatProductMetadata';
15
16
  import {
16
17
  fetchProductImageBase64,
17
18
  getProductImageUrl,
@@ -234,7 +235,7 @@ export function useGoodResultsChat() {
234
235
  id: p.sku || p.objectID || `product-${i}`,
235
236
  title: title || 'Untitled',
236
237
  brand: p.brand || '',
237
- description: (p.description || '').substring(0, 200),
238
+ description: p.description || '',
238
239
  mpn: p.customIds?.mpn || p.ids?.mpn || '',
239
240
  };
240
241
  });
@@ -267,7 +268,7 @@ export function useGoodResultsChat() {
267
268
  content: m.content,
268
269
  }));
269
270
 
270
- // Fetch product thumbnail images with caching — send images for ALL results (up to 50)
271
+ // Fetch product thumbnail images with caching — send images for ALL results (up to 30)
271
272
  // Cache persists across filter operations; only clears on new search session
272
273
  const productsForImages = poolForAI.slice(0, 30);
273
274
  const currentSessionId = useConversationStore.getState().sessionId;
@@ -317,19 +318,18 @@ ${JSON.stringify(attributeFrequency, null, 2)}
317
318
 
318
319
  ${visualNotice}
319
320
 
320
- 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):
321
+ PRODUCT LIST (up to 30 results — each JSON entry has a unique id and complete product metadata. When present, metadata._tags contains AI-generated type/color/shape/material tags pre-analyzed from product images. Use these tags for filtering. Return matching id values in matched_ids array):
321
322
  `,
322
323
  });
323
324
 
324
- // Part 2: Product entries with interleaved images (for all results up to 50)
325
+ // Part 2: Product entries with interleaved images (for all results up to 30)
325
326
  visibleResults.forEach((r, i) => {
326
- // Include AI-generated tags if available (from useProductTagger)
327
- const originalProduct = poolForAI[i];
328
- const tags = originalProduct?._tags;
329
- const tagStr = tags
330
- ? ` | TAGS: type=${tags.productType}, color=${tags.color}, shape=${tags.shape}, material=${tags.material}`
331
- : '';
332
- const metaLine = `id: ${r.id} | ${r.title} | brand: ${r.brand} | mpn: ${r.mpn} | desc: ${r.description}${tagStr}`;
327
+ const metaLine = JSON.stringify(
328
+ getChatProductMetadata({
329
+ id: r.id,
330
+ metadata: getChatProductMetadata(poolForAI[i]),
331
+ }),
332
+ );
333
333
 
334
334
  // Include product image if available
335
335
  if (
@@ -587,12 +587,15 @@ Respond to the user's message with an appropriate action and follow-up options.`
587
587
  }
588
588
 
589
589
  // Add assistant message
590
- addMessage({
591
- role: 'assistant',
592
- content: finalMessage,
593
- action: p3Response.action,
594
- options: finalOptions,
595
- });
590
+ addMessage(
591
+ {
592
+ role: 'assistant',
593
+ content: finalMessage,
594
+ action: p3Response.action,
595
+ options: finalOptions,
596
+ },
597
+ { isApiResponse: true },
598
+ );
596
599
  } catch (err: any) {
597
600
  console.error('[P3] Error:', err);
598
601
  setError(err.message || 'Failed to process message');