@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
@@ -14,6 +14,11 @@ import useConversationStore, {
14
14
  } from 'stores/chat/conversationStore';
15
15
  import useSmartFiltersStore from 'stores/smartFilters/smartFiltersStore';
16
16
  import { chatAnalysis } from 'services/vizo';
17
+ import {
18
+ getChatAttributeFrequency,
19
+ getChatProductAttributes,
20
+ getChatProductMetadata,
21
+ } from 'utils/chatProductMetadata';
17
22
  import useRequestStore from 'stores/request/requestStore';
18
23
  import { canvasToBase64, cropImage } from 'utils/cropImageToBase64';
19
24
  import {
@@ -74,29 +79,8 @@ export function useResultEvaluator() {
74
79
  const resultsSummary = activeProducts
75
80
  .slice(0, 30)
76
81
  .map((p: any, i: number) => {
77
- // Extract all potentially useful metadata
78
- const metadata: Record<string, any> = {};
79
- const metadataFields = [
80
- 'voltage',
81
- 'size',
82
- 'brand',
83
- 'manufacturer',
84
- 'material',
85
- 'type',
86
- 'category',
87
- 'color',
88
- 'dimensions',
89
- 'power',
90
- 'current',
91
- 'connector',
92
- 'model',
93
- 'series',
94
- 'product_line',
95
- ];
96
-
97
- metadataFields.forEach(field => {
98
- if (p[field]) metadata[field] = p[field];
99
- });
82
+ const fullMetadata = getChatProductMetadata(p);
83
+ const metadata = getChatProductAttributes(fullMetadata);
100
84
 
101
85
  // Extract from title/description using common patterns
102
86
  const title = p.title || p.name || '';
@@ -181,11 +165,8 @@ export function useResultEvaluator() {
181
165
 
182
166
  return {
183
167
  rank: i + 1,
184
- title: title || 'Untitled',
185
- description: (p.description || '').substring(0, 100),
186
- category: p.category || p.type || 'Unknown',
187
- score: p.score || p.similarity || 0,
188
- metadata: Object.keys(metadata).length > 0 ? metadata : null,
168
+ metadata: fullMetadata,
169
+ attributes: metadata,
189
170
  };
190
171
  });
191
172
 
@@ -203,17 +184,9 @@ export function useResultEvaluator() {
203
184
  });
204
185
 
205
186
  // Analyze common attributes across results for filter suggestions
206
- const attributeFrequency: Record<string, Record<string, number>> = {};
207
- resultsSummary.forEach(r => {
208
- if (r.metadata) {
209
- Object.entries(r.metadata).forEach(([key, value]) => {
210
- if (!attributeFrequency[key]) attributeFrequency[key] = {};
211
- const strValue = String(value);
212
- attributeFrequency[key][strValue] =
213
- (attributeFrequency[key][strValue] || 0) + 1;
214
- });
215
- }
216
- });
187
+ const attributeFrequency = getChatAttributeFrequency(
188
+ resultsSummary.map(result => ({ metadata: result.attributes })),
189
+ );
217
190
 
218
191
  const requestImage = useRequestStore.getState().requestImages;
219
192
  const regions = useRequestStore.getState().regions;
@@ -232,20 +205,16 @@ IMAGE DESCRIPTION: ${hasRequestImage ? imageDescription : 'N/A'}
232
205
  SPECIFICATION (from image analysis):
233
206
  ${hasRequestImage ? JSON.stringify(p4Specification, null, 2) : 'N/A'}
234
207
 
235
- TOP 20 RESULTS WITH METADATA:
236
- ${resultsSummary
237
- .map(
238
- r =>
239
- `${r.rank}. ${r.title}${r.description ? ` - ${r.description}` : ''} (Score: ${r.score})${r.metadata ? `\n Metadata: ${JSON.stringify(r.metadata)}` : ''}`,
240
- )
241
- .join('\n')}
242
208
 
243
209
  COMMON ATTRIBUTES FOUND IN RESULTS (for filter suggestions):
244
210
  ${JSON.stringify(attributeFrequency, null, 2)}
245
211
 
246
212
  TOTAL RESULTS: ${resultsSummary.length}
247
213
 
248
- Evaluate if these results match ${evaluationTarget}. If they do (same type of product), route to P3 and suggest filter options based on the COMMON ATTRIBUTES found above (like voltage, size, brand). Do NOT suggest the obvious category match.`;
214
+ Evaluate if these results match ${evaluationTarget}. If they do (same type of product), route to P3 and suggest filter options based on the COMMON ATTRIBUTES found above (like voltage, size, brand). Do NOT suggest the obvious category match.
215
+
216
+ ${hasRequestImage ? 'The request image is available, use it for evaluation.' : 'The request image is not available, rely solely on the user query for evaluation. Rest of the image information is only result/response images.'}.
217
+ `;
249
218
 
250
219
  const productsForImages = activeProducts.slice(0, 30);
251
220
  const imageResults = await Promise.all(
@@ -268,14 +237,9 @@ Evaluate if these results match ${evaluationTarget}. If they do (same type of pr
268
237
  mimeType?: string;
269
238
  }> = [];
270
239
 
271
- resultsSummary.forEach((result, i) => {
272
- const originalProduct = productsForImages[i];
273
- const tags = originalProduct?._tags;
274
- const tagStr = tags
275
- ? ` | TAGS: type=${tags.productType}, color=${tags.color}, shape=${tags.shape}, material=${tags.material}`
276
- : '';
277
-
278
- const metaLine = `${result.rank}. ${result.title}${result.description ? ` - ${result.description}` : ''} (Score: ${result.score})${result.metadata ? ` | Metadata: ${JSON.stringify(result.metadata)}` : ''}${tagStr}`;
240
+ resultsSummary.forEach(({ rank, metadata }, i) => {
241
+ const result = { rank, metadata };
242
+ const metaLine = `${JSON.stringify(getChatProductMetadata(result))}. Result_images.`;
279
243
 
280
244
  if (hasVisualContext && imageResults[i]?.base64) {
281
245
  productImageParts.push({
@@ -283,6 +247,8 @@ Evaluate if these results match ${evaluationTarget}. If they do (same type of pr
283
247
  image: imageResults[i].base64 || '',
284
248
  mimeType: 'image/jpeg',
285
249
  });
250
+ } else {
251
+ productImageParts.push({ metadata: metaLine });
286
252
  }
287
253
  });
288
254
 
@@ -6,6 +6,7 @@ import NyrisAPI, {
6
6
  } from '@nyris/nyris-api';
7
7
  import { getNyrisSettings } from 'utils/nyrisApiKey';
8
8
  import useResultStore from 'stores/result/resultStore';
9
+ import useConversationStore from 'stores/chat/conversationStore';
9
10
  import { CONVERSION_SUBTYPE, normalizeSubtype } from './feedbackSubtypes';
10
11
 
11
12
  export interface FeedbackIds {
@@ -164,8 +165,9 @@ export const trackResultFeedback = (
164
165
  success: boolean,
165
166
  comment?: string,
166
167
  ids?: FeedbackIds,
167
- ) =>
168
- trackFeedbackEvent(
168
+ ) => {
169
+ useConversationStore.getState().setSearchResolved(success);
170
+ return trackFeedbackEvent(
169
171
  {
170
172
  event: 'feedback',
171
173
  data: {
@@ -175,6 +177,7 @@ export const trackResultFeedback = (
175
177
  },
176
178
  ids,
177
179
  );
180
+ };
178
181
 
179
182
  /** `region` event; converts x1/y1/x2/y2 to the API's x/y/w/h rectangle. */
180
183
  export const trackRegion = (
@@ -0,0 +1,133 @@
1
+ import useConversationStore from './conversationStore';
2
+
3
+ beforeEach(() => {
4
+ window.settings = {
5
+ vizo: { requestSupport: true },
6
+ support: { emailInquiry: true, emailTemplateId: 'support-template' },
7
+ } as typeof window.settings;
8
+ useConversationStore.getState().reset();
9
+ });
10
+
11
+ const sendUserMessages = (count: number) => {
12
+ for (let index = 0; index < count; index += 1) {
13
+ useConversationStore.getState().addMessage({
14
+ role: 'user',
15
+ content: `Still searching ${index}`,
16
+ });
17
+ }
18
+ };
19
+
20
+ const addApiResponse = () => {
21
+ useConversationStore.getState().addMessage(
22
+ {
23
+ role: 'assistant',
24
+ content: 'Let us keep looking.',
25
+ options: [{ label: 'Continue search', action_type: 'filter' }],
26
+ },
27
+ { isApiResponse: true },
28
+ );
29
+ };
30
+
31
+ test('offers support within the third API reply, once per session', () => {
32
+ sendUserMessages(2);
33
+ addApiResponse();
34
+ expect(useConversationStore.getState().supportPromptShown).toBe(false);
35
+
36
+ sendUserMessages(1);
37
+ expect(useConversationStore.getState().supportPromptShown).toBe(false);
38
+ addApiResponse();
39
+ const state = useConversationStore.getState();
40
+ expect(state.userMessageCount).toBe(3);
41
+ expect(state.messages).toHaveLength(5);
42
+ expect(state.messages[state.messages.length - 1]).toMatchObject({
43
+ role: 'assistant',
44
+ content: 'Let us keep looking.',
45
+ options: [
46
+ { label: 'Continue search', action_type: 'filter' },
47
+ { label: 'Request support', action_type: 'request_support' },
48
+ ],
49
+ });
50
+
51
+ sendUserMessages(2);
52
+ addApiResponse();
53
+ expect(
54
+ useConversationStore
55
+ .getState()
56
+ .messages.filter(message =>
57
+ message.options?.some(
58
+ option => option.action_type === 'request_support',
59
+ ),
60
+ ),
61
+ ).toHaveLength(1);
62
+ });
63
+
64
+ test('waits for a response and excludes assistant and empty user messages', () => {
65
+ const store = useConversationStore.getState();
66
+ store.addMessage({ role: 'assistant', content: 'How can I help?' });
67
+ store.addMessage({ role: 'user', content: ' ' });
68
+ expect(useConversationStore.getState().userMessageCount).toBe(0);
69
+ sendUserMessages(3);
70
+ store.setIsLoading(true);
71
+ expect(useConversationStore.getState().supportPromptShown).toBe(false);
72
+ store.setIsLoading(false);
73
+ expect(useConversationStore.getState().supportPromptShown).toBe(false);
74
+ store.addMessage({ role: 'assistant', content: 'Something went wrong.' });
75
+ expect(useConversationStore.getState().supportPromptShown).toBe(false);
76
+ addApiResponse();
77
+ expect(useConversationStore.getState().supportPromptShown).toBe(true);
78
+ });
79
+
80
+ test('does not offer support after a successful outcome', () => {
81
+ sendUserMessages(3);
82
+ useConversationStore.getState().setSearchResolved(true);
83
+ addApiResponse();
84
+ expect(useConversationStore.getState().supportPromptShown).toBe(false);
85
+ });
86
+
87
+ test('offers support on the third reply when recovery exits without another API call', () => {
88
+ sendUserMessages(3);
89
+ useConversationStore.getState().addMessage({
90
+ role: 'assistant',
91
+ content: 'I was not able to find a match.',
92
+ action: { type: 'exit' },
93
+ });
94
+ const state = useConversationStore.getState();
95
+ expect(state.supportPromptShown).toBe(true);
96
+ expect(state.messages[state.messages.length - 1].options).toContainEqual({
97
+ label: 'Request support',
98
+ action_type: 'request_support',
99
+ });
100
+ });
101
+
102
+ test('a new session resets message counting and allows another support prompt', () => {
103
+ sendUserMessages(3);
104
+ addApiResponse();
105
+ useConversationStore.getState().startNewSession();
106
+ expect(useConversationStore.getState()).toMatchObject({
107
+ userMessageCount: 0,
108
+ supportPromptShown: false,
109
+ searchResolved: false,
110
+ });
111
+ sendUserMessages(3);
112
+ addApiResponse();
113
+ expect(useConversationStore.getState().supportPromptShown).toBe(true);
114
+ });
115
+
116
+ test('does not offer support when vizo.requestSupport is disabled', () => {
117
+ window.settings.vizo!.requestSupport = false;
118
+ sendUserMessages(3);
119
+ addApiResponse();
120
+ const state = useConversationStore.getState();
121
+ expect(state.supportPromptShown).toBe(false);
122
+ expect(state.messages[state.messages.length - 1].options).not.toContainEqual(
123
+ expect.objectContaining({ action_type: 'request_support' }),
124
+ );
125
+ });
126
+
127
+ test('offers support even when the inquiry banner and email template are disabled', () => {
128
+ window.settings.support.emailInquiry = false;
129
+ window.settings.support.emailTemplateId = '';
130
+ sendUserMessages(3);
131
+ addApiResponse();
132
+ expect(useConversationStore.getState().supportPromptShown).toBe(true);
133
+ });
@@ -39,7 +39,8 @@ export interface QuickReplyOption {
39
39
  | 'restore_previous'
40
40
  | 'related_part'
41
41
  | 'find_related_parts'
42
- | 'restore_original';
42
+ | 'restore_original'
43
+ | 'request_support';
43
44
  value?: string;
44
45
  key?: string;
45
46
  }
@@ -72,7 +73,7 @@ export interface RelatedPartSuggestion {
72
73
  interface ConversationState {
73
74
  // Session state
74
75
  sessionId: string;
75
- isEvaluated: boolean; // P2 has fired
76
+ isEvaluated: boolean; // P2 has fired
76
77
  // Routing
77
78
  route: ConversationRoute;
78
79
  quality: ResultQuality;
@@ -84,6 +85,9 @@ interface ConversationState {
84
85
  // Conversation
85
86
  messages: ChatMessage[];
86
87
  activeFilters: ActiveFilter[];
88
+ userMessageCount: number;
89
+ supportPromptShown: boolean;
90
+ searchResolved: boolean;
87
91
 
88
92
  // UI state
89
93
  isOpen: boolean;
@@ -126,8 +130,12 @@ interface ConversationActions {
126
130
  ) => void;
127
131
 
128
132
  // Messages
129
- addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'>) => void;
133
+ addMessage: (
134
+ message: Omit<ChatMessage, 'id' | 'timestamp'>,
135
+ options?: { isApiResponse?: boolean },
136
+ ) => void;
130
137
  clearMessages: () => void;
138
+ setSearchResolved: (resolved: boolean) => void;
131
139
 
132
140
  // Filters (for P3)
133
141
  addFilter: (key: string, value: string) => void;
@@ -172,6 +180,9 @@ const initialState: ConversationState = {
172
180
  attemptCounter: 0,
173
181
  messages: [],
174
182
  activeFilters: [],
183
+ userMessageCount: 0,
184
+ supportPromptShown: false,
185
+ searchResolved: false,
175
186
  isOpen: false, // Start closed so left panel is open by default
176
187
  hasNotification: false,
177
188
  isLoading: false,
@@ -240,20 +251,50 @@ const useConversationStore = create<ConversationState & ConversationActions>()(
240
251
  }));
241
252
  },
242
253
 
243
- addMessage: message =>
244
- set(state => ({
245
- messages: [
246
- ...state.messages,
247
- {
248
- ...message,
249
- id: generateId(),
250
- timestamp: Date.now(),
251
- },
252
- ],
253
- })),
254
+ addMessage: (message, { isApiResponse = false } = {}) =>
255
+ set(state => {
256
+ const shouldOfferSupport =
257
+ window.settings?.vizo?.requestSupport &&
258
+ (isApiResponse || message.action?.type === 'exit') &&
259
+ message.role === 'assistant' &&
260
+ state.userMessageCount > 2 &&
261
+ !state.supportPromptShown &&
262
+ !state.searchResolved;
263
+
264
+ return {
265
+ userMessageCount:
266
+ state.userMessageCount +
267
+ (message.role === 'user' &&
268
+ (message.content.trim() || message.imageUrl)
269
+ ? 1
270
+ : 0),
271
+ supportPromptShown: state.supportPromptShown || shouldOfferSupport,
272
+ messages: [
273
+ ...state.messages,
274
+ {
275
+ ...message,
276
+ id: generateId(),
277
+ timestamp: Date.now(),
278
+ ...(shouldOfferSupport
279
+ ? {
280
+ options: [
281
+ ...(message.options || []),
282
+ {
283
+ label: 'Request support',
284
+ action_type: 'request_support' as const,
285
+ },
286
+ ],
287
+ }
288
+ : {}),
289
+ },
290
+ ],
291
+ };
292
+ }),
254
293
 
255
294
  clearMessages: () => set({ messages: [] }),
256
295
 
296
+ setSearchResolved: searchResolved => set({ searchResolved }),
297
+
257
298
  addFilter: (key, value) =>
258
299
  set(state => ({
259
300
  activeFilters: [
package/src/types.ts CHANGED
@@ -133,6 +133,7 @@ export interface AppSettings extends NyrisAPISettings {
133
133
  fallbackToElasticSearch?: boolean;
134
134
  chat?: boolean;
135
135
  findRelatedParts?: boolean;
136
+ requestSupport?: boolean;
136
137
  };
137
138
  headerText?: string;
138
139
  instantRedirectPatterns: string[];
@@ -0,0 +1,130 @@
1
+ import {
2
+ getChatAttributeFrequency,
3
+ getChatProductAttributes,
4
+ getChatProductMetadata,
5
+ } from './chatProductMetadata';
6
+
7
+ test('selects only curated primitive filter traits without mutating metadata', () => {
8
+ const metadata = {
9
+ brand: 'ACME',
10
+ voltage: '24V DC',
11
+ current: 0,
12
+ connector: false,
13
+ product_type: 'PUMP',
14
+ sku: 'pump-1',
15
+ title: 'Unique pump title',
16
+ description: 'Unique pump description',
17
+ customIds: { mpn: 'MPN-1' },
18
+ attributes: { pressure: 10 },
19
+ metadata: { customAttribute: 'value' },
20
+ _tags: { color: 'blue' },
21
+ material: { name: 'steel' },
22
+ category: ['Pumps'],
23
+ size: null,
24
+ dimensions: undefined,
25
+ color: ' ',
26
+ power: NaN,
27
+ model: Infinity,
28
+ };
29
+ const snapshot = { ...metadata };
30
+
31
+ expect(getChatProductAttributes(metadata)).toEqual({
32
+ brand: 'ACME',
33
+ voltage: '24V DC',
34
+ current: 0,
35
+ connector: false,
36
+ product_type: 'PUMP',
37
+ });
38
+ expect(metadata).toEqual(snapshot);
39
+ });
40
+
41
+ test('counts trait values, not full metadata or object stringifications', () => {
42
+ expect(
43
+ getChatAttributeFrequency([
44
+ {
45
+ metadata: {
46
+ brand: 'ACME',
47
+ voltage: '24V DC',
48
+ sku: 'pump-1',
49
+ attributes: { material: 'steel' },
50
+ },
51
+ },
52
+ {
53
+ metadata: {
54
+ brand: 'ACME',
55
+ voltage: '12V DC',
56
+ sku: 'pump-2',
57
+ material: { name: 'steel' },
58
+ },
59
+ },
60
+ { metadata: null },
61
+ ]),
62
+ ).toEqual({
63
+ brand: { ACME: 2 },
64
+ voltage: { '24V DC': 1, '12V DC': 1 },
65
+ });
66
+ expect(getChatAttributeFrequency([])).toEqual({});
67
+ });
68
+
69
+ test('counts trait values that match object prototype properties', () => {
70
+ const frequency = getChatAttributeFrequency([
71
+ { metadata: { model: '__proto__', series: 'constructor' } },
72
+ { metadata: { model: '__proto__', series: 'constructor' } },
73
+ ]);
74
+ expect(frequency.model['__proto__']).toBe(2);
75
+ expect(frequency.series.constructor).toBe(2);
76
+ });
77
+
78
+ test('preserves all non-URL metadata, including nested and falsy values', () => {
79
+ const metadata = {
80
+ sku: 'pump-1',
81
+ description: ' Complete description '.repeat(30),
82
+ customIds: { mpn: 'MPN-1', gtin: '123456' },
83
+ attributes: [
84
+ { label: 'pressure', value: 0, unit: 'bar' },
85
+ { label: 'available', value: false },
86
+ ],
87
+ metadata: { security: 'IP67', empty: '', spaces: ' ', unknown: null },
88
+ _tags: { productType: 'pump', material: 'steel' },
89
+ };
90
+
91
+ expect(getChatProductMetadata(metadata)).toEqual(metadata);
92
+ });
93
+
94
+ test('removes URL fields and URL values recursively without mutating products', () => {
95
+ const product = {
96
+ sku: 'pump-1',
97
+ url: '/products/pump-1',
98
+ productURL: '/products/pump-1',
99
+ image_url_original: '/images/pump.jpg',
100
+ imageurl: '/images/pump.jpg',
101
+ attributes: {
102
+ documentationUri: '/documents/manual.pdf',
103
+ href: '/products/pump-1',
104
+ links: ['https://example.com/manual.pdf'],
105
+ manual: 'https://example.com/manual.pdf',
106
+ material: 'steel',
107
+ },
108
+ images: [
109
+ 'https://example.com/pump.jpg',
110
+ { url: '/images/pump.jpg', width: 300 },
111
+ ],
112
+ references: [
113
+ 'MPN-1',
114
+ '//example.com/pump',
115
+ 'blob:https://example.com/image',
116
+ 'data:image/png;base64,aGVsbG8=',
117
+ ],
118
+ description: 'Pump details at https://example.com/pump with steel housing',
119
+ };
120
+ const snapshot = JSON.stringify(product);
121
+
122
+ expect(getChatProductMetadata(product)).toEqual({
123
+ sku: 'pump-1',
124
+ attributes: { material: 'steel' },
125
+ images: [{ width: 300 }],
126
+ references: ['MPN-1'],
127
+ description: 'Pump details at with steel housing',
128
+ });
129
+ expect(JSON.stringify(product)).toBe(snapshot);
130
+ });
@@ -0,0 +1,93 @@
1
+ const urlField = /(?:^|_)(?:urls?|uris?|hrefs?|links?)(?:_|$)/i;
2
+ const urlValue = /(?:https?:\/\/|ftp:\/\/|\/\/|blob:|data:)[^\s"'<>]+/gi;
3
+
4
+ const attributeFields = [
5
+ 'voltage',
6
+ 'size',
7
+ 'brand',
8
+ 'manufacturer',
9
+ 'material',
10
+ 'type',
11
+ 'category',
12
+ 'color',
13
+ 'dimensions',
14
+ 'power',
15
+ 'current',
16
+ 'connector',
17
+ 'model',
18
+ 'series',
19
+ 'product_line',
20
+ 'product_type',
21
+ ];
22
+
23
+ /** Keep filter traits separate from the complete product context sent to chat. */
24
+ export function getChatProductAttributes(
25
+ metadata: Record<string, unknown>,
26
+ ): Record<string, string | number | boolean> {
27
+ return Object.fromEntries(
28
+ Object.entries(metadata).filter(
29
+ (entry): entry is [string, string | number | boolean] => {
30
+ const [key, value] = entry;
31
+ return (
32
+ attributeFields.includes(key) &&
33
+ ((typeof value === 'string' && value.trim().length > 0) ||
34
+ (typeof value === 'number' && Number.isFinite(value)) ||
35
+ typeof value === 'boolean')
36
+ );
37
+ },
38
+ ),
39
+ );
40
+ }
41
+
42
+ export function getChatAttributeFrequency(
43
+ products: ReadonlyArray<{ metadata: Record<string, unknown> | null }>,
44
+ ): Record<string, Record<string, number>> {
45
+ const frequency = new Map<string, Map<string, number>>();
46
+ products.forEach(({ metadata }) => {
47
+ Object.entries(getChatProductAttributes(metadata || {})).forEach(
48
+ ([key, value]) => {
49
+ const counts = frequency.get(key) || new Map<string, number>();
50
+ const text = String(value);
51
+ counts.set(text, (counts.get(text) || 0) + 1);
52
+ frequency.set(key, counts);
53
+ },
54
+ );
55
+ });
56
+ return Object.fromEntries(
57
+ Array.from(frequency, ([key, counts]) => [key, Object.fromEntries(counts)]),
58
+ );
59
+ }
60
+
61
+ function withoutUrls(value: unknown): unknown {
62
+ if (typeof value === 'string') {
63
+ const text = value.replace(urlValue, '');
64
+ return text !== value && !text.trim() ? undefined : text;
65
+ }
66
+ if (Array.isArray(value)) {
67
+ return value.map(withoutUrls).filter(item => item !== undefined);
68
+ }
69
+ if (value !== null && typeof value === 'object') {
70
+ return getChatProductMetadata(value);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /** Preserve product metadata, including nested attributes, without URL fields or values. */
76
+ export function getChatProductMetadata(
77
+ product: object,
78
+ ): Record<string, unknown> {
79
+ return Object.fromEntries(
80
+ Object.entries(product)
81
+ .filter(([key]) => {
82
+ const normalizedKey = key
83
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
84
+ .replace(/[^a-z0-9]/gi, '_');
85
+ return (
86
+ !urlField.test(normalizedKey) &&
87
+ !/(?:urls?|uris?|hrefs?)$/i.test(normalizedKey)
88
+ );
89
+ })
90
+ .map(([key, value]) => [key, withoutUrls(value)])
91
+ .filter(([, value]) => value !== undefined),
92
+ );
93
+ }
@@ -28,9 +28,9 @@ export const compressImage = async (input: string | Blob): Promise<string> => {
28
28
 
29
29
  return new Promise<string>((resolve, reject) => {
30
30
  new Compressor(blob, {
31
- quality: 0.91,
32
- maxHeight: 1024,
33
- maxWidth: 1024,
31
+ quality: window.settings.jpegQuality,
32
+ maxHeight: window.settings.maxHeight,
33
+ maxWidth: window.settings.maxWidth,
34
34
  strict: true,
35
35
  success: compressedResult => {
36
36
  const reader = new FileReader();